diff --git a/README.md b/README.md index e3b0e30..6bc588d 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,22 @@ scalar 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. + +
+scalar SecondsSinceEpoch
+
+A scalar that represents a point in time as seconds since the Unix epoch (January 1, 1970, 00:00:00 UTC). It accepts integers or strings containing integers as input values and produces java.time.ZonedDateTime objects at runtime (with UTC timezone).

+Using seconds since epoch is preferable to formatted date time strings in several scenarios: + +However, human readability is sacrificed compared to formatted date strings, so consider your use case requirements when choosing between DateTime and SecondsSinceEpoch. + An example declaration in SDL might be: @@ -181,10 +197,11 @@ type Customer { birthDay: Date workStartTime: Time bornAt: DateTime + createdAtTimestamp: SecondsSinceEpoch } type Query { - customers(bornAfter: DateTime): [Customers] + customers(bornAfter: DateTime, createdAfter: SecondsSinceEpoch): [Customers] } ``` @@ -192,9 +209,10 @@ And example query might look like: ```graphql query { - customers(bornAfter: "1996-12-19T16:39:57-08:00") { + customers(bornAfter: "1996-12-19T16:39:57-08:00", createdAfter: 1609459200) { birthDay bornAt + createdAtTimestamp } } ``` diff --git a/src/main/java/graphql/scalars/ExtendedScalars.java b/src/main/java/graphql/scalars/ExtendedScalars.java index e3f7d9d..07124fc 100644 --- a/src/main/java/graphql/scalars/ExtendedScalars.java +++ b/src/main/java/graphql/scalars/ExtendedScalars.java @@ -10,6 +10,7 @@ import graphql.scalars.datetime.AccurateDurationScalar; import graphql.scalars.datetime.LocalTimeCoercing; import graphql.scalars.datetime.NominalDurationScalar; +import graphql.scalars.datetime.SecondsSinceEpochScalar; import graphql.scalars.datetime.TimeScalar; import graphql.scalars.datetime.YearMonthScalar; import graphql.scalars.datetime.YearScalar; @@ -138,6 +139,34 @@ public class ExtendedScalars { */ public static final GraphQLScalarType NominalDuration = NominalDurationScalar.INSTANCE; + /** + * A scalar that represents a point in time as seconds since the Unix epoch (Unix timestamp). + *

+ * It accepts integers or strings containing integers as input values and produces + * `java.time.ZonedDateTime` objects at runtime (with UTC timezone). + *

+ * Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} method accepts various + * {@link java.time.temporal.TemporalAccessor} types and returns a string containing the number of seconds since epoch + * (January 1, 1970, 00:00:00 UTC). + *

+ * Using seconds since epoch is preferable to formatted date time strings in several scenarios: + *

+ *

+ * However, human readability is sacrificed compared to formatted date strings, so consider your use case + * requirements when choosing between {@link #DateTime} and {@link #SecondsSinceEpoch}. + * + * @see java.time.Instant + * @see java.time.ZonedDateTime + */ + public static final GraphQLScalarType SecondsSinceEpoch = SecondsSinceEpochScalar.INSTANCE; + /** * 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/SecondsSinceEpochScalar.java b/src/main/java/graphql/scalars/datetime/SecondsSinceEpochScalar.java new file mode 100644 index 0000000..cc46d2b --- /dev/null +++ b/src/main/java/graphql/scalars/datetime/SecondsSinceEpochScalar.java @@ -0,0 +1,176 @@ +package graphql.scalars.datetime; + +import graphql.GraphQLContext; +import graphql.Internal; +import graphql.execution.CoercedVariables; +import graphql.language.IntValue; +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.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.temporal.Temporal; +import java.time.temporal.TemporalAccessor; +import java.util.Locale; + +import static graphql.scalars.util.Kit.typeName; + +/** + * Access this via {@link graphql.scalars.ExtendedScalars#SecondsSinceEpoch} + */ +@Internal +public final class SecondsSinceEpochScalar { + + public static final GraphQLScalarType INSTANCE; + + private SecondsSinceEpochScalar() { + } + + private static Temporal convertToTemporal(String value) { + try { + if (value.matches("\\d+")) { + long epochSeconds = Long.parseLong(value); + return convertEpochSecondsToTemporal(epochSeconds); + } + throw new CoercingParseValueException( + "Invalid seconds since epoch value : '" + value + "'. Expected a string containing only digits." + ); + } catch (Exception e) { + throw new CoercingParseValueException( + "Invalid seconds since epoch value : '" + value + "'. " + e.getMessage() + ); + } + } + + private static Temporal convertEpochSecondsToTemporal(long epochSeconds) { + return Instant.ofEpochSecond(epochSeconds).atZone(ZoneOffset.UTC); + } + + static { + Coercing coercing = new Coercing<>() { + @Override + public Long serialize(Object input, GraphQLContext graphQLContext, Locale locale) throws CoercingSerializeException { + try { + if (input instanceof Number) { + Number number = (Number) input; + return number.longValue(); + } + if (input instanceof String) { + try { + return Long.parseLong((String) input); + } catch (NumberFormatException e) { + throw new CoercingSerializeException( + "Invalid seconds since epoch value : '" + input + "'. Expected a string containing only digits.", + e + ); + } + } + if (input instanceof TemporalAccessor) { + TemporalAccessor temporalAccessor = (TemporalAccessor) input; + if (temporalAccessor instanceof Instant) { + Instant instant = (Instant) temporalAccessor; + return instant.getEpochSecond(); + } else if (temporalAccessor instanceof LocalDateTime) { + LocalDateTime localDateTime = (LocalDateTime) temporalAccessor; + return localDateTime.toEpochSecond(ZoneOffset.UTC); + } else if (temporalAccessor instanceof ZonedDateTime) { + ZonedDateTime zonedDateTime = (ZonedDateTime) temporalAccessor; + return zonedDateTime.toEpochSecond(); + } else if (temporalAccessor instanceof OffsetDateTime) { + OffsetDateTime offsetDateTime = (OffsetDateTime) temporalAccessor; + return offsetDateTime.toEpochSecond(); + } else { + try { + Instant instant = Instant.from(temporalAccessor); + return instant.getEpochSecond(); + } catch (Exception e) { + throw new CoercingSerializeException( + "Unable to convert TemporalAccessor to seconds since epoch because of : '" + e.getMessage() + "'." + ); + } + } + } + throw new CoercingSerializeException( + "Expected a 'Number', 'String' or 'TemporalAccessor' but was '" + typeName(input) + "'." + ); + } catch (CoercingSerializeException e) { + throw e; + } catch (Exception e) { + throw new CoercingSerializeException( + "Unable to convert to seconds since epoch because of : '" + e.getMessage() + "'." + ); + } + } + + @Override + public TemporalAccessor parseValue(Object input, GraphQLContext graphQLContext, Locale locale) throws CoercingParseValueException { + try { + if (input instanceof Number) { + Number number = (Number) input; + return convertEpochSecondsToTemporal(number.longValue()); + } + if (input instanceof String) { + String string = (String) input; + return convertToTemporal(string); + } + throw new CoercingParseValueException( + "Expected a 'Number' or 'String' but was '" + typeName(input) + "'." + ); + } catch (CoercingParseValueException e) { + throw e; + } catch (Exception e) { + throw new CoercingParseValueException( + "Unable to parse value to seconds since epoch because of : '" + e.getMessage() + "'." + ); + } + } + + @Override + public TemporalAccessor parseLiteral(Value input, CoercedVariables variables, GraphQLContext graphQLContext, Locale locale) throws CoercingParseLiteralException { + try { + if (input instanceof StringValue) { + StringValue stringValue = (StringValue) input; + return convertToTemporal(stringValue.getValue()); + } + if (input instanceof IntValue) { + IntValue intValue = (IntValue) input; + long epochSeconds = intValue.getValue().longValue(); + return convertEpochSecondsToTemporal(epochSeconds); + } + throw new CoercingParseLiteralException( + "Expected AST type 'StringValue' or 'IntValue' but was '" + typeName(input) + "'." + ); + } catch (CoercingParseLiteralException e) { + throw e; + } catch (Exception e) { + throw new CoercingParseLiteralException( + "Unable to parse literal to seconds since epoch because of : '" + e.getMessage() + "'." + ); + } + } + + @Override + public Value valueToLiteral(Object input, GraphQLContext graphQLContext, Locale locale) { + Long value = serialize(input, graphQLContext, locale); + return IntValue.newIntValue(java.math.BigInteger.valueOf(value)).build(); + } + + }; + + INSTANCE = GraphQLScalarType.newScalar() + .name("SecondsSinceEpoch") + .description("Scalar that represents a point in time as seconds since the Unix epoch (Unix timestamp). " + + "Accepts integers or strings containing integers as input values. " + + "Returns a Long representing the number of seconds since epoch (January 1, 1970, 00:00:00 UTC).") + .coercing(coercing) + .build(); + } +} diff --git a/src/test/groovy/graphql/scalars/datetime/SecondsSinceEpochScalarTest.groovy b/src/test/groovy/graphql/scalars/datetime/SecondsSinceEpochScalarTest.groovy new file mode 100644 index 0000000..a932273 --- /dev/null +++ b/src/test/groovy/graphql/scalars/datetime/SecondsSinceEpochScalarTest.groovy @@ -0,0 +1,135 @@ +package graphql.scalars.datetime + +import graphql.language.IntValue +import graphql.language.StringValue +import graphql.scalars.ExtendedScalars +import graphql.scalars.util.AbstractScalarTest +import graphql.schema.CoercingParseLiteralException +import graphql.schema.CoercingParseValueException +import graphql.schema.CoercingSerializeException +import spock.lang.Unroll + +import java.time.Instant +import java.time.LocalDateTime +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.time.ZonedDateTime + +import static graphql.scalars.util.TestKit.mkIntValue +import static graphql.scalars.util.TestKit.mkLocalDT +import static graphql.scalars.util.TestKit.mkOffsetDT +import static graphql.scalars.util.TestKit.mkStringValue +import static graphql.scalars.util.TestKit.mkZonedDT + +class SecondsSinceEpochScalarTest extends AbstractScalarTest { + + def coercing = ExtendedScalars.SecondsSinceEpoch.getCoercing() + + @Unroll + def "secondsSinceEpoch parseValue"() { + when: + def result = coercing.parseValue(input, graphQLContext, locale) + then: + result.toEpochSecond() == expectedValue + where: + input | expectedValue + "0" | 0L + "1" | 1L + "1609459200" | 1609459200L // 2021-01-01T00:00:00Z + "1640995200" | 1640995200L // 2022-01-01T00:00:00Z + 0 | 0L + 1 | 1L + 1609459200 | 1609459200L // 2021-01-01T00:00:00Z + 1640995200 | 1640995200L // 2022-01-01T00:00:00Z + } + + @Unroll + def "secondsSinceEpoch valueToLiteral"() { + when: + def result = coercing.valueToLiteral(input, graphQLContext, locale) + then: + result.isEqualTo(expectedValue) + where: + input | expectedValue + "0" | mkIntValue(0) + "1" | mkIntValue(1) + "1609459200" | mkIntValue(1609459200) + "1640995200" | mkIntValue(1640995200) + 0 | mkIntValue(0) + 1 | mkIntValue(1) + 1609459200 | mkIntValue(1609459200) + 1640995200 | mkIntValue(1640995200) + Instant.ofEpochSecond(1609459200) | mkIntValue(1609459200) + ZonedDateTime.ofInstant(Instant.ofEpochSecond(1609459200), ZoneOffset.UTC) | mkIntValue(1609459200) + } + + @Unroll + def "secondsSinceEpoch parseValue bad inputs"() { + when: + coercing.parseValue(input, graphQLContext, locale) + then: + thrown(expectedValue) + where: + input | expectedValue + "not a number" | CoercingParseValueException + "123abc" | CoercingParseValueException + "2022-01-01" | CoercingParseValueException + "2022-01-01T00:00:00Z" | CoercingParseValueException + new Object() | CoercingParseValueException + } + + def "secondsSinceEpoch AST literal"() { + when: + def result = coercing.parseLiteral(input, variables, graphQLContext, locale) + then: + result.toEpochSecond() == expectedValue + where: + input | expectedValue + new StringValue("0") | 0L + new StringValue("1") | 1L + new StringValue("1609459200") | 1609459200L // 2021-01-01T00:00:00Z + new IntValue(0) | 0L + new IntValue(1) | 1L + new IntValue(1609459200) | 1609459200L // 2021-01-01T00:00:00Z + } + + def "secondsSinceEpoch serialisation"() { + when: + def result = coercing.serialize(input, graphQLContext, locale) + then: + result == expectedValue + where: + input | expectedValue + Instant.ofEpochSecond(0) | 0L + Instant.ofEpochSecond(1) | 1L + Instant.ofEpochSecond(1609459200) | 1609459200L + LocalDateTime.ofInstant(Instant.ofEpochSecond(1609459200), ZoneOffset.UTC) | 1609459200L + ZonedDateTime.ofInstant(Instant.ofEpochSecond(1609459200), ZoneOffset.UTC) | 1609459200L + OffsetDateTime.ofInstant(Instant.ofEpochSecond(1609459200), ZoneOffset.UTC) | 1609459200L + } + + def "secondsSinceEpoch serialisation bad inputs"() { + when: + coercing.serialize(input, graphQLContext, locale) + then: + thrown(expectedValue) + where: + input | expectedValue + "not a temporal" | CoercingSerializeException + new Object() | CoercingSerializeException + } + + @Unroll + def "secondsSinceEpoch parseLiteral bad inputs"() { + when: + coercing.parseLiteral(input, variables, graphQLContext, locale) + then: + thrown(expectedValue) + where: + input | expectedValue + mkStringValue("not a number") | CoercingParseLiteralException + mkStringValue("123abc") | CoercingParseLiteralException + mkStringValue("2022-01-01") | CoercingParseLiteralException + mkStringValue("2022-01-01T00:00:00Z")| CoercingParseLiteralException + } +}