Skip to content

Commit 5cf6299

Browse files
authored
feat: best practice implementing intervals (#805) (#865)
Signed-off-by: Tronje Krop <tronje.krop@jactors.de>
1 parent db9c5b8 commit 5cf6299

2 files changed

Lines changed: 223 additions & 1 deletion

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ check-rules-duplicates:
3939
fi;
4040

4141
check-rules-incorrects:
42-
@INCORRECT="$$(grep -rnE "(\[[0-9]{1,4}\]|\[ +#?[0-9]+ +\])" $(DIRCONTENTS))"; \
42+
@INCORRECT="$$(grep -rnE "(^\[[0-9]{1,4}\]|\[ +#?[0-9]+ +\])" $(DIRCONTENTS))"; \
4343
if [ -n "$${INCORRECT}" ]; then \
4444
echo -e "Incorrect Rule IDs:\n $${INCORRECT}"; \
4545
echo "Please make sure that the Rule ID anchors conform to '\[#[0-9]+\]'"; \

chapters/best-practices.adoc

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,225 @@ following one:
381381
@JsonAnySetter
382382
private Map<String, JsonNode> additionalProperties = new HashMap<>();
383383
----
384+
385+
[[implementing-intervals]]
386+
== Implementing Intervals in RESTful APIs
387+
388+
Unfortunately the out-of-the-box library support for open intervals in most
389+
programming languages is very limited. As a consequence, you will likely need
390+
to implement your own parser for ISO 8601 compliant intervals, which also supports open boundaries using `..`. The following sections provide example implementations for Java/Kotlin/Scala, Go, and Python.
391+
392+
[[intervals-java]]
393+
=== In Java/Kotlin/Scala
394+
395+
In Java/Kotlin/Scala you can use the Time4J library, which provides
396+
a powerful and flexible API for working with date and time, including support
397+
for intervals. However, it does not provide a built-in parser for ISO 8601 compliant intervals, but a custom format using `-` to signal open ends.
398+
399+
A common approach to implement ISO 8601 compliant interval parsing is to use regular expressions to identify and transform the input string into the format expected by Time4J. Below is an example implementation of such a parser:
400+
401+
[source,java]
402+
----
403+
import java.util.regex.Pattern;
404+
import net.time4j.range.MomentInterval;
405+
import org.slf4j.Logger;
406+
import org.slf4j.LoggerFactory;
407+
408+
public class IntervalParser {
409+
private static final Logger log =
410+
LoggerFactory.getLogger(IntervalParser.class);
411+
412+
// Pre-compile the patterns for performance
413+
private static final Pattern ISO_INCOMPLIANT_INTERVAL =
414+
Pattern.compile("(^-/|/-$)");
415+
private static final Pattern ISO_OPEN_START_INTERVAL =
416+
Pattern.compile("^(\\.\\.)?/");
417+
private static final Pattern ISO_OPEN_END_INTERVAL =
418+
Pattern.compile("/(\\.\\.)?$");
419+
420+
public MomentInterval parseDateTimeInterval(String interval) {
421+
if (ISO_INCOMPLIANT_INTERVAL.matcher(interval).find()) {
422+
return null;
423+
}
424+
425+
try {
426+
// Apply the regex replacements sequentially
427+
String formattedInterval = ISO_OPEN_START_INTERVAL
428+
.matcher(interval).replaceAll("-/");
429+
430+
formattedInterval = ISO_OPEN_END_INTERVAL
431+
.matcher(formattedInterval).replaceAll("/-");
432+
433+
return MomentInterval.parseISO(formattedInterval);
434+
435+
} catch (Exception except) {
436+
log.debug("parsing interval failed", except);
437+
return null;
438+
}
439+
}
440+
}
441+
----
442+
443+
[[intervals-go]]
444+
=== In Go
445+
446+
In Go, the approach is a bit more complex, since there is no built-in library
447+
for intervals and we also need to implement the parsing logic for ISO 8601
448+
durations due to the lack of native duration support. Below is an example
449+
implementation of an ISO 8601 compliant interval and duration parser in Go:
450+
451+
[source,go]
452+
----
453+
package interval
454+
455+
import (
456+
"fmt"
457+
"regexp"
458+
"strconv"
459+
"strings"
460+
"time"
461+
)
462+
463+
type MomentInterval struct {
464+
Start *time.Time
465+
End *time.Time
466+
}
467+
468+
// Regex to extract Days, Hours, Minutes, and Seconds from an ISO duration (e.g., P6DT2H5M)
469+
var durationRegex = regexp.MustCompile(`^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$`)
470+
471+
// parseISODuration converts an ISO duration string into a native time.Duration
472+
func parseISODuration(str string) (time.Duration, error) {
473+
matches := durationRegex.FindStringSubmatch(str)
474+
if matches == nil {
475+
return 0, fmt.Errorf("invalid or unsupported ISO duration")
476+
}
477+
478+
var total time.Duration
479+
480+
// Note: We only parse exact time units (Days, Hours, Minutes, Seconds).
481+
if matches[1] != "" { // Days (assumed 24 hours for machine-time math)
482+
d, _ := strconv.Atoi(matches[1])
483+
total += time.Duration(d) * 24 * time.Hour
484+
}
485+
if matches[2] != "" { // Hours
486+
h, _ := strconv.Atoi(matches[2])
487+
total += time.Duration(h) * time.Hour
488+
}
489+
if matches[3] != "" { // Minutes
490+
m, _ := strconv.Atoi(matches[3])
491+
total += time.Duration(m) * time.Minute
492+
}
493+
if matches[4] != "" { // Seconds
494+
s, _ := strconv.Atoi(matches[4])
495+
total += time.Duration(s) * time.Second
496+
}
497+
498+
return total, nil
499+
}
500+
501+
func ParseDateTimeInterval(interval string) *MomentInterval {
502+
parts := strings.Split(interval, "/")
503+
if len(parts) != 2 {
504+
return nil
505+
}
506+
507+
startStr := parts[0]
508+
endStr := parts[1]
509+
510+
if startStr == "-" || endStr == "-" {
511+
return nil
512+
}
513+
514+
result := &MomentInterval{}
515+
516+
// Identify if either side is a duration (starts with 'P')
517+
isStartDuration := strings.HasPrefix(startStr, "P")
518+
isEndDuration := strings.HasPrefix(endStr, "P")
519+
520+
// An interval cannot be made of two durations
521+
if isStartDuration && isEndDuration {
522+
return nil
523+
}
524+
525+
// 1. Parse the Start boundary (if it is a timestamp or open)
526+
if !isStartDuration && startStr != "" && startStr != ".." {
527+
if t, err := time.Parse(time.RFC3339, startStr); err != nil {
528+
return nil
529+
}
530+
result.Start = &t
531+
}
532+
533+
// 2. Parse the End boundary (if it is a timestamp or open)
534+
if !isEndDuration && endStr != "" && endStr != ".." {
535+
if t, err := time.Parse(time.RFC3339, endStr); err != nil {
536+
return nil
537+
}
538+
result.End = &t
539+
}
540+
541+
// 3. Resolve Durations
542+
if isStartDuration {
543+
// Duration/End scenario -> Calculate Start
544+
if result.End == nil {
545+
return nil // Cannot subtract a duration from an open/infinite end
546+
} else if d, err := parseISODuration(startStr); err != nil {
547+
return nil
548+
}
549+
t := result.End.Add(-d)
550+
result.Start = &t
551+
552+
} else if isEndDuration {
553+
// Start/Duration scenario -> Calculate End
554+
if result.Start == nil {
555+
return nil // Cannot add a duration to an open/infinite start
556+
} else if d, err := parseISODuration(endStr); err != nil {
557+
return nil
558+
}
559+
t := result.Start.Add(d)
560+
result.End = &t
561+
}
562+
563+
return result
564+
}
565+
----
566+
567+
[[intervals-python]]
568+
=== In Python
569+
570+
In Python, the approach is more straightforward, since there are libraries like
571+
`aniso8601`, that provide built-in support for parsing ISO 8601 compliant
572+
intervals without open boundaries (".."). Below is an example implementation of
573+
how such a parser can be used to implement ISO 8601 compliant interval parsing
574+
in Python, including handling of open boundaries:
575+
576+
[source,python]
577+
----
578+
import aniso8601
579+
from datetime import datetime
580+
from typing import Optional, Tuple
581+
582+
def parse_datetime_interval(interval: str) -> Optional[Tuple[datetime, datetime]]:
583+
if interval.startswith("-/") or interval.endswith("/-"):
584+
return None
585+
586+
try:
587+
# 2. Intercept open boundaries ("..") manually
588+
if interval.startswith("../"):
589+
end_str = interval.split("/")[1].replace("Z", "+00:00")
590+
return (None, datetime.fromisoformat(end_str))
591+
592+
if interval.endswith("/.."):
593+
start_str = interval.split("/")[0].replace("Z", "+00:00")
594+
return (datetime.fromisoformat(start_str), None)
595+
596+
# 3. THE SHORTCUT: aniso8601 handles Start/End, Start/Duration, and
597+
# Duration/End! (We use sorted() because if the string is Duration/End,
598+
# aniso8601 returns the tuple backwards as (End, Start))
599+
start, end = sorted(aniso8601.parse_interval(interval))
600+
601+
return (start, end)
602+
603+
except Exception:
604+
return None
605+
----

0 commit comments

Comments
 (0)