Skip to content

Add AccurateDuration and NominalDuration Scalars #132

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/main/java/graphql/scalars/ExtendedScalars.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import graphql.scalars.currency.CurrencyScalar;
import graphql.scalars.datetime.DateScalar;
import graphql.scalars.datetime.DateTimeScalar;
import graphql.scalars.datetime.AccurateDurationScalar;
import graphql.scalars.datetime.LocalTimeCoercing;
import graphql.scalars.datetime.NominalDurationScalar;
import graphql.scalars.datetime.TimeScalar;
import graphql.scalars.java.JavaPrimitives;
import graphql.scalars.locale.LocaleScalar;
Expand Down Expand Up @@ -85,6 +87,35 @@ public class ExtendedScalars {
.coercing(new LocalTimeCoercing())
.build();

/**
* A duration scalar that accepts string values like `P1DT2H3M4.5s` and produces * `java.time.Duration` objects at runtime.
* <p>
* Components like years and months are not supported as these may have different meanings depending on the placement in the calendar year.
* <p>
* Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods
* accept Duration and formatted Strings as valid objects.
* <p>
* See the ISO 8601 for more details on the format.
*
* @see java.time.Duration
*/
public static final GraphQLScalarType AccurateDuration = AccurateDurationScalar.INSTANCE;

/**
* An RFC-3339 compliant duration scalar that accepts string values like `P1Y2M3D` and produces
* `java.time.Period` objects at runtime.
* <p>
* Components like hours and seconds are not supported as these are handled by {@link #AccurateDuration}.
* <p>
* Its {@link graphql.schema.Coercing#serialize(java.lang.Object)} and {@link graphql.schema.Coercing#parseValue(java.lang.Object)} methods
* accept Period and formatted Strings as valid objects.
* <p>
* See the ISO 8601 for more details on the format.
*
* @see java.time.Period
*/
public static final GraphQLScalarType NominalDuration = NominalDurationScalar.INSTANCE;

/**
* An object scalar allows you to have a multi level data value without defining it in the graphql schema.
* <p>
Expand Down
93 changes: 93 additions & 0 deletions src/main/java/graphql/scalars/datetime/AccurateDurationScalar.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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.Duration;
import java.time.format.DateTimeParseException;
import java.util.function.Function;

import static graphql.scalars.util.Kit.typeName;

/**
* Access this via {@link graphql.scalars.ExtendedScalars#AccurateDuration}
*/
@Internal
public class AccurateDurationScalar {

public static final GraphQLScalarType INSTANCE;

private AccurateDurationScalar() {}

static {
Coercing<Duration, String> coercing = new Coercing<Duration, String>() {
@Override
public String serialize(Object input) throws CoercingSerializeException {
Duration duration;
if (input instanceof Duration) {
duration = (Duration) input;
} else if (input instanceof String) {
duration = parseDuration(input.toString(), CoercingSerializeException::new);
} else {
throw new CoercingSerializeException(
"Expected something we can convert to 'java.time.Duration' but was '" + typeName(input) + "'."
);
}
return duration.toString();
}

@Override
public Duration parseValue(Object input) throws CoercingParseValueException {
Duration duration;
if (input instanceof Duration) {
duration = (Duration) input;
} else if (input instanceof String) {
duration = parseDuration(input.toString(), CoercingParseValueException::new);
} else {
throw new CoercingParseValueException(
"Expected a 'String' but was '" + typeName(input) + "'."
);
}
return duration;
}

@Override
public Duration parseLiteral(Object input) throws CoercingParseLiteralException {
if (!(input instanceof StringValue)) {
throw new CoercingParseLiteralException(
"Expected AST type 'StringValue' but was '" + typeName(input) + "'."
);
}
return parseDuration(((StringValue) input).getValue(), CoercingParseLiteralException::new);
}

@Override
public Value<?> valueToLiteral(Object input) {
String s = serialize(input);
return StringValue.newStringValue(s).build();
}

private Duration parseDuration(String s, Function<String, RuntimeException> exceptionMaker) {
try {
return Duration.parse(s);
} catch (DateTimeParseException e) {
throw exceptionMaker.apply("Invalid ISO 8601 value : '" + s + "'. because of : '" + e.getMessage() + "'");
}
}
};

INSTANCE = GraphQLScalarType.newScalar()
.name("AccurateDuration")
.description("A ISO 8601 duration scalar with only day, hour, minute, second components.")
.specifiedByUrl("https://scalars.graphql.org/AlexandreCarlton/accurate-duration") // TODO: Change to .specifiedByURL when builder added to graphql-java
.coercing(coercing)
.build();
}

}
92 changes: 92 additions & 0 deletions src/main/java/graphql/scalars/datetime/NominalDurationScalar.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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.Period;
import java.time.format.DateTimeParseException;
import java.util.function.Function;

import static graphql.scalars.util.Kit.typeName;

/**
* Access this via {@link graphql.scalars.ExtendedScalars#NominalDuration}
*/
@Internal
public class NominalDurationScalar {

public static final GraphQLScalarType INSTANCE;

private NominalDurationScalar() {}

static {
Coercing<Period, String> coercing = new Coercing<Period, String>() {
@Override
public String serialize(Object input) throws CoercingSerializeException {
Period period;
if (input instanceof Period) {
period = (Period) input;
} else if (input instanceof String) {
period = parsePeriod(input.toString(), CoercingSerializeException::new);
} else {
throw new CoercingSerializeException(
"Expected something we can convert to 'java.time.OffsetDateTime' but was '" + typeName(input) + "'."
);
}
return period.toString();
}

@Override
public Period parseValue(Object input) throws CoercingParseValueException {
Period period;
if (input instanceof Period) {
period = (Period) input;
} else if (input instanceof String) {
period = parsePeriod(input.toString(), CoercingParseValueException::new);
} else {
throw new CoercingParseValueException(
"Expected a 'String' but was '" + typeName(input) + "'."
);
}
return period;
}

@Override
public Period parseLiteral(Object input) throws CoercingParseLiteralException {
if (!(input instanceof StringValue)) {
throw new CoercingParseLiteralException(
"Expected AST type 'StringValue' but was '" + typeName(input) + "'."
);
}
return parsePeriod(((StringValue) input).getValue(), CoercingParseLiteralException::new);
}

@Override
public Value<?> valueToLiteral(Object input) {
String s = serialize(input);
return StringValue.newStringValue(s).build();
}

private Period parsePeriod(String s, Function<String, RuntimeException> exceptionMaker) {
try {
return Period.parse(s);
} catch (DateTimeParseException e) {
throw exceptionMaker.apply("Invalid ISO 8601 value : '" + s + "'. because of : '" + e.getMessage() + "'");
}
}
};

INSTANCE = GraphQLScalarType.newScalar()
.name("NominalDuration")
.description("A ISO 8601 duration with only year, month, week and day components.")
.specifiedByUrl("https://scalars.graphql.org/AlexandreCarlton/nominal-duration") // TODO: Change to .specifiedByURL when builder added to graphql-java
.coercing(coercing)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package graphql.scalars.datetime


import graphql.language.StringValue
import graphql.scalars.ExtendedScalars
import graphql.schema.CoercingParseLiteralException
import graphql.schema.CoercingParseValueException
import graphql.schema.CoercingSerializeException
import spock.lang.Specification
import spock.lang.Unroll

import java.time.Period
import java.time.temporal.ChronoUnit

import static graphql.scalars.util.TestKit.mkDuration
import static graphql.scalars.util.TestKit.mkStringValue

class AccurateDurationScalarTest extends Specification {

def coercing = ExtendedScalars.AccurateDuration.getCoercing()

@Unroll
def "accurateduration parseValue"() {

when:
def result = coercing.parseValue(input)
then:
result == expectedValue
where:
input | expectedValue
"PT1S" | mkDuration("PT1S")
"PT1.5S" | mkDuration("PT1.5S")
"P1DT2H3M4S" | mkDuration("P1DT2H3M4S")
"-P1DT2H3M4S" | mkDuration("-P1DT2H3M4S")
"P1DT-2H3M4S" | mkDuration("P1DT-2H3M4S")
mkDuration(amount: 123456, unit: ChronoUnit.HOURS) | mkDuration("PT123456H")
}

@Unroll
def "accurateduration valueToLiteral"() {

when:
def result = coercing.valueToLiteral(input)
then:
result.isEqualTo(expectedValue)
where:
input | expectedValue
"PT1S" | mkStringValue("PT1S")
"PT1.5S" | mkStringValue("PT1.5S")
"P1D" | mkStringValue("PT24H")
"P1DT2H3M4S" | mkStringValue("PT26H3M4S")
mkDuration("P1DT2H3M4S") | mkStringValue("PT26H3M4S")
mkDuration("-P1DT2H3M4S") | mkStringValue("PT-26H-3M-4S")
mkDuration(amount: 123456, unit: ChronoUnit.HOURS) | mkStringValue("PT123456H")
}

@Unroll
def "accurateduration parseValue bad inputs"() {

when:
coercing.parseValue(input)
then:
thrown(expectedValue)
where:
input | expectedValue
"P1M" | CoercingParseValueException
"P1MT2H" | CoercingParseValueException
"P2W" | CoercingParseValueException
"P3Y" | CoercingParseValueException
123 | CoercingParseValueException
"" | CoercingParseValueException
Period.of(1, 2, 3) | CoercingParseValueException
}

def "accurateduration AST literal"() {

when:
def result = coercing.parseLiteral(input)
then:
result == expectedValue
where:
input | expectedValue
new StringValue("P1DT2H3M4S") | mkDuration("P1DT2H3M4S")
}

def "accurateduration serialisation"() {

when:
def result = coercing.serialize(input)
then:
result == expectedValue
where:
input | expectedValue
"PT1S" | "PT1S"
"PT1.5S" | "PT1.5S"
"P1DT2H3M4S" | "PT26H3M4S"
"-P1DT2H3M4S" | "PT-26H-3M-4S"
"P1DT-2H3M4S" | "PT22H3M4S"
mkDuration("P1DT-2H3M4S") | "PT22H3M4S"
mkDuration(amount: 123456, unit: ChronoUnit.HOURS) | "PT123456H"
}

def "accurateduration serialisation bad inputs"() {

when:
coercing.serialize(input)
then:
thrown(expectedValue)
where:
input | expectedValue
"P1M" | CoercingSerializeException
"PT1.5M" | CoercingSerializeException
"P1MT2H" | CoercingSerializeException
"P2W" | CoercingSerializeException
"P3Y" | CoercingSerializeException
123 | CoercingSerializeException
"" | CoercingSerializeException
Period.of(1, 2, 3) | CoercingSerializeException
}

@Unroll
def "accurateduration parseLiteral bad inputs"() {

when:
coercing.parseLiteral(input)
then:
thrown(expectedValue)
where:
input | expectedValue
"P1M" | CoercingParseLiteralException
"PT1.5M" | CoercingParseLiteralException
"P1MT2H" | CoercingParseLiteralException
"P2W" | CoercingParseLiteralException
"P3Y" | CoercingParseLiteralException
123 | CoercingParseLiteralException
"" | CoercingParseLiteralException
Period.of(1, 2, 3) | CoercingParseLiteralException
}
}
Loading