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) {