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")
+ }
+
+}