From e6539b00ccda2b9e18bca10b442f9a557fb7c53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Thu, 4 Jan 2024 15:39:37 +0100 Subject: [PATCH 1/2] #1854 enhance time:now* placeholders to calculate plus and minus from now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * and to optionally truncate to a unit (rounding down) Signed-off-by: Thomas Jäckle --- .../base/model/common/DittoDuration.java | 32 +++- .../base/model/common/DittoDurationTest.java | 8 + .../DittoDurationValueValidatorTest.java | 2 +- .../headers/TimeoutValueValidatorTest.java | 2 +- .../ImmutableTimePlaceholder.java | 145 +++++++++++++++++- .../ImmutableTimePlaceholderTest.java | 102 ++++++++++-- 6 files changed, 258 insertions(+), 33 deletions(-) diff --git a/base/model/src/main/java/org/eclipse/ditto/base/model/common/DittoDuration.java b/base/model/src/main/java/org/eclipse/ditto/base/model/common/DittoDuration.java index a8b02dcdef..ae22b18f8d 100644 --- a/base/model/src/main/java/org/eclipse/ditto/base/model/common/DittoDuration.java +++ b/base/model/src/main/java/org/eclipse/ditto/base/model/common/DittoDuration.java @@ -18,7 +18,9 @@ import java.text.MessageFormat; import java.time.Duration; import java.time.temporal.ChronoUnit; +import java.util.Arrays; import java.util.Objects; +import java.util.Optional; import java.util.function.LongFunction; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -201,7 +203,12 @@ public int hashCode() { return Objects.hash(amount, dittoTimeUnit); } - private enum DittoTimeUnit { + /** + * Enumeration providing the supported time units of {@link DittoDuration}, together with their {@link ChronoUnit}. + * + * @since 3.5.0 + */ + public enum DittoTimeUnit { // The order matters as we expect seconds to be the main unit. // By making it the first constant, parsing a duration from string will be accelerated. @@ -209,7 +216,8 @@ private enum DittoTimeUnit { SECONDS_IMPLICIT("", Duration::ofSeconds, ChronoUnit.SECONDS), MILLISECONDS("ms", Duration::ofMillis, ChronoUnit.MILLIS), MINUTES("m", Duration::ofMinutes, ChronoUnit.MINUTES), - HOURS("h", Duration::ofHours, ChronoUnit.HOURS); + HOURS("h", Duration::ofHours, ChronoUnit.HOURS), + DAYS("d", Duration::ofDays, ChronoUnit.DAYS); private final String suffix; private final LongFunction toJavaDuration; @@ -223,19 +231,31 @@ private enum DittoTimeUnit { regexPattern = Pattern.compile("(?[+-]?\\d++)(?" + suffix + ")"); } - private Matcher getRegexMatcher(final CharSequence duration) { + /** + * Find a DittoTimeUnit option by a provided suffix string. + * + * @param suffix the suffix. + * @return the DittoTimeUnit with the given suffix string if any exists. + */ + public static Optional forSuffix(final String suffix) { + return Arrays.stream(values()) + .filter(unit -> unit.getSuffix().equals(suffix)) + .findAny(); + } + + public Matcher getRegexMatcher(final CharSequence duration) { return regexPattern.matcher(duration); } - private String getSuffix() { + public String getSuffix() { return suffix; } - private Duration getJavaDuration(final long amount) { + public Duration getJavaDuration(final long amount) { return toJavaDuration.apply(amount); } - private ChronoUnit getChronoUnit() { + public ChronoUnit getChronoUnit() { return chronoUnit; } diff --git a/base/model/src/test/java/org/eclipse/ditto/base/model/common/DittoDurationTest.java b/base/model/src/test/java/org/eclipse/ditto/base/model/common/DittoDurationTest.java index d755d1e79e..9a7a603e3e 100644 --- a/base/model/src/test/java/org/eclipse/ditto/base/model/common/DittoDurationTest.java +++ b/base/model/src/test/java/org/eclipse/ditto/base/model/common/DittoDurationTest.java @@ -104,6 +104,14 @@ public void createDittoDurationFromStringMilliseconds() { assertThat(dittoDuration.getDuration()).isEqualTo(Duration.ofMillis(durationValue)); } + @Test + public void createDittoDurationFromStringDays() { + final short durationValue = 7; + final DittoDuration dittoDuration = DittoDuration.parseDuration(durationValue + "d"); + + assertThat(dittoDuration.getDuration()).isEqualTo(Duration.ofDays(durationValue)); + } + @Test public void createDittoDurationFromStringWithAndWithoutSecondsIsEqual() { final byte durationValue = 23; diff --git a/base/model/src/test/java/org/eclipse/ditto/base/model/headers/DittoDurationValueValidatorTest.java b/base/model/src/test/java/org/eclipse/ditto/base/model/headers/DittoDurationValueValidatorTest.java index a5e279831e..721230ff2b 100644 --- a/base/model/src/test/java/org/eclipse/ditto/base/model/headers/DittoDurationValueValidatorTest.java +++ b/base/model/src/test/java/org/eclipse/ditto/base/model/headers/DittoDurationValueValidatorTest.java @@ -111,7 +111,7 @@ public void acceptDittoDurationStringWithNegativeAmount() { @Test public void acceptDittoDurationStringWithInvalidTimeUnit() { - final String invalidDittoDurationString = "1d"; + final String invalidDittoDurationString = "1w"; assertThatExceptionOfType(DittoHeaderInvalidException.class) .isThrownBy(() -> underTest.accept(DittoHeaderDefinition.TIMEOUT, invalidDittoDurationString)) diff --git a/base/model/src/test/java/org/eclipse/ditto/base/model/headers/TimeoutValueValidatorTest.java b/base/model/src/test/java/org/eclipse/ditto/base/model/headers/TimeoutValueValidatorTest.java index d0d1c899d1..6d04d9d3bf 100644 --- a/base/model/src/test/java/org/eclipse/ditto/base/model/headers/TimeoutValueValidatorTest.java +++ b/base/model/src/test/java/org/eclipse/ditto/base/model/headers/TimeoutValueValidatorTest.java @@ -111,7 +111,7 @@ public void acceptDittoDurationStringWithNegativeAmount() { @Test public void acceptDittoDurationStringWithInvalidTimeUnit() { - final String invalidDittoDurationString = "1d"; + final String invalidDittoDurationString = "1y"; assertThatExceptionOfType(TimeoutInvalidException.class) .isThrownBy(() -> underTest.accept(DittoHeaderDefinition.TIMEOUT, invalidDittoDurationString)) diff --git a/placeholders/src/main/java/org/eclipse/ditto/placeholders/ImmutableTimePlaceholder.java b/placeholders/src/main/java/org/eclipse/ditto/placeholders/ImmutableTimePlaceholder.java index de45442433..4db60c7058 100644 --- a/placeholders/src/main/java/org/eclipse/ditto/placeholders/ImmutableTimePlaceholder.java +++ b/placeholders/src/main/java/org/eclipse/ditto/placeholders/ImmutableTimePlaceholder.java @@ -13,13 +13,15 @@ package org.eclipse.ditto.placeholders; import java.time.Instant; -import java.util.Arrays; +import java.time.temporal.ChronoUnit; import java.util.Collections; import java.util.List; +import javax.annotation.Nullable; import javax.annotation.concurrent.Immutable; import org.eclipse.ditto.base.model.common.ConditionChecker; +import org.eclipse.ditto.base.model.common.DittoDuration; /** * Placeholder implementation that replaces: @@ -42,8 +44,12 @@ final class ImmutableTimePlaceholder implements TimePlaceholder { private static final String NOW_PLACEHOLDER = "now"; private static final String NOW_EPOCH_MILLIS_PLACEHOLDER = "now_epoch_millis"; - private static final List SUPPORTED = Collections.unmodifiableList( - Arrays.asList(NOW_PLACEHOLDER, NOW_EPOCH_MILLIS_PLACEHOLDER)); + private static final String TRUNCATE_START; + private static final String TRUNCATE_END = "]"; + + static { + TRUNCATE_START = "["; + } private ImmutableTimePlaceholder() { } @@ -55,25 +61,148 @@ public String getPrefix() { @Override public List getSupportedNames() { - return SUPPORTED; + return Collections.emptyList(); } @Override public boolean supports(final String name) { - return SUPPORTED.contains(name); + return startsWithNowPrefix(name) && containsEmptyOrValidTimeRangeDefinition(name); } @Override public List resolveValues(final Object someObject, final String placeholder) { ConditionChecker.argumentNotEmpty(placeholder, "placeholder"); + final Instant now = Instant.now(); switch (placeholder) { case NOW_PLACEHOLDER: - return Collections.singletonList(Instant.now().toString()); + return Collections.singletonList(now.toString()); case NOW_EPOCH_MILLIS_PLACEHOLDER: - return Collections.singletonList(String.valueOf(Instant.now().toEpochMilli())); + return Collections.singletonList(formatAsEpochMilli(now)); default: - return Collections.emptyList(); + return resolveWithPotentialTimeRangeSuffix(now, placeholder); + } + } + + private static boolean startsWithNowPrefix(final String name) { + return name.startsWith(NOW_EPOCH_MILLIS_PLACEHOLDER) || name.startsWith(NOW_PLACEHOLDER) ; + } + + private boolean containsEmptyOrValidTimeRangeDefinition(final String placeholder) { + final String timeRangeSuffix = extractTimeRangeSuffix(placeholder); + return timeRangeSuffix.isEmpty() || isValidTimeRange(timeRangeSuffix); + } + + private static String formatAsEpochMilli(final Instant now) { + return String.valueOf(now.toEpochMilli()); + } + + private static String extractTimeRangeSuffix(final String placeholder) { + final String timeRangeSuffix; + if (placeholder.startsWith(NOW_EPOCH_MILLIS_PLACEHOLDER)) { + timeRangeSuffix = placeholder.replace(NOW_EPOCH_MILLIS_PLACEHOLDER, ""); + } else if (placeholder.startsWith(NOW_PLACEHOLDER)) { + timeRangeSuffix = placeholder.replace(NOW_PLACEHOLDER, ""); + } else { + throw new IllegalStateException("Unsupported placeholder prefix for TimePlaceholder: " + placeholder); + } + return timeRangeSuffix; + } + + private boolean isValidTimeRange(final String timeRangeSuffix) { + final char sign = timeRangeSuffix.charAt(0); + if (sign == '-' || sign == '+') { + final String durationWithTruncate = timeRangeSuffix.substring(1); + final String durationString; + if (durationWithTruncate.contains(TRUNCATE_START) && durationWithTruncate.contains(TRUNCATE_END)) { + final String[] durationStringAndTruncateString = durationWithTruncate.split(TRUNCATE_START, 2); + durationString = durationStringAndTruncateString[0]; + final String truncateString = durationStringAndTruncateString[1]; + if (!isValidTruncateStatement(truncateString.substring(0, truncateString.lastIndexOf(TRUNCATE_END)))) { + return false; + } + } else { + durationString = durationWithTruncate; + } + try { + DittoDuration.parseDuration(durationString); + return true; + } catch (final Exception e) { + return false; + } + } else { + return false; + } + } + + private boolean isValidTruncateStatement(final String truncateString) { + return DittoDuration.DittoTimeUnit.forSuffix(truncateString).isPresent(); + } + + private List resolveWithPotentialTimeRangeSuffix(final Instant now, final String placeholder) { + final String timeRangeSuffix = extractTimeRangeSuffix(placeholder); + if (timeRangeSuffix.isEmpty()) { + return Collections.emptyList(); + } + + @Nullable final ChronoUnit truncateTo = calculateTruncateTo(timeRangeSuffix); + + final char sign = timeRangeSuffix.charAt(0); + if (sign == '-') { + final DittoDuration dittoDuration = extractTimeRangeDuration(timeRangeSuffix); + Instant nowMinus = now.minus(dittoDuration.getDuration()); + if (truncateTo != null) { + nowMinus = nowMinus.truncatedTo(truncateTo); + } + return buildResult(placeholder, nowMinus); + } else if (sign == '+') { + final DittoDuration dittoDuration = extractTimeRangeDuration(timeRangeSuffix); + Instant nowPlus = now.plus(dittoDuration.getDuration()); + if (truncateTo != null) { + nowPlus = nowPlus.truncatedTo(truncateTo); + } + return buildResult(placeholder, nowPlus); + } else if (truncateTo != null) { + final Instant nowTruncated = now.truncatedTo(truncateTo); + return buildResult(placeholder, nowTruncated); + } + + return Collections.emptyList(); + } + + private static List buildResult(final String placeholder, final Instant nowMinus) { + if (placeholder.startsWith(NOW_EPOCH_MILLIS_PLACEHOLDER)) { + return Collections.singletonList(formatAsEpochMilli(nowMinus)); + } else if (placeholder.startsWith(NOW_PLACEHOLDER)) { + return Collections.singletonList(nowMinus.toString()); + } else { + return Collections.emptyList(); + } + } + + @Nullable + private static ChronoUnit calculateTruncateTo(final String timeRangeSuffix) { + if (timeRangeSuffix.contains(TRUNCATE_START) && timeRangeSuffix.contains(TRUNCATE_END)) { + final String truncateUnit = timeRangeSuffix.substring(timeRangeSuffix.indexOf(TRUNCATE_START) + 1, + timeRangeSuffix.lastIndexOf(TRUNCATE_END)); + final DittoDuration.DittoTimeUnit dittoTimeUnit = + DittoDuration.DittoTimeUnit.forSuffix(truncateUnit).orElseThrow(() -> + new IllegalStateException("Truncating string contained unsupported unit: " + truncateUnit) + ); + return dittoTimeUnit.getChronoUnit(); + } else { + return null; + } + } + + private static DittoDuration extractTimeRangeDuration(final String timeRangeSuffix) { + final int truncateStart = timeRangeSuffix.indexOf(TRUNCATE_START); + final String timeRange; + if (truncateStart > 0) { + timeRange = timeRangeSuffix.substring(0, truncateStart); + } else { + timeRange = timeRangeSuffix; } + return DittoDuration.parseDuration(timeRange.substring(1)); } @Override diff --git a/placeholders/src/test/java/org/eclipse/ditto/placeholders/ImmutableTimePlaceholderTest.java b/placeholders/src/test/java/org/eclipse/ditto/placeholders/ImmutableTimePlaceholderTest.java index 03e1b2eded..a29b41bac0 100644 --- a/placeholders/src/test/java/org/eclipse/ditto/placeholders/ImmutableTimePlaceholderTest.java +++ b/placeholders/src/test/java/org/eclipse/ditto/placeholders/ImmutableTimePlaceholderTest.java @@ -14,6 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; @@ -50,35 +51,102 @@ public void testHashCodeAndEquals() { @Test public void testReplaceCurrentTimestampIso() { - final List resolved = UNDER_TEST.resolveValues(SOME_OBJECT, "now").stream() + testWithTimePlaceholder("now", Instant.now()); + } + + @Test + public void testReplaceCurrentTimestampIsoMinusDays() { + testWithTimePlaceholder("now-2d", Instant.now().minus(Duration.ofDays(2))); + } + + @Test + public void testReplaceCurrentTimestampIsoPlusHours() { + testWithTimePlaceholder("now+8h", Instant.now().plus(Duration.ofHours(8))); + } + + @Test + public void testReplaceCurrentTimestampIsoMinusSeconds() { + testWithTimePlaceholder("now-45s", Instant.now().minus(Duration.ofSeconds(45))); + } + + @Test + public void testReplaceCurrentTimestampIsoTruncateDay() { + testWithTimePlaceholder("now[d]", Instant.now().truncatedTo(ChronoUnit.DAYS)); + } + + @Test + public void testReplaceCurrentTimestampIsoTruncateMinute() { + testWithTimePlaceholder("now[m]", Instant.now().truncatedTo(ChronoUnit.MINUTES)); + } + + @Test + public void testReplaceCurrentTimestampIsoMinusDaysTruncateDay() { + testWithTimePlaceholder("now-2d[d]", Instant.now().minus(Duration.ofDays(2)).truncatedTo(ChronoUnit.DAYS)); + } + + @Test + public void testReplaceCurrentTimestampIsoPlusHoursTruncateMinute() { + testWithTimePlaceholder("now+4h[m]", Instant.now().plus(Duration.ofHours(4)).truncatedTo(ChronoUnit.MINUTES)); + } + + @Test + public void testReplaceCurrentTimestampMillisSinceEpoch() { + testEpochWithTimePlaceholder("now_epoch_millis", Instant.now()); + } + + @Test + public void testReplaceCurrentTimestampMillisSinceEpochPlusDay() { + testEpochWithTimePlaceholder("now_epoch_millis+1d", Instant.now().plus(Duration.ofDays(1))); + } + + @Test + public void testReplaceCurrentTimestampMillisSinceEpochMinusMinutes() { + testEpochWithTimePlaceholder("now_epoch_millis-25m", Instant.now().minus(Duration.ofMinutes(25))); + } + + @Test + public void testReplaceCurrentTimestampMillisSinceEpochTruncateHour() { + testEpochWithTimePlaceholder("now_epoch_millis[h]", Instant.now().truncatedTo(ChronoUnit.HOURS)); + } + + @Test + public void testReplaceCurrentTimestampMillisSinceEpochTruncateMillisecond() { + testEpochWithTimePlaceholder("now_epoch_millis[ms]", Instant.now().truncatedTo(ChronoUnit.MILLIS)); + } + + @Test + public void testReplaceCurrentTimestampMillisSinceEpochPlusDaysTruncateDay() { + testWithTimePlaceholder("now+30d[d]", Instant.now().plus(Duration.ofDays(30)).truncatedTo(ChronoUnit.DAYS)); + } + + @Test + public void testReplaceCurrentTimestampMillisSinceEpochMinusSecondsTruncateHour() { + testWithTimePlaceholder("now-30s[h]", Instant.now().minus(Duration.ofSeconds(4)).truncatedTo(ChronoUnit.HOURS)); + } + + private static void testWithTimePlaceholder(final String placeholder, final Instant expectedTimestamp) { + final List resolved = UNDER_TEST.resolveValues(SOME_OBJECT, placeholder).stream() .map(Instant::parse) .collect(Collectors.toList()); assertThat(resolved) .hasSize(1) - .allSatisfy(i -> { - final Instant now = Instant.now(); - assertThat(i) - .isBefore(now) - .isCloseTo(now, new TemporalUnitLessThanOffset(1000, ChronoUnit.MILLIS)); - } + .allSatisfy(i -> + assertThat(i) + .isCloseTo(expectedTimestamp, new TemporalUnitLessThanOffset(1000, ChronoUnit.MILLIS)) ); } - @Test - public void testReplaceCurrentTimestampMillisSinceEpoch() { - final List resolved = UNDER_TEST.resolveValues(SOME_OBJECT, "now_epoch_millis").stream() + private static void testEpochWithTimePlaceholder(final String placeholder, final Instant expectedTimestamp) { + final List resolved = UNDER_TEST.resolveValues(SOME_OBJECT, placeholder).stream() .map(Long::parseLong) .map(Instant::ofEpochMilli) .collect(Collectors.toList()); assertThat(resolved) .hasSize(1) - .allSatisfy(i -> { - final Instant now = Instant.now(); - assertThat(i) - .isBefore(now) - .isCloseTo(now, new TemporalUnitLessThanOffset(1000, ChronoUnit.MILLIS)); - } - ); + .allSatisfy(i -> + assertThat(i) + .isCloseTo(expectedTimestamp, new TemporalUnitLessThanOffset(1000, ChronoUnit.MILLIS)) + ); } } From 0d7a748ce1342c2a7480ebff245299446d634af6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20J=C3=A4ckle?= Date: Thu, 4 Jan 2024 19:19:47 +0100 Subject: [PATCH 2/2] #1854 provide documentation about enhanced now placeholder functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Thomas Jäckle --- .../pages/ditto/basic-placeholders.md | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/documentation/src/main/resources/pages/ditto/basic-placeholders.md b/documentation/src/main/resources/pages/ditto/basic-placeholders.md index 5641d813f5..eef3a65595 100644 --- a/documentation/src/main/resources/pages/ditto/basic-placeholders.md +++ b/documentation/src/main/resources/pages/ditto/basic-placeholders.md @@ -94,10 +94,30 @@ Which placeholder values are available depends on the context where the placehol ### Time Placeholder -| Placeholder | Description | -|------------------------------------------------|-------------------------------------------------------------------------| -| `{%raw%}{{ time:now }}{%endraw%}` | the current timestamp in ISO-8601 format as string in UTC timezone | -| `{%raw%}{{ time:now_epoch_millis }}{%endraw%}` | the current timestamp in "milliseconds since epoch" formatted as string | +| Placeholder | Description | +|-----------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `{%raw%}{{ time:now }}{%endraw%}` | the current timestamp in ISO-8601 format as string in UTC timezone | +| `{%raw%}{{ time:now_epoch_millis }}{%endraw%}` | the current timestamp in "milliseconds since epoch" formatted as string | +| `{%raw%}{{ time:now<+-offset> }}{%endraw%}` | the current timestamp in ISO-8601 format as string in UTC timezone plus or minus the offset in format `` where unit is one of `ms s m h d` | +| `{%raw%}{{ time:now_epoch_millis<+-offset> }}{%endraw%}` | the current timestamp in "milliseconds since epoch" formatted as string plus or minus the offset in format `` where unit is one of `ms s m h d` | +| `{%raw%}{{ time:now[] }}{%endraw%}` | the current timestamp in ISO-8601 format as string in UTC timezone, truncated to the unit defined in square brackets, being one of `ms s m h d` | +| `{%raw%}{{ time:now_epoch_millis[] }}{%endraw%}` | the current timestamp in "milliseconds since epoch" formatted as string, truncated to the unit defined in square brackets, being one of `ms s m h d` | +| `{%raw%}{{ time:now<+-offset>[] }}{%endraw%}` | the current timestamp in ISO-8601 format as string in UTC timezone plus or minus the offset in format `` where unit is one of `ms s m h d`, truncated to the unit defined in square brackets, being one of `ms s m h d` | +| `{%raw%}{{ time:now_epoch_millis<+-offset>[] }}{%endraw%}` | the current timestamp in "milliseconds since epoch" formatted as string plus or minus the offset in format `` where unit is one of `ms s m h d`, truncated to the unit defined in square brackets, being one of `ms s m h d` | + +Examples - assuming that the `now` timestamp is: `2024-01-06T14:23:42.123Z` +``` +{%raw%}{{ time:now }}{%endraw%} # current ts: 2024-01-06T14:23:42.123Z +{%raw%}{{ time:now+1h }}{%endraw%} # current ts, +1 hour: 2024-01-06T15:23:42.123Z +{%raw%}{{ time:now-7d }}{%endraw%} # current ts, -7 days: 2023-12-31T14:23:42.123Z +{%raw%}{{ time:now[h] }}{%endraw%} # current ts, truncated to the hour: 2024-01-06T14:00:00.000Z +{%raw%}{{ time:now[d] }}{%endraw%} # current ts, truncated to the day: 2024-01-06T00:00:00.000Z +{%raw%}{{ time:now-25m[m] }}{%endraw%} # current ts, -25 minutes, truncated to the minute: 2024-01-06T13:58:00.000Z +{%raw%}{{ time:now+3d[s] }}{%endraw%} # current ts, +3 days, truncated to the second: 2024-01-09T14:23:42.000Z +``` + +The same offset and truncation can be done with the `now_epoch_millis`. + ### JWT Placeholder