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))