From 1a1a895d6bb4381028a45115d17adf010e61b79d Mon Sep 17 00:00:00 2001 From: Alexandre Carlton Date: Sun, 17 Mar 2024 00:46:45 +1100 Subject: [PATCH] Add AccurateDuration and NominalDuration Scalars This adds support for two scalars: - `AccurateDuration` (which maps to a `java.time.Duration`). - `NominalDuration` (which maps to a `java.time.Period`). Both of these heavily relate to durations as defined in ISO 8601 but with the following caveats: - `AccurateDuration` only relates to the portion of a duration that is context-free (i.e. does not need to know the calendar position to determine its value). It maps to a `java.time.Duration`. - `NominalDuration` relates to the portion of a duration that is dependent on knowing the calendar position to determine its value. It maps to a `java.time.Period`. The naming of these are strongly influenced by the wording in ISO 8601: > Duration can be expressed by a combination of components with accurate duration (hour, minute and second) and components with nominal duration (year, month, week and day). The code here follows the same pattern as that used for the `DateTime` Scalar. --- .../java/graphql/scalars/ExtendedScalars.java | 31 ++++ .../datetime/AccurateDurationScalar.java | 93 ++++++++++++ .../datetime/NominalDurationScalar.java | 92 ++++++++++++ .../AccurateDurationScalarTest.groovy | 139 ++++++++++++++++++ .../datetime/NominalDurationScalarTest.groovy | 134 +++++++++++++++++ .../graphql/scalars/util/TestKit.groovy | 17 +++ 6 files changed, 506 insertions(+) create mode 100644 src/main/java/graphql/scalars/datetime/AccurateDurationScalar.java create mode 100644 src/main/java/graphql/scalars/datetime/NominalDurationScalar.java create mode 100644 src/test/groovy/graphql/scalars/datetime/AccurateDurationScalarTest.groovy create mode 100644 src/test/groovy/graphql/scalars/datetime/NominalDurationScalarTest.groovy diff --git a/src/main/java/graphql/scalars/ExtendedScalars.java b/src/main/java/graphql/scalars/ExtendedScalars.java index ce36232..214ff90 100644 --- a/src/main/java/graphql/scalars/ExtendedScalars.java +++ b/src/main/java/graphql/scalars/ExtendedScalars.java @@ -6,7 +6,9 @@ import graphql.scalars.currency.CurrencyScalar; import graphql.scalars.datetime.DateScalar; import graphql.scalars.datetime.DateTimeScalar; +import graphql.scalars.datetime.AccurateDurationScalar; import graphql.scalars.datetime.LocalTimeCoercing; +import graphql.scalars.datetime.NominalDurationScalar; import graphql.scalars.datetime.TimeScalar; import graphql.scalars.java.JavaPrimitives; import graphql.scalars.locale.LocaleScalar; @@ -85,6 +87,35 @@ public class ExtendedScalars { .coercing(new LocalTimeCoercing()) .build(); + /** + * A duration scalar that accepts string values like `P1DT2H3M4.5s` and produces * `java.time.Duration` objects at runtime. + *

+ * Components like years and months are not supported as these may have different meanings depending on the placement in the calendar year. + *

+ * Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods + * accept Duration and formatted Strings as valid objects. + *

+ * See the ISO 8601 for more details on the format. + * + * @see java.time.Duration + */ + public static final GraphQLScalarType AccurateDuration = AccurateDurationScalar.INSTANCE; + + /** + * An RFC-3339 compliant duration scalar that accepts string values like `P1Y2M3D` and produces + * `java.time.Period` objects at runtime. + *

+ * Components like hours and seconds are not supported as these are handled by {@link #AccurateDuration}. + *

+ * Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods + * accept Period and formatted Strings as valid objects. + *

+ * See the ISO 8601 for more details on the format. + * + * @see java.time.Period + */ + public static final GraphQLScalarType NominalDuration = NominalDurationScalar.INSTANCE; + /** * An object scalar allows you to have a multi level data value without defining it in the graphql schema. *

diff --git a/src/main/java/graphql/scalars/datetime/AccurateDurationScalar.java b/src/main/java/graphql/scalars/datetime/AccurateDurationScalar.java new file mode 100644 index 0000000..e23446b --- /dev/null +++ b/src/main/java/graphql/scalars/datetime/AccurateDurationScalar.java @@ -0,0 +1,93 @@ +package graphql.scalars.datetime; + +import graphql.Internal; +import graphql.language.StringValue; +import graphql.language.Value; +import graphql.schema.Coercing; +import graphql.schema.CoercingParseLiteralException; +import graphql.schema.CoercingParseValueException; +import graphql.schema.CoercingSerializeException; +import graphql.schema.GraphQLScalarType; + +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.function.Function; + +import static graphql.scalars.util.Kit.typeName; + +/** + * Access this via {@link graphql.scalars.ExtendedScalars#AccurateDuration} + */ +@Internal +public class AccurateDurationScalar { + + public static final GraphQLScalarType INSTANCE; + + private AccurateDurationScalar() {} + + static { + Coercing coercing = new Coercing() { + @Override + public String serialize(Object input) throws CoercingSerializeException { + Duration duration; + if (input instanceof Duration) { + duration = (Duration) input; + } else if (input instanceof String) { + duration = parseDuration(input.toString(), CoercingSerializeException::new); + } else { + throw new CoercingSerializeException( + "Expected something we can convert to 'java.time.Duration' but was '" + typeName(input) + "'." + ); + } + return duration.toString(); + } + + @Override + public Duration parseValue(Object input) throws CoercingParseValueException { + Duration duration; + if (input instanceof Duration) { + duration = (Duration) input; + } else if (input instanceof String) { + duration = parseDuration(input.toString(), CoercingParseValueException::new); + } else { + throw new CoercingParseValueException( + "Expected a 'String' but was '" + typeName(input) + "'." + ); + } + return duration; + } + + @Override + public Duration parseLiteral(Object input) throws CoercingParseLiteralException { + if (!(input instanceof StringValue)) { + throw new CoercingParseLiteralException( + "Expected AST type 'StringValue' but was '" + typeName(input) + "'." + ); + } + return parseDuration(((StringValue) input).getValue(), CoercingParseLiteralException::new); + } + + @Override + public Value valueToLiteral(Object input) { + String s = serialize(input); + return StringValue.newStringValue(s).build(); + } + + private Duration parseDuration(String s, Function exceptionMaker) { + try { + return Duration.parse(s); + } catch (DateTimeParseException e) { + throw exceptionMaker.apply("Invalid ISO 8601 value : '" + s + "'. because of : '" + e.getMessage() + "'"); + } + } + }; + + INSTANCE = GraphQLScalarType.newScalar() + .name("AccurateDuration") + .description("A ISO 8601 duration scalar with only day, hour, minute, second components.") + .specifiedByUrl("https://scalars.graphql.org/AlexandreCarlton/accurate-duration") // TODO: Change to .specifiedByURL when builder added to graphql-java + .coercing(coercing) + .build(); + } + +} diff --git a/src/main/java/graphql/scalars/datetime/NominalDurationScalar.java b/src/main/java/graphql/scalars/datetime/NominalDurationScalar.java new file mode 100644 index 0000000..320393a --- /dev/null +++ b/src/main/java/graphql/scalars/datetime/NominalDurationScalar.java @@ -0,0 +1,92 @@ +package graphql.scalars.datetime; + +import graphql.Internal; +import graphql.language.StringValue; +import graphql.language.Value; +import graphql.schema.Coercing; +import graphql.schema.CoercingParseLiteralException; +import graphql.schema.CoercingParseValueException; +import graphql.schema.CoercingSerializeException; +import graphql.schema.GraphQLScalarType; + +import java.time.Period; +import java.time.format.DateTimeParseException; +import java.util.function.Function; + +import static graphql.scalars.util.Kit.typeName; + +/** + * Access this via {@link graphql.scalars.ExtendedScalars#NominalDuration} + */ +@Internal +public class NominalDurationScalar { + + public static final GraphQLScalarType INSTANCE; + + private NominalDurationScalar() {} + + static { + Coercing coercing = new Coercing() { + @Override + public String serialize(Object input) throws CoercingSerializeException { + Period period; + if (input instanceof Period) { + period = (Period) input; + } else if (input instanceof String) { + period = parsePeriod(input.toString(), CoercingSerializeException::new); + } else { + throw new CoercingSerializeException( + "Expected something we can convert to 'java.time.OffsetDateTime' but was '" + typeName(input) + "'." + ); + } + return period.toString(); + } + + @Override + public Period parseValue(Object input) throws CoercingParseValueException { + Period period; + if (input instanceof Period) { + period = (Period) input; + } else if (input instanceof String) { + period = parsePeriod(input.toString(), CoercingParseValueException::new); + } else { + throw new CoercingParseValueException( + "Expected a 'String' but was '" + typeName(input) + "'." + ); + } + return period; + } + + @Override + public Period parseLiteral(Object input) throws CoercingParseLiteralException { + if (!(input instanceof StringValue)) { + throw new CoercingParseLiteralException( + "Expected AST type 'StringValue' but was '" + typeName(input) + "'." + ); + } + return parsePeriod(((StringValue) input).getValue(), CoercingParseLiteralException::new); + } + + @Override + public Value valueToLiteral(Object input) { + String s = serialize(input); + return StringValue.newStringValue(s).build(); + } + + private Period parsePeriod(String s, Function exceptionMaker) { + try { + return Period.parse(s); + } catch (DateTimeParseException e) { + throw exceptionMaker.apply("Invalid ISO 8601 value : '" + s + "'. because of : '" + e.getMessage() + "'"); + } + } + }; + + INSTANCE = GraphQLScalarType.newScalar() + .name("NominalDuration") + .description("A ISO 8601 duration with only year, month, week and day components.") + .specifiedByUrl("https://scalars.graphql.org/AlexandreCarlton/nominal-duration") // TODO: Change to .specifiedByURL when builder added to graphql-java + .coercing(coercing) + .build(); + } +} diff --git a/src/test/groovy/graphql/scalars/datetime/AccurateDurationScalarTest.groovy b/src/test/groovy/graphql/scalars/datetime/AccurateDurationScalarTest.groovy new file mode 100644 index 0000000..9fb8009 --- /dev/null +++ b/src/test/groovy/graphql/scalars/datetime/AccurateDurationScalarTest.groovy @@ -0,0 +1,139 @@ +package graphql.scalars.datetime + + +import graphql.language.StringValue +import graphql.scalars.ExtendedScalars +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import spock.lang.Specification +import spock.lang.Unroll + +import java.time.Period +import java.time.temporal.ChronoUnit + +import static graphql.scalars.util.TestKit.mkDuration +import static graphql.scalars.util.TestKit.mkStringValue + +class AccurateDurationScalarTest extends Specification { + + def coercing = ExtendedScalars.AccurateDuration.getCoercing() + + @Unroll + def "accurateduration parseValue"() { + + when: + def result = coercing.parseValue(input) + then: + result == expectedValue + where: + input | expectedValue + "PT1S" | mkDuration("PT1S") + "PT1.5S" | mkDuration("PT1.5S") + "P1DT2H3M4S" | mkDuration("P1DT2H3M4S") + "-P1DT2H3M4S" | mkDuration("-P1DT2H3M4S") + "P1DT-2H3M4S" | mkDuration("P1DT-2H3M4S") + mkDuration(amount: 123456, unit: ChronoUnit.HOURS) | mkDuration("PT123456H") + } + + @Unroll + def "accurateduration valueToLiteral"() { + + when: + def result = coercing.valueToLiteral(input) + then: + result.isEqualTo(expectedValue) + where: + input | expectedValue + "PT1S" | mkStringValue("PT1S") + "PT1.5S" | mkStringValue("PT1.5S") + "P1D" | mkStringValue("PT24H") + "P1DT2H3M4S" | mkStringValue("PT26H3M4S") + mkDuration("P1DT2H3M4S") | mkStringValue("PT26H3M4S") + mkDuration("-P1DT2H3M4S") | mkStringValue("PT-26H-3M-4S") + mkDuration(amount: 123456, unit: ChronoUnit.HOURS) | mkStringValue("PT123456H") + } + + @Unroll + def "accurateduration parseValue bad inputs"() { + + when: + coercing.parseValue(input) + then: + thrown(expectedValue) + where: + input | expectedValue + "P1M" | CoercingParseValueException + "P1MT2H" | CoercingParseValueException + "P2W" | CoercingParseValueException + "P3Y" | CoercingParseValueException + 123 | CoercingParseValueException + "" | CoercingParseValueException + Period.of(1, 2, 3) | CoercingParseValueException + } + + def "accurateduration AST literal"() { + + when: + def result = coercing.parseLiteral(input) + then: + result == expectedValue + where: + input | expectedValue + new StringValue("P1DT2H3M4S") | mkDuration("P1DT2H3M4S") + } + + def "accurateduration serialisation"() { + + when: + def result = coercing.serialize(input) + then: + result == expectedValue + where: + input | expectedValue + "PT1S" | "PT1S" + "PT1.5S" | "PT1.5S" + "P1DT2H3M4S" | "PT26H3M4S" + "-P1DT2H3M4S" | "PT-26H-3M-4S" + "P1DT-2H3M4S" | "PT22H3M4S" + mkDuration("P1DT-2H3M4S") | "PT22H3M4S" + mkDuration(amount: 123456, unit: ChronoUnit.HOURS) | "PT123456H" + } + + def "accurateduration serialisation bad inputs"() { + + when: + coercing.serialize(input) + then: + thrown(expectedValue) + where: + input | expectedValue + "P1M" | CoercingSerializeException + "PT1.5M" | CoercingSerializeException + "P1MT2H" | CoercingSerializeException + "P2W" | CoercingSerializeException + "P3Y" | CoercingSerializeException + 123 | CoercingSerializeException + "" | CoercingSerializeException + Period.of(1, 2, 3) | CoercingSerializeException + } + + @Unroll + def "accurateduration parseLiteral bad inputs"() { + + when: + coercing.parseLiteral(input) + then: + thrown(expectedValue) + where: + input | expectedValue + "P1M" | CoercingParseLiteralException + "PT1.5M" | CoercingParseLiteralException + "P1MT2H" | CoercingParseLiteralException + "P2W" | CoercingParseLiteralException + "P3Y" | CoercingParseLiteralException + 123 | CoercingParseLiteralException + "" | CoercingParseLiteralException + Period.of(1, 2, 3) | CoercingParseLiteralException + } +} diff --git a/src/test/groovy/graphql/scalars/datetime/NominalDurationScalarTest.groovy b/src/test/groovy/graphql/scalars/datetime/NominalDurationScalarTest.groovy new file mode 100644 index 0000000..2790286 --- /dev/null +++ b/src/test/groovy/graphql/scalars/datetime/NominalDurationScalarTest.groovy @@ -0,0 +1,134 @@ +package graphql.scalars.datetime + +import graphql.language.StringValue +import graphql.scalars.ExtendedScalars +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import spock.lang.Specification +import spock.lang.Unroll + +import java.time.Duration +import java.time.temporal.ChronoUnit + +import static graphql.scalars.util.TestKit.mkPeriod +import static graphql.scalars.util.TestKit.mkStringValue + +class NominalDurationScalarTest extends Specification { + + def coercing = ExtendedScalars.NominalDuration.getCoercing() + + @Unroll + def "nominalduration parseValue"() { + + when: + def result = coercing.parseValue(input) + then: + result == expectedValue + where: + input | expectedValue + "P1D" | mkPeriod("P1D") + "P1W" | mkPeriod("P7D") + "P1Y2M3D" | mkPeriod("P1Y2M3D") + "-P1Y2M3D" | mkPeriod("-P1Y2M3D") + "P1Y-2M3D" | mkPeriod("P1Y-2M3D") + mkPeriod(years: 1, months: 2, days: 3) | mkPeriod("P1Y2M3D") + } + + @Unroll + def "nominalduration valueToLiteral"() { + + when: + def result = coercing.valueToLiteral(input) + then: + result.isEqualTo(expectedValue) + where: + input | expectedValue + "P1D" | mkStringValue("P1D") + "P1W" | mkStringValue("P7D") + "P1Y2M3D" | mkStringValue("P1Y2M3D") + "-P1Y2M3D" | mkStringValue("P-1Y-2M-3D") + "P1Y-2M3D" | mkStringValue("P1Y-2M3D") + mkPeriod("P1Y2M3D") | mkStringValue("P1Y2M3D") + mkPeriod(years: 1, months: 2, days: 3) | mkStringValue("P1Y2M3D") + } + + @Unroll + def "nominalduration parseValue bad inputs"() { + + when: + coercing.parseValue(input) + then: + thrown(expectedValue) + where: + input | expectedValue + "P1.5M" | CoercingParseValueException + "P1MT2H" | CoercingParseValueException + "PT1S" | CoercingParseValueException + 123 | CoercingParseValueException + "" | CoercingParseValueException + Duration.of(30, ChronoUnit.MINUTES) | CoercingParseValueException + } + + def "nominalduration AST literal"() { + + when: + def result = coercing.parseLiteral(input) + then: + result == expectedValue + where: + input | expectedValue + new StringValue("P1Y2M3D") | mkPeriod("P1Y2M3D") + } + + def "nominalduration serialisation"() { + + when: + def result = coercing.serialize(input) + then: + result == expectedValue + where: + input | expectedValue + "P1D" | "P1D" + "P1W" | "P7D" + "P1Y2M3D" | "P1Y2M3D" + "-P1Y2M3D" | "P-1Y-2M-3D" + "P1Y-2M3D" | "P1Y-2M3D" + mkPeriod(years: 1, months: 2, days: 3) | "P1Y2M3D" + } + + def "nominalduration serialisation bad inputs"() { + + when: + coercing.serialize(input) + then: + thrown(expectedValue) + where: + input | expectedValue + "PT1M" | CoercingSerializeException + "P1.5M" | CoercingSerializeException + "P1MT2H" | CoercingSerializeException + "PY" | CoercingSerializeException + 123 | CoercingSerializeException + "" | CoercingSerializeException + Duration.of(1, ChronoUnit.MINUTES) | CoercingSerializeException + } + + @Unroll + def "nominalduration parseLiteral bad inputs"() { + + when: + coercing.parseLiteral(input) + then: + thrown(expectedValue) + where: + input | expectedValue + "PT1M" | CoercingParseLiteralException + "P1.5M" | CoercingParseLiteralException + "P1MT2H" | CoercingParseLiteralException + "PY" | CoercingParseLiteralException + 123 | CoercingParseLiteralException + "" | CoercingParseLiteralException + Duration.of(1, ChronoUnit.MINUTES) | CoercingParseLiteralException + } +} diff --git a/src/test/groovy/graphql/scalars/util/TestKit.groovy b/src/test/groovy/graphql/scalars/util/TestKit.groovy index 2a96dee..6ff0577 100644 --- a/src/test/groovy/graphql/scalars/util/TestKit.groovy +++ b/src/test/groovy/graphql/scalars/util/TestKit.groovy @@ -5,11 +5,13 @@ import graphql.language.IntValue import graphql.language.StringValue import graphql.scalars.country.code.CountryCode +import java.time.Duration import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime import java.time.OffsetDateTime import java.time.OffsetTime +import java.time.Period import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime @@ -51,6 +53,21 @@ class TestKit { args.min ?: 10, args.secs ?: 9, args.nanos ?: 0, ZoneId.ofOffset("", ZoneOffset.ofHours(10))) } + static Duration mkDuration(String s) { + Duration.parse(s) + } + + static Duration mkDuration(args) { + Duration.of(args.amount, args.unit) + } + + static Period mkPeriod(String s) { + Period.parse(s) + } + + static Period mkPeriod(args) { + Period.of(args.years, args.months, args.days) + } static assertValueOrException(result, expectedResult) { if (result instanceof Exception) {