diff --git a/src/main/java/graphql/scalars/ExtendedScalars.java b/src/main/java/graphql/scalars/ExtendedScalars.java index ce36232..fa7edf4 100644 --- a/src/main/java/graphql/scalars/ExtendedScalars.java +++ b/src/main/java/graphql/scalars/ExtendedScalars.java @@ -8,6 +8,8 @@ import graphql.scalars.datetime.DateTimeScalar; import graphql.scalars.datetime.LocalTimeCoercing; import graphql.scalars.datetime.TimeScalar; +import graphql.scalars.datetime.YearMonthScalar; +import graphql.scalars.datetime.YearScalar; import graphql.scalars.java.JavaPrimitives; import graphql.scalars.locale.LocaleScalar; import graphql.scalars.id.UUIDScalar; @@ -69,6 +71,25 @@ public class ExtendedScalars { * @see java.time.OffsetTime */ public static final GraphQLScalarType Time = TimeScalar.INSTANCE; + /** + * A scalar that represents a year and month (e.g., `1996-12`) and produces + * `java.time.YearMonth` objects at runtime. + *

+ * Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods + * accept YearMonth instances and formatted Strings as valid objects. + * + * @see java.time.YearMonth + */ + public static final GraphQLScalarType YearMonth = YearMonthScalar.INSTANCE; + /** + * A scalar that represents a year (e.g., `1996`) and produces `java.time.Year` objects at runtime. + *

+ * Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods + * accept Year instances and formatted Strings as valid objects. + * + * @see java.time.Year + */ + public static final GraphQLScalarType Year = YearScalar.INSTANCE; /** * A 24-hour local time scalar that accepts strings like `hh:mm:ss` and `hh:mm:ss.sss` and produces diff --git a/src/main/java/graphql/scalars/datetime/YearMonthScalar.java b/src/main/java/graphql/scalars/datetime/YearMonthScalar.java new file mode 100644 index 0000000..2d679f1 --- /dev/null +++ b/src/main/java/graphql/scalars/datetime/YearMonthScalar.java @@ -0,0 +1,109 @@ +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.DateTimeException; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; +import java.util.function.Function; + +import static graphql.scalars.util.Kit.typeName; + +/** + * Access this via {@link graphql.scalars.ExtendedScalars#YearMonth} + */ +@Internal +public final class YearMonthScalar { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM"); + + public static final GraphQLScalarType INSTANCE; + + private YearMonthScalar() {} + + static { + Coercing coercing = new Coercing() { + @Override + public String serialize(Object input) throws CoercingSerializeException { + TemporalAccessor temporalAccessor; + if (input instanceof TemporalAccessor) { + temporalAccessor = (TemporalAccessor) input; + } else if (input instanceof String) { + temporalAccessor = parseYearMonth(input.toString(), CoercingSerializeException::new); + } else { + throw new CoercingSerializeException( + "Expected a 'String' or 'java.time.temporal.TemporalAccessor' but was '" + typeName(input) + "'." + ); + } + try { + return DATE_FORMATTER.format(temporalAccessor); + } catch (DateTimeException e) { + throw new CoercingSerializeException( + "Unable to turn TemporalAccessor into full yearMonth because of : '" + e.getMessage() + "'." + ); + } + } + + @Override + public YearMonth parseValue(Object input) throws CoercingParseValueException { + TemporalAccessor temporalAccessor; + if (input instanceof TemporalAccessor) { + temporalAccessor = (TemporalAccessor) input; + } else if (input instanceof String) { + temporalAccessor = parseYearMonth(input.toString(), CoercingParseValueException::new); + } else { + throw new CoercingParseValueException( + "Expected a 'String' or 'java.time.temporal.TemporalAccessor' but was '" + typeName(input) + "'." + ); + } + try { + return YearMonth.from(temporalAccessor); + } catch (DateTimeException e) { + throw new CoercingParseValueException( + "Unable to turn TemporalAccessor into full yearMonth because of : '" + e.getMessage() + "'." + ); + } + } + + @Override + public YearMonth parseLiteral(Object input) throws CoercingParseLiteralException { + if (!(input instanceof StringValue)) { + throw new CoercingParseLiteralException( + "Expected AST type 'StringValue' but was '" + typeName(input) + "'." + ); + } + return parseYearMonth(((StringValue) input).getValue(), CoercingParseLiteralException::new); + } + + @Override + public Value valueToLiteral(Object input) { + String s = serialize(input); + return StringValue.newStringValue(s).build(); + } + + private YearMonth parseYearMonth(String s, Function exceptionMaker) { + try { + TemporalAccessor temporalAccessor = DATE_FORMATTER.parse(s); + return YearMonth.from(temporalAccessor); + } catch (DateTimeParseException e) { + throw exceptionMaker.apply("Invalid RFC3339 full yearMonth value : '" + s + "'. because of : '" + e.getMessage() + "'"); + } + } + }; + + INSTANCE = GraphQLScalarType.newScalar() + .name("YearMonth") + .description("An RFC-3339 compliant Full YearMonth Scalar") + .coercing(coercing) + .build(); + } +} diff --git a/src/main/java/graphql/scalars/datetime/YearScalar.java b/src/main/java/graphql/scalars/datetime/YearScalar.java new file mode 100644 index 0000000..dd268a5 --- /dev/null +++ b/src/main/java/graphql/scalars/datetime/YearScalar.java @@ -0,0 +1,109 @@ +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.DateTimeException; +import java.time.Year; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; +import java.util.function.Function; + +import static graphql.scalars.util.Kit.typeName; + +/** + * Access this via {@link graphql.scalars.ExtendedScalars#Year} + */ +@Internal +public final class YearScalar { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy"); + + public static final GraphQLScalarType INSTANCE; + + private YearScalar() {} + + static { + Coercing coercing = new Coercing() { + @Override + public String serialize(Object input) throws CoercingSerializeException { + TemporalAccessor temporalAccessor; + if (input instanceof TemporalAccessor) { + temporalAccessor = (TemporalAccessor) input; + } else if (input instanceof String) { + temporalAccessor = parseYear(input.toString(), CoercingSerializeException::new); + } else { + throw new CoercingSerializeException( + "Expected a 'String' or 'java.time.temporal.TemporalAccessor' but was '" + typeName(input) + "'." + ); + } + try { + return DATE_FORMATTER.format(temporalAccessor); + } catch (DateTimeException e) { + throw new CoercingSerializeException( + "Unable to turn TemporalAccessor into full year because of : '" + e.getMessage() + "'." + ); + } + } + + @Override + public Year parseValue(Object input) throws CoercingParseValueException { + TemporalAccessor temporalAccessor; + if (input instanceof TemporalAccessor) { + temporalAccessor = (TemporalAccessor) input; + } else if (input instanceof String) { + temporalAccessor = parseYear(input.toString(), CoercingParseValueException::new); + } else { + throw new CoercingParseValueException( + "Expected a 'String' or 'java.time.temporal.TemporalAccessor' but was '" + typeName(input) + "'." + ); + } + try { + return Year.from(temporalAccessor); + } catch (DateTimeException e) { + throw new CoercingParseValueException( + "Unable to turn TemporalAccessor into full year because of : '" + e.getMessage() + "'." + ); + } + } + + @Override + public Year parseLiteral(Object input) throws CoercingParseLiteralException { + if (!(input instanceof StringValue)) { + throw new CoercingParseLiteralException( + "Expected AST type 'StringValue' but was '" + typeName(input) + "'." + ); + } + return parseYear(((StringValue) input).getValue(), CoercingParseLiteralException::new); + } + + @Override + public Value valueToLiteral(Object input) { + String s = serialize(input); + return StringValue.newStringValue(s).build(); + } + + private Year parseYear(String s, Function exceptionMaker) { + try { + TemporalAccessor temporalAccessor = DATE_FORMATTER.parse(s); + return Year.from(temporalAccessor); + } catch (DateTimeParseException e) { + throw exceptionMaker.apply("Invalid RFC3339 full year value : '" + s + "'. because of : '" + e.getMessage() + "'"); + } + } + }; + + INSTANCE = GraphQLScalarType.newScalar() + .name("Year") + .description("An RFC-3339 compliant Full Year Scalar") + .coercing(coercing) + .build(); + } +} diff --git a/src/test/groovy/graphql/scalars/datetime/YearMonthScalarTest.groovy b/src/test/groovy/graphql/scalars/datetime/YearMonthScalarTest.groovy new file mode 100644 index 0000000..c6f0e9b --- /dev/null +++ b/src/test/groovy/graphql/scalars/datetime/YearMonthScalarTest.groovy @@ -0,0 +1,64 @@ +package graphql.scalars.datetime + +import graphql.language.StringValue +import graphql.scalars.ExtendedScalars +import spock.lang.Specification +import spock.lang.Unroll + +import java.time.YearMonth + +import static graphql.scalars.util.TestKit.mkStringValue + +class YearMonthScalarTest extends Specification { + + def coercing = ExtendedScalars.YearMonth.getCoercing() + + @Unroll + def "yearMonth parseValue"() { + + when: + def result = coercing.parseValue(input) + then: + result == expectedValue + where: + input | expectedValue + "1937-01" | YearMonth.of(1937, 1) + } + + @Unroll + def "yearMonth parseLiteral"() { + + when: + def result = coercing.parseLiteral(input) + then: + result == expectedValue + where: + input | expectedValue + new StringValue("1937-01") | YearMonth.of(1937, 1) + } + + @Unroll + def "yearMonth serialize"() { + + when: + def result = coercing.serialize(input) + then: + result == expectedValue + where: + input | expectedValue + "1937-01" | "1937-01" + } + + @Unroll + def "yearMonth valueToLiteral"() { + + when: + def result = coercing.valueToLiteral(input) + then: + result.isEqualTo(expectedValue) + where: + input | expectedValue + "1937-01" | mkStringValue("1937-01") + } + +} diff --git a/src/test/groovy/graphql/scalars/datetime/YearScalarTest.groovy b/src/test/groovy/graphql/scalars/datetime/YearScalarTest.groovy new file mode 100644 index 0000000..0d622fa --- /dev/null +++ b/src/test/groovy/graphql/scalars/datetime/YearScalarTest.groovy @@ -0,0 +1,64 @@ +package graphql.scalars.datetime + +import graphql.language.StringValue +import graphql.scalars.ExtendedScalars +import spock.lang.Specification +import spock.lang.Unroll + +import java.time.Year + +import static graphql.scalars.util.TestKit.mkStringValue + +class YearScalarTest extends Specification { + + def coercing = ExtendedScalars.Year.getCoercing() + + @Unroll + def "year parseValue"() { + + when: + def result = coercing.parseValue(input) + then: + result == expectedValue + where: + input | expectedValue + "1937" | Year.of(1937) + } + + @Unroll + def "year parseLiteral"() { + + when: + def result = coercing.parseLiteral(input) + then: + result == expectedValue + where: + input | expectedValue + new StringValue("1937") | Year.of(1937) + } + + @Unroll + def "year serialize"() { + + when: + def result = coercing.serialize(input) + then: + result == expectedValue + where: + input | expectedValue + "1937" | "1937" + } + + @Unroll + def "year valueToLiteral"() { + + when: + def result = coercing.valueToLiteral(input) + then: + result.isEqualTo(expectedValue) + where: + input | expectedValue + "1937" | mkStringValue("1937") + } + +}