Skip to content

Commit 1a1a895

Browse files
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.
1 parent f6191ff commit 1a1a895

File tree

6 files changed

+506
-0
lines changed

6 files changed

+506
-0
lines changed

src/main/java/graphql/scalars/ExtendedScalars.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
import graphql.scalars.currency.CurrencyScalar;
77
import graphql.scalars.datetime.DateScalar;
88
import graphql.scalars.datetime.DateTimeScalar;
9+
import graphql.scalars.datetime.AccurateDurationScalar;
910
import graphql.scalars.datetime.LocalTimeCoercing;
11+
import graphql.scalars.datetime.NominalDurationScalar;
1012
import graphql.scalars.datetime.TimeScalar;
1113
import graphql.scalars.java.JavaPrimitives;
1214
import graphql.scalars.locale.LocaleScalar;
@@ -85,6 +87,35 @@ public class ExtendedScalars {
8587
.coercing(new LocalTimeCoercing())
8688
.build();
8789

90+
/**
91+
* A duration scalar that accepts string values like `P1DT2H3M4.5s` and produces * `java.time.Duration` objects at runtime.
92+
* <p>
93+
* Components like years and months are not supported as these may have different meanings depending on the placement in the calendar year.
94+
* <p>
95+
* Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods
96+
* accept Duration and formatted Strings as valid objects.
97+
* <p>
98+
* See the ISO 8601 for more details on the format.
99+
*
100+
* @see java.time.Duration
101+
*/
102+
public static final GraphQLScalarType AccurateDuration = AccurateDurationScalar.INSTANCE;
103+
104+
/**
105+
* An RFC-3339 compliant duration scalar that accepts string values like `P1Y2M3D` and produces
106+
* `java.time.Period` objects at runtime.
107+
* <p>
108+
* Components like hours and seconds are not supported as these are handled by {@link #AccurateDuration}.
109+
* <p>
110+
* Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods
111+
* accept Period and formatted Strings as valid objects.
112+
* <p>
113+
* See the ISO 8601 for more details on the format.
114+
*
115+
* @see java.time.Period
116+
*/
117+
public static final GraphQLScalarType NominalDuration = NominalDurationScalar.INSTANCE;
118+
88119
/**
89120
* An object scalar allows you to have a multi level data value without defining it in the graphql schema.
90121
* <p>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package graphql.scalars.datetime;
2+
3+
import graphql.Internal;
4+
import graphql.language.StringValue;
5+
import graphql.language.Value;
6+
import graphql.schema.Coercing;
7+
import graphql.schema.CoercingParseLiteralException;
8+
import graphql.schema.CoercingParseValueException;
9+
import graphql.schema.CoercingSerializeException;
10+
import graphql.schema.GraphQLScalarType;
11+
12+
import java.time.Duration;
13+
import java.time.format.DateTimeParseException;
14+
import java.util.function.Function;
15+
16+
import static graphql.scalars.util.Kit.typeName;
17+
18+
/**
19+
* Access this via {@link graphql.scalars.ExtendedScalars#AccurateDuration}
20+
*/
21+
@Internal
22+
public class AccurateDurationScalar {
23+
24+
public static final GraphQLScalarType INSTANCE;
25+
26+
private AccurateDurationScalar() {}
27+
28+
static {
29+
Coercing<Duration, String> coercing = new Coercing<Duration, String>() {
30+
@Override
31+
public String serialize(Object input) throws CoercingSerializeException {
32+
Duration duration;
33+
if (input instanceof Duration) {
34+
duration = (Duration) input;
35+
} else if (input instanceof String) {
36+
duration = parseDuration(input.toString(), CoercingSerializeException::new);
37+
} else {
38+
throw new CoercingSerializeException(
39+
"Expected something we can convert to 'java.time.Duration' but was '" + typeName(input) + "'."
40+
);
41+
}
42+
return duration.toString();
43+
}
44+
45+
@Override
46+
public Duration parseValue(Object input) throws CoercingParseValueException {
47+
Duration duration;
48+
if (input instanceof Duration) {
49+
duration = (Duration) input;
50+
} else if (input instanceof String) {
51+
duration = parseDuration(input.toString(), CoercingParseValueException::new);
52+
} else {
53+
throw new CoercingParseValueException(
54+
"Expected a 'String' but was '" + typeName(input) + "'."
55+
);
56+
}
57+
return duration;
58+
}
59+
60+
@Override
61+
public Duration parseLiteral(Object input) throws CoercingParseLiteralException {
62+
if (!(input instanceof StringValue)) {
63+
throw new CoercingParseLiteralException(
64+
"Expected AST type 'StringValue' but was '" + typeName(input) + "'."
65+
);
66+
}
67+
return parseDuration(((StringValue) input).getValue(), CoercingParseLiteralException::new);
68+
}
69+
70+
@Override
71+
public Value<?> valueToLiteral(Object input) {
72+
String s = serialize(input);
73+
return StringValue.newStringValue(s).build();
74+
}
75+
76+
private Duration parseDuration(String s, Function<String, RuntimeException> exceptionMaker) {
77+
try {
78+
return Duration.parse(s);
79+
} catch (DateTimeParseException e) {
80+
throw exceptionMaker.apply("Invalid ISO 8601 value : '" + s + "'. because of : '" + e.getMessage() + "'");
81+
}
82+
}
83+
};
84+
85+
INSTANCE = GraphQLScalarType.newScalar()
86+
.name("AccurateDuration")
87+
.description("A ISO 8601 duration scalar with only day, hour, minute, second components.")
88+
.specifiedByUrl("https://scalars.graphql.org/AlexandreCarlton/accurate-duration") // TODO: Change to .specifiedByURL when builder added to graphql-java
89+
.coercing(coercing)
90+
.build();
91+
}
92+
93+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package graphql.scalars.datetime;
2+
3+
import graphql.Internal;
4+
import graphql.language.StringValue;
5+
import graphql.language.Value;
6+
import graphql.schema.Coercing;
7+
import graphql.schema.CoercingParseLiteralException;
8+
import graphql.schema.CoercingParseValueException;
9+
import graphql.schema.CoercingSerializeException;
10+
import graphql.schema.GraphQLScalarType;
11+
12+
import java.time.Period;
13+
import java.time.format.DateTimeParseException;
14+
import java.util.function.Function;
15+
16+
import static graphql.scalars.util.Kit.typeName;
17+
18+
/**
19+
* Access this via {@link graphql.scalars.ExtendedScalars#NominalDuration}
20+
*/
21+
@Internal
22+
public class NominalDurationScalar {
23+
24+
public static final GraphQLScalarType INSTANCE;
25+
26+
private NominalDurationScalar() {}
27+
28+
static {
29+
Coercing<Period, String> coercing = new Coercing<Period, String>() {
30+
@Override
31+
public String serialize(Object input) throws CoercingSerializeException {
32+
Period period;
33+
if (input instanceof Period) {
34+
period = (Period) input;
35+
} else if (input instanceof String) {
36+
period = parsePeriod(input.toString(), CoercingSerializeException::new);
37+
} else {
38+
throw new CoercingSerializeException(
39+
"Expected something we can convert to 'java.time.OffsetDateTime' but was '" + typeName(input) + "'."
40+
);
41+
}
42+
return period.toString();
43+
}
44+
45+
@Override
46+
public Period parseValue(Object input) throws CoercingParseValueException {
47+
Period period;
48+
if (input instanceof Period) {
49+
period = (Period) input;
50+
} else if (input instanceof String) {
51+
period = parsePeriod(input.toString(), CoercingParseValueException::new);
52+
} else {
53+
throw new CoercingParseValueException(
54+
"Expected a 'String' but was '" + typeName(input) + "'."
55+
);
56+
}
57+
return period;
58+
}
59+
60+
@Override
61+
public Period parseLiteral(Object input) throws CoercingParseLiteralException {
62+
if (!(input instanceof StringValue)) {
63+
throw new CoercingParseLiteralException(
64+
"Expected AST type 'StringValue' but was '" + typeName(input) + "'."
65+
);
66+
}
67+
return parsePeriod(((StringValue) input).getValue(), CoercingParseLiteralException::new);
68+
}
69+
70+
@Override
71+
public Value<?> valueToLiteral(Object input) {
72+
String s = serialize(input);
73+
return StringValue.newStringValue(s).build();
74+
}
75+
76+
private Period parsePeriod(String s, Function<String, RuntimeException> exceptionMaker) {
77+
try {
78+
return Period.parse(s);
79+
} catch (DateTimeParseException e) {
80+
throw exceptionMaker.apply("Invalid ISO 8601 value : '" + s + "'. because of : '" + e.getMessage() + "'");
81+
}
82+
}
83+
};
84+
85+
INSTANCE = GraphQLScalarType.newScalar()
86+
.name("NominalDuration")
87+
.description("A ISO 8601 duration with only year, month, week and day components.")
88+
.specifiedByUrl("https://scalars.graphql.org/AlexandreCarlton/nominal-duration") // TODO: Change to .specifiedByURL when builder added to graphql-java
89+
.coercing(coercing)
90+
.build();
91+
}
92+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
package graphql.scalars.datetime
2+
3+
4+
import graphql.language.StringValue
5+
import graphql.scalars.ExtendedScalars
6+
import graphql.schema.CoercingParseLiteralException
7+
import graphql.schema.CoercingParseValueException
8+
import graphql.schema.CoercingSerializeException
9+
import spock.lang.Specification
10+
import spock.lang.Unroll
11+
12+
import java.time.Period
13+
import java.time.temporal.ChronoUnit
14+
15+
import static graphql.scalars.util.TestKit.mkDuration
16+
import static graphql.scalars.util.TestKit.mkStringValue
17+
18+
class AccurateDurationScalarTest extends Specification {
19+
20+
def coercing = ExtendedScalars.AccurateDuration.getCoercing()
21+
22+
@Unroll
23+
def "accurateduration parseValue"() {
24+
25+
when:
26+
def result = coercing.parseValue(input)
27+
then:
28+
result == expectedValue
29+
where:
30+
input | expectedValue
31+
"PT1S" | mkDuration("PT1S")
32+
"PT1.5S" | mkDuration("PT1.5S")
33+
"P1DT2H3M4S" | mkDuration("P1DT2H3M4S")
34+
"-P1DT2H3M4S" | mkDuration("-P1DT2H3M4S")
35+
"P1DT-2H3M4S" | mkDuration("P1DT-2H3M4S")
36+
mkDuration(amount: 123456, unit: ChronoUnit.HOURS) | mkDuration("PT123456H")
37+
}
38+
39+
@Unroll
40+
def "accurateduration valueToLiteral"() {
41+
42+
when:
43+
def result = coercing.valueToLiteral(input)
44+
then:
45+
result.isEqualTo(expectedValue)
46+
where:
47+
input | expectedValue
48+
"PT1S" | mkStringValue("PT1S")
49+
"PT1.5S" | mkStringValue("PT1.5S")
50+
"P1D" | mkStringValue("PT24H")
51+
"P1DT2H3M4S" | mkStringValue("PT26H3M4S")
52+
mkDuration("P1DT2H3M4S") | mkStringValue("PT26H3M4S")
53+
mkDuration("-P1DT2H3M4S") | mkStringValue("PT-26H-3M-4S")
54+
mkDuration(amount: 123456, unit: ChronoUnit.HOURS) | mkStringValue("PT123456H")
55+
}
56+
57+
@Unroll
58+
def "accurateduration parseValue bad inputs"() {
59+
60+
when:
61+
coercing.parseValue(input)
62+
then:
63+
thrown(expectedValue)
64+
where:
65+
input | expectedValue
66+
"P1M" | CoercingParseValueException
67+
"P1MT2H" | CoercingParseValueException
68+
"P2W" | CoercingParseValueException
69+
"P3Y" | CoercingParseValueException
70+
123 | CoercingParseValueException
71+
"" | CoercingParseValueException
72+
Period.of(1, 2, 3) | CoercingParseValueException
73+
}
74+
75+
def "accurateduration AST literal"() {
76+
77+
when:
78+
def result = coercing.parseLiteral(input)
79+
then:
80+
result == expectedValue
81+
where:
82+
input | expectedValue
83+
new StringValue("P1DT2H3M4S") | mkDuration("P1DT2H3M4S")
84+
}
85+
86+
def "accurateduration serialisation"() {
87+
88+
when:
89+
def result = coercing.serialize(input)
90+
then:
91+
result == expectedValue
92+
where:
93+
input | expectedValue
94+
"PT1S" | "PT1S"
95+
"PT1.5S" | "PT1.5S"
96+
"P1DT2H3M4S" | "PT26H3M4S"
97+
"-P1DT2H3M4S" | "PT-26H-3M-4S"
98+
"P1DT-2H3M4S" | "PT22H3M4S"
99+
mkDuration("P1DT-2H3M4S") | "PT22H3M4S"
100+
mkDuration(amount: 123456, unit: ChronoUnit.HOURS) | "PT123456H"
101+
}
102+
103+
def "accurateduration serialisation bad inputs"() {
104+
105+
when:
106+
coercing.serialize(input)
107+
then:
108+
thrown(expectedValue)
109+
where:
110+
input | expectedValue
111+
"P1M" | CoercingSerializeException
112+
"PT1.5M" | CoercingSerializeException
113+
"P1MT2H" | CoercingSerializeException
114+
"P2W" | CoercingSerializeException
115+
"P3Y" | CoercingSerializeException
116+
123 | CoercingSerializeException
117+
"" | CoercingSerializeException
118+
Period.of(1, 2, 3) | CoercingSerializeException
119+
}
120+
121+
@Unroll
122+
def "accurateduration parseLiteral bad inputs"() {
123+
124+
when:
125+
coercing.parseLiteral(input)
126+
then:
127+
thrown(expectedValue)
128+
where:
129+
input | expectedValue
130+
"P1M" | CoercingParseLiteralException
131+
"PT1.5M" | CoercingParseLiteralException
132+
"P1MT2H" | CoercingParseLiteralException
133+
"P2W" | CoercingParseLiteralException
134+
"P3Y" | CoercingParseLiteralException
135+
123 | CoercingParseLiteralException
136+
"" | CoercingParseLiteralException
137+
Period.of(1, 2, 3) | CoercingParseLiteralException
138+
}
139+
}

0 commit comments

Comments
 (0)