Skip to content

Commit fd47c81

Browse files
authored
Merge pull request #132 from AlexandreCarlton/add-accurate-duration-and-nominal-duration
Add AccurateDuration and NominalDuration Scalars
2 parents edc81e8 + d40fde7 commit fd47c81

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
@@ -7,7 +7,9 @@
77
import graphql.scalars.currency.CurrencyScalar;
88
import graphql.scalars.datetime.DateScalar;
99
import graphql.scalars.datetime.DateTimeScalar;
10+
import graphql.scalars.datetime.AccurateDurationScalar;
1011
import graphql.scalars.datetime.LocalTimeCoercing;
12+
import graphql.scalars.datetime.NominalDurationScalar;
1113
import graphql.scalars.datetime.TimeScalar;
1214
import graphql.scalars.datetime.YearMonthScalar;
1315
import graphql.scalars.datetime.YearScalar;
@@ -107,6 +109,35 @@ public class ExtendedScalars {
107109
.coercing(new LocalTimeCoercing())
108110
.build();
109111

112+
/**
113+
* A duration scalar that accepts string values like `P1DT2H3M4.5s` and produces * `java.time.Duration` objects at runtime.
114+
* <p>
115+
* Components like years and months are not supported as these may have different meanings depending on the placement in the calendar year.
116+
* <p>
117+
* Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods
118+
* accept Duration and formatted Strings as valid objects.
119+
* <p>
120+
* See the ISO 8601 for more details on the format.
121+
*
122+
* @see java.time.Duration
123+
*/
124+
public static final GraphQLScalarType AccurateDuration = AccurateDurationScalar.INSTANCE;
125+
126+
/**
127+
* An RFC-3339 compliant duration scalar that accepts string values like `P1Y2M3D` and produces
128+
* `java.time.Period` objects at runtime.
129+
* <p>
130+
* Components like hours and seconds are not supported as these are handled by {@link #AccurateDuration}.
131+
* <p>
132+
* Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods
133+
* accept Period and formatted Strings as valid objects.
134+
* <p>
135+
* See the ISO 8601 for more details on the format.
136+
*
137+
* @see java.time.Period
138+
*/
139+
public static final GraphQLScalarType NominalDuration = NominalDurationScalar.INSTANCE;
140+
110141
/**
111142
* An object scalar allows you to have a multi level data value without defining it in the graphql schema.
112143
* <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)