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