diff --git a/readme.md b/readme.md
index 584c9f1..5dd8968 100644
--- a/readme.md
+++ b/readme.md
@@ -54,7 +54,10 @@ And use it in your schema
`java.time.OffsetDateTime` objects at runtime
* `Time`
* An RFC-3339 compliant time scalar that accepts string values like `16:39:57-08:00` and produces
- `java.time.OffsetTime` objects at runtime
+ `java.time.OffsetTime` objects at runtime
+* `LocalTime`
+ * 24-hour clock time string in the format `hh:mm:ss.sss` or `hh:mm:ss` if partial seconds is zero and
+ produces `java.time.LocalTime` objects at runtime.
* `Date`
* An RFC-3339 compliant date scalar that accepts string values like `1996-12-19` and produces
`java.time.LocalDate` objects at runtime
diff --git a/src/main/java/graphql/scalars/ExtendedScalars.java b/src/main/java/graphql/scalars/ExtendedScalars.java
index cb3325f..b09e067 100644
--- a/src/main/java/graphql/scalars/ExtendedScalars.java
+++ b/src/main/java/graphql/scalars/ExtendedScalars.java
@@ -4,6 +4,7 @@
import graphql.scalars.alias.AliasedScalar;
import graphql.scalars.datetime.DateScalar;
import graphql.scalars.datetime.DateTimeScalar;
+import graphql.scalars.datetime.LocalTimeCoercing;
import graphql.scalars.datetime.TimeScalar;
import graphql.scalars.java.JavaPrimitives;
import graphql.scalars.locale.LocaleScalar;
@@ -66,6 +67,21 @@ public class ExtendedScalars {
*/
public static GraphQLScalarType Time = new TimeScalar();
+ /**
+ * A 24-hour local time scalar that accepts strings like `hh:mm:ss` and `hh:mm:ss.sss` and produces
+ * `java.time.LocalTime` objects at runtime.
+ *
+ * Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods
+ * accept time {@link java.time.temporal.TemporalAccessor}s and formatted Strings as valid objects.
+ *
+ * @see java.time.LocalTime
+ */
+ public static GraphQLScalarType LocalTime = GraphQLScalarType.newScalar()
+ .name("LocalTime")
+ .description("24-hour clock time value string in the format `hh:mm:ss` or `hh:mm:ss.sss`.")
+ .coercing(new LocalTimeCoercing())
+ .build();
+
/**
* 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/LocalTimeCoercing.java b/src/main/java/graphql/scalars/datetime/LocalTimeCoercing.java
new file mode 100644
index 0000000..e0345cb
--- /dev/null
+++ b/src/main/java/graphql/scalars/datetime/LocalTimeCoercing.java
@@ -0,0 +1,82 @@
+package graphql.scalars.datetime;
+
+import graphql.language.StringValue;
+import graphql.schema.Coercing;
+import graphql.schema.CoercingParseLiteralException;
+import graphql.schema.CoercingParseValueException;
+import graphql.schema.CoercingSerializeException;
+
+import java.time.DateTimeException;
+import java.time.LocalTime;
+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;
+
+public class LocalTimeCoercing implements Coercing {
+
+ private final static DateTimeFormatter dateFormatter = DateTimeFormatter.ISO_LOCAL_TIME;
+
+ @Override
+ public String serialize(final Object input) throws CoercingSerializeException {
+ TemporalAccessor temporalAccessor;
+ if (input instanceof TemporalAccessor) {
+ temporalAccessor = (TemporalAccessor) input;
+ } else if (input instanceof String) {
+ temporalAccessor = parseTime(input.toString(), CoercingSerializeException::new);
+ } else {
+ throw new CoercingSerializeException(
+ "Expected a 'String' or 'java.time.temporal.TemporalAccessor' but was '" + typeName(input) + "'."
+ );
+ }
+ try {
+ return dateFormatter.format(temporalAccessor);
+ } catch (DateTimeException e) {
+ throw new CoercingSerializeException(
+ "Unable to turn TemporalAccessor into full time because of : '" + e.getMessage() + "'."
+ );
+ }
+ }
+
+ @Override
+ public LocalTime parseValue(final Object input) throws CoercingParseValueException {
+ TemporalAccessor temporalAccessor;
+ if (input instanceof TemporalAccessor) {
+ temporalAccessor = (TemporalAccessor) input;
+ } else if (input instanceof String) {
+ temporalAccessor = parseTime(input.toString(), CoercingParseValueException::new);
+ } else {
+ throw new CoercingParseValueException(
+ "Expected a 'String' or 'java.time.temporal.TemporalAccessor' but was '" + typeName(input) + "'."
+ );
+ }
+ try {
+ return LocalTime.from(temporalAccessor);
+ } catch (DateTimeException e) {
+ throw new CoercingParseValueException(
+ "Unable to turn TemporalAccessor into full time because of : '" + e.getMessage() + "'."
+ );
+ }
+ }
+
+ @Override
+ public LocalTime parseLiteral(final Object input) throws CoercingParseLiteralException {
+ if (!(input instanceof StringValue)) {
+ throw new CoercingParseLiteralException(
+ "Expected AST type 'StringValue' but was '" + typeName(input) + "'."
+ );
+ }
+ return parseTime(((StringValue) input).getValue(), CoercingParseLiteralException::new);
+ }
+
+ private static LocalTime parseTime(String s, Function exceptionMaker) {
+ try {
+ TemporalAccessor temporalAccessor = dateFormatter.parse(s);
+ return LocalTime.from(temporalAccessor);
+ } catch (DateTimeParseException e) {
+ throw exceptionMaker.apply("Invalid local time value : '" + s + "'. because of : '" + e.getMessage() + "'");
+ }
+ }
+}
diff --git a/src/test/groovy/graphql/scalars/datetime/LocalTimeScalarTest.groovy b/src/test/groovy/graphql/scalars/datetime/LocalTimeScalarTest.groovy
new file mode 100644
index 0000000..1d03acb
--- /dev/null
+++ b/src/test/groovy/graphql/scalars/datetime/LocalTimeScalarTest.groovy
@@ -0,0 +1,85 @@
+package graphql.scalars.datetime
+
+import graphql.language.StringValue
+import graphql.scalars.ExtendedScalars
+import graphql.schema.CoercingParseValueException
+import graphql.schema.CoercingSerializeException
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import static graphql.scalars.util.TestKit.mkLocalT
+
+class LocalTimeScalarTest extends Specification {
+
+ def coercing = ExtendedScalars.LocalTime.getCoercing()
+
+ @Unroll
+ def "localtime parseValue"() {
+
+ when:
+ def result = coercing.parseValue(input)
+ then:
+ result == expectedValue
+ where:
+ input | expectedValue
+ "23:20:50.123456789" | mkLocalT("23:20:50.123456789")
+ "16:39:57.000000000" | mkLocalT("16:39:57")
+ "16:39:57.0" | mkLocalT("16:39:57")
+ "16:39:57" | mkLocalT("16:39:57")
+ }
+
+ @Unroll
+ def "localtime parseValue bad inputs"() {
+
+ when:
+ coercing.parseValue(input)
+ then:
+ thrown(expectedValue)
+ where:
+ input | expectedValue
+ "23:20:50.52Z" | CoercingParseValueException
+ "16:39:57-08:00" | CoercingParseValueException
+ 666 || CoercingParseValueException
+ }
+
+ def "localtime AST literal"() {
+
+ when:
+ def result = coercing.parseLiteral(input)
+ then:
+ result == expectedValue
+ where:
+ input | expectedValue
+ new StringValue("23:20:50.123456789") | mkLocalT("23:20:50.123456789")
+ new StringValue("16:39:57.000000000") | mkLocalT("16:39:57")
+ new StringValue("16:39:57.0") | mkLocalT("16:39:57")
+ new StringValue("16:39:57") | mkLocalT("16:39:57")
+ }
+
+ def "localtime serialisation"() {
+
+ when:
+ def result = coercing.serialize(input)
+ then:
+ result == expectedValue
+ where:
+ input | expectedValue
+ "23:20:50.123456789" | "23:20:50.123456789"
+ "23:20:50" | "23:20:50"
+ mkLocalT("16:39:57") | "16:39:57"
+ mkLocalT("16:39:57.1") | "16:39:57.1"
+ }
+
+ def "datetime serialisation bad inputs"() {
+
+ when:
+ coercing.serialize(input)
+ then:
+ thrown(expectedValue)
+ where:
+ input | expectedValue
+ "23:20:50.52Z" | CoercingSerializeException
+ "16:39:57-08:00" | CoercingSerializeException
+ 666 || CoercingSerializeException
+ }
+}
diff --git a/src/test/groovy/graphql/scalars/util/TestKit.groovy b/src/test/groovy/graphql/scalars/util/TestKit.groovy
index 35ca49e..139a6b2 100644
--- a/src/test/groovy/graphql/scalars/util/TestKit.groovy
+++ b/src/test/groovy/graphql/scalars/util/TestKit.groovy
@@ -5,6 +5,7 @@ import graphql.language.IntValue
import java.time.LocalDate
import java.time.LocalDateTime
+import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.OffsetTime
import java.time.ZoneId
@@ -29,6 +30,10 @@ class TestKit {
OffsetTime.parse(s)
}
+ static LocalTime mkLocalT(String s) {
+ LocalTime.parse(s)
+ }
+
static OffsetDateTime mkOffsetDT(args) {
OffsetDateTime.of(args.year ?: 1969, args.month ?: 8, args.day ?: 8, args.hour ?: 11,
args.min ?: 10, args.secs ?: 9, args.nanos ?: 0, ZoneOffset.ofHours(10))