diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java index a5821babf..bcf3143f0 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/Annotations.java @@ -586,6 +586,7 @@ private static Map getAnnotationsWithFilter(org.jbo // SmallRye GraphQL Annotations (Experimental) public static final DotName TO_SCALAR = DotName.createSimple("io.smallrye.graphql.api.ToScalar"); // TODO: Remove + public static final DotName CUSTOM_SCALAR = DotName.createSimple("io.smallrye.graphql.api.CustomScalar"); public static final DotName ADAPT_TO_SCALAR = DotName.createSimple("io.smallrye.graphql.api.AdaptToScalar"); public static final DotName ADAPT_WITH = DotName.createSimple("io.smallrye.graphql.api.AdaptWith"); public static final DotName ERROR_CODE = DotName.createSimple("io.smallrye.graphql.api.ErrorCode"); diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/SchemaBuilder.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/SchemaBuilder.java index 0fe4b3ac2..cdf05fdd1 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/SchemaBuilder.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/SchemaBuilder.java @@ -1,5 +1,6 @@ package io.smallrye.graphql.schema; +import static io.smallrye.graphql.schema.Annotations.CUSTOM_SCALAR; import static io.smallrye.graphql.schema.Annotations.DIRECTIVE; import java.util.ArrayList; @@ -28,6 +29,7 @@ import io.smallrye.graphql.schema.creator.OperationCreator; import io.smallrye.graphql.schema.creator.ReferenceCreator; import io.smallrye.graphql.schema.creator.type.Creator; +import io.smallrye.graphql.schema.creator.type.CustomScalarCreator; import io.smallrye.graphql.schema.creator.type.EnumCreator; import io.smallrye.graphql.schema.creator.type.InputTypeCreator; import io.smallrye.graphql.schema.creator.type.InterfaceCreator; @@ -73,6 +75,7 @@ public class SchemaBuilder { private final OperationCreator operationCreator; private final DirectiveTypeCreator directiveTypeCreator; private final UnionCreator unionCreator; + private final CustomScalarCreator customScalarCreator; private final DotName FEDERATION_ANNOTATIONS_PACKAGE = DotName.createSimple("io.smallrye.graphql.api.federation"); @@ -109,6 +112,7 @@ private SchemaBuilder(TypeAutoNameStrategy autoNameStrategy) { interfaceCreator = new InterfaceCreator(referenceCreator, fieldCreator, operationCreator); directiveTypeCreator = new DirectiveTypeCreator(referenceCreator); unionCreator = new UnionCreator(referenceCreator); + customScalarCreator = new CustomScalarCreator(referenceCreator); } private Schema generateSchema() { @@ -127,6 +131,8 @@ private Schema generateSchema() { // add AppliedSchemaDirectives and Schema Description setUpSchemaDirectivesAndDescription(schema, graphQLApiAnnotations, directivesHelper); + addCustomScalarTypes(schema); + for (AnnotationInstance graphQLApiAnnotation : graphQLApiAnnotations) { ClassInfo apiClass = graphQLApiAnnotation.target().asClass(); List methods = getAllMethodsIncludingFromSuperClasses(apiClass); @@ -187,7 +193,18 @@ private void addDirectiveTypes(Schema schema) { schema.addDirectiveType(RolesAllowedDirectivesHelper.ROLES_ALLOWED_DIRECTIVE_TYPE); } + private void addCustomScalarTypes(Schema schema) { + Collection annotations = ScanningContext.getIndex().getAnnotations(CUSTOM_SCALAR); + + for (AnnotationInstance annotationInstance : annotations) { + schema.addCustomScalarType(customScalarCreator.create( + annotationInstance.target().asClass(), + annotationInstance.value().asString())); + } + } + private void setupDirectives(Directives directives) { + customScalarCreator.setDirectives(directives); inputTypeCreator.setDirectives(directives); typeCreator.setDirectives(directives); interfaceCreator.setDirectives(directives); diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/type/CustomScalarCreator.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/type/CustomScalarCreator.java new file mode 100644 index 000000000..d4194a985 --- /dev/null +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/type/CustomScalarCreator.java @@ -0,0 +1,91 @@ +package io.smallrye.graphql.schema.creator.type; + +import static io.smallrye.graphql.schema.model.CustomScalarType.CustomScalarPrimitiveType.FLOAT_TYPE; +import static io.smallrye.graphql.schema.model.CustomScalarType.CustomScalarPrimitiveType.INT_TYPE; +import static io.smallrye.graphql.schema.model.CustomScalarType.CustomScalarPrimitiveType.STRING_TYPE; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.ClassType; +import org.jboss.jandex.DotName; +import org.jboss.logging.Logger; + +import io.smallrye.graphql.schema.Annotations; +import io.smallrye.graphql.schema.creator.ModelCreator; +import io.smallrye.graphql.schema.creator.ReferenceCreator; +import io.smallrye.graphql.schema.helper.DescriptionHelper; +import io.smallrye.graphql.schema.helper.Directives; +import io.smallrye.graphql.schema.model.CustomScalarType; +import io.smallrye.graphql.schema.model.CustomScalarType.CustomScalarPrimitiveType; +import io.smallrye.graphql.schema.model.DirectiveInstance; + +public class CustomScalarCreator extends ModelCreator { + + private static final Logger LOG = Logger.getLogger(CustomScalarCreator.class.getName()); + + private Directives directives; + + public CustomScalarCreator(ReferenceCreator referenceCreator) { + super(referenceCreator); + } + + public CustomScalarType create( + ClassInfo classInfo, + String scalarName) { + LOG.debug("Creating custom scalar from " + classInfo.name().toString()); + + Annotations annotations = Annotations.getAnnotationsForClass(classInfo); + + Set interfaces = classInfo.interfaceNames().stream().map(DotName::toString) + .collect(Collectors.toSet()); + CustomScalarPrimitiveType customScalarPrimitiveType; + if (interfaces.contains("io.smallrye.graphql.api.CustomIntScalar")) { + checkForOneArgConstructor(classInfo, BigInteger.class); + customScalarPrimitiveType = INT_TYPE; + } else if (interfaces.contains("io.smallrye.graphql.api.CustomFloatScalar")) { + checkForOneArgConstructor(classInfo, BigDecimal.class); + customScalarPrimitiveType = FLOAT_TYPE; + } else if (interfaces.contains("io.smallrye.graphql.api.CustomStringScalar")) { + checkForOneArgConstructor(classInfo, String.class); + customScalarPrimitiveType = STRING_TYPE; + } else { + throw new RuntimeException(classInfo.name().toString() + " is required to implement a " + + "known CustomScalar primitive type. (CustomStringScalar, CustomFloatScalar, " + + "CustomIntScalar)"); + } + + return new CustomScalarType( + classInfo.name().toString(), + scalarName, + DescriptionHelper.getDescriptionForType(annotations).orElse(null), + customScalarPrimitiveType); + + } + + private static void checkForOneArgConstructor(final ClassInfo classInfo, Class argType) { + if (classInfo.constructors().stream().noneMatch(methodInfo -> methodInfo.parameters().size() == 1 + && methodInfo.parameterType(0).equals(ClassType.create(argType)))) { + throw new RuntimeException(classInfo.name().toString() + " is required to implement a " + + "one arg constructor with a type of " + argType.getName()); + } + } + + @Override + public String getDirectiveLocation() { + return "SCALAR"; + } + + private List getDirectiveInstances(Annotations annotations, + String referenceName) { + return directives.buildDirectiveInstances(annotations, getDirectiveLocation(), referenceName); + } + + public void setDirectives(Directives directives) { + this.directives = directives; + } +} diff --git a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/CustomScalarType.java b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/CustomScalarType.java new file mode 100644 index 000000000..e8d4ba3fc --- /dev/null +++ b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/CustomScalarType.java @@ -0,0 +1,52 @@ +package io.smallrye.graphql.schema.model; + +import static io.smallrye.graphql.schema.model.CustomScalarType.CustomScalarPrimitiveType.FLOAT_TYPE; +import static io.smallrye.graphql.schema.model.CustomScalarType.CustomScalarPrimitiveType.INT_TYPE; +import static io.smallrye.graphql.schema.model.CustomScalarType.CustomScalarPrimitiveType.STRING_TYPE; + +public final class CustomScalarType extends Reference { + + private String description; + private CustomScalarPrimitiveType customScalarPrimitiveType; + + public CustomScalarType() { + } + + public CustomScalarType(String className, String name, String description, + CustomScalarPrimitiveType customScalarPrimitiveType) { + super(className, name, ReferenceType.SCALAR); + this.description = description; + this.customScalarPrimitiveType = customScalarPrimitiveType; + } + + public String getDescription() { + return description; + } + + public CustomScalarPrimitiveType getCustomScalarPrimitiveType() { + return customScalarPrimitiveType; + } + + public void setDescription(String description) { + this.description = description; + } + + public void setCustomScalarPrimitiveType(CustomScalarPrimitiveType customScalarPrimitiveType) { + this.customScalarPrimitiveType = customScalarPrimitiveType; + } + + @Override + public String toString() { + return "CustomScalarType{" + + "name='" + getName() + '\'' + + ", description='" + description + '\'' + + ", customScalar='" + customScalarPrimitiveType.name() + '\'' + + '}'; + } + + public enum CustomScalarPrimitiveType { + STRING_TYPE, + INT_TYPE, + FLOAT_TYPE + } +} diff --git a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Scalars.java b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Scalars.java index 901feab8c..8e698ecc2 100644 --- a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Scalars.java +++ b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Scalars.java @@ -94,6 +94,12 @@ public static Reference getIDScalar(String className) { .build(); } + public static void registerCustomScalarInSchema( + String graphQlScalarName, + String valueClassName) { + populateScalar(valueClassName, graphQlScalarName, valueClassName); + } + // this is for the UUID from graphql-java-extended-scalars // if used, it will override the original UUID type that is mapped to a String in the schema public static void addUuid() { diff --git a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Schema.java b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Schema.java index 579a17967..8575c949c 100644 --- a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Schema.java +++ b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Schema.java @@ -26,6 +26,7 @@ public final class Schema implements Serializable { private Map> groupedMutations = new HashMap<>(); private Map> groupedSubscriptions = new HashMap<>(); + private List customScalarTypes = new ArrayList<>(); private List directiveTypes = new ArrayList<>(); private Map inputs = new HashMap<>(); private Map types = new HashMap<>(); @@ -320,6 +321,21 @@ private void addToOperationMap(Map> map, Group group, Oper map.put(group, set); } + public void addCustomScalarType(CustomScalarType customScalarType) { + customScalarTypes.add(customScalarType); + Scalars.registerCustomScalarInSchema( + customScalarType.getName(), + customScalarType.getClassName()); + } + + public List getCustomScalarTypes() { + return customScalarTypes; + } + + public boolean hasCustomScalarTypes() { + return !customScalarTypes.isEmpty(); + } + public List getDirectiveTypes() { return directiveTypes; } @@ -347,6 +363,7 @@ public String toString() { ", groupedMutations=" + groupedMutations + ", groupedSubscriptions=" + groupedSubscriptions + ", directiveTypes=" + directiveTypes + + ", customScalarTypes=" + customScalarTypes + ", inputs=" + inputs + ", types=" + types + ", interfaces=" + interfaces + diff --git a/docs/custom-scalar.md b/docs/custom-scalar.md new file mode 100644 index 000000000..e8a5fe994 --- /dev/null +++ b/docs/custom-scalar.md @@ -0,0 +1,30 @@ +Creating GraphQL Custom Scalars with SmallRye GraphQL +======= +While by default **MicroProfile GraphQL** specification doesn't provide direct support for creating +custom scalars for GraphQL literal types of String, Int and Float, **SmallRye GraphQL** has implemented +this feature based on user feedback. To implement a **custom scalar** with **SmallRye GraphQL**, +you can annotate your custom scalar class with `@CustomScalar` in addition to following the pattern below +of including a public constructor that takes one of `String`|`BigDecimal`|`BigInteger` depending on +the scalar type: +```java + @CustomScalar("BigDecimalString") + public class BigDecimalString implements CustomStringScalar { + public BigDecimalString(String stringValue) { + ... + } + @Override + public String stringValueForSerialization() { + ... + } +} +``` +In this example, `BigDecimalString` implements the `CustomStringScalar` which is used to identify the +proper (de) serialization for BigDecimalString. `BigDecimalString` also provides a single argument +constructor which takes a String. Finally, `BigDecimalString` implements +`stringValueForSerialization()` which provides the String representation to be used during +serialization. + +> [NOTE] +> If the user wants to create a literal for GraphQL Int or Float, they would implement either +> CustomIntScalar with the intValueForSerialization method or CustomFloatScalar with the +> floatValueForSerialization method respectively. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index d854d3235..b9d6b4ca1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ nav: - Response extensions: 'extensions.md' - Returning Void from Mutations: 'mutation-void.md' - Handling of the WebSocket's init-payload: 'handling-init-payload-from-the-websocket.md' + - Custom scalars: 'custom-scalar.md' - Typesafe client: - Basic usage: 'typesafe-client-usage.md' - Reactive: 'typesafe-client-reactive-types.md' diff --git a/server/api/src/main/java/io/smallrye/graphql/api/CustomFloatScalar.java b/server/api/src/main/java/io/smallrye/graphql/api/CustomFloatScalar.java new file mode 100644 index 000000000..941fadf6a --- /dev/null +++ b/server/api/src/main/java/io/smallrye/graphql/api/CustomFloatScalar.java @@ -0,0 +1,10 @@ +package io.smallrye.graphql.api; + +import java.math.BigDecimal; + +/** + * A base class for all CustomScalars that are based on GraphQL's Float. + */ +public interface CustomFloatScalar { + BigDecimal floatValueForSerialization(); +} diff --git a/server/api/src/main/java/io/smallrye/graphql/api/CustomIntScalar.java b/server/api/src/main/java/io/smallrye/graphql/api/CustomIntScalar.java new file mode 100644 index 000000000..7839fbb9e --- /dev/null +++ b/server/api/src/main/java/io/smallrye/graphql/api/CustomIntScalar.java @@ -0,0 +1,10 @@ +package io.smallrye.graphql.api; + +import java.math.BigInteger; + +/** + * A base class for all CustomScalars that are based on GraphQL's Int. + */ +public interface CustomIntScalar { + BigInteger intValueForSerialization(); +} diff --git a/server/api/src/main/java/io/smallrye/graphql/api/CustomScalar.java b/server/api/src/main/java/io/smallrye/graphql/api/CustomScalar.java new file mode 100644 index 000000000..c4c9860c7 --- /dev/null +++ b/server/api/src/main/java/io/smallrye/graphql/api/CustomScalar.java @@ -0,0 +1,22 @@ +package io.smallrye.graphql.api; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import io.smallrye.common.annotation.Experimental; + +/** + * Allows for definition of custom graphql scalars. Types with this annotation should extend one of + * CustomStringScalar, CustomIntScalar, or CustomFloatScalar. Additionally, the Type should provide + * a public single argument constructor taking the associated type(String, BigInteger, BigDecimal). + */ +@Retention(RUNTIME) +@Target(TYPE) +@Experimental("Mark a type as a custom scalar with the given name in the GraphQL schema.") +public @interface CustomScalar { + + String value(); +} diff --git a/server/api/src/main/java/io/smallrye/graphql/api/CustomStringScalar.java b/server/api/src/main/java/io/smallrye/graphql/api/CustomStringScalar.java new file mode 100644 index 000000000..41bbd20e1 --- /dev/null +++ b/server/api/src/main/java/io/smallrye/graphql/api/CustomStringScalar.java @@ -0,0 +1,8 @@ +package io.smallrye.graphql.api; + +/** + * A base class for all CustomScalars that are based on GraphQL's String. + */ +public interface CustomStringScalar { + String stringValueForSerialization(); +} diff --git a/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/Bootstrap.java b/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/Bootstrap.java index 892ae7392..f5e57134d 100644 --- a/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/Bootstrap.java +++ b/server/implementation/src/main/java/io/smallrye/graphql/bootstrap/Bootstrap.java @@ -31,6 +31,7 @@ import com.apollographql.federation.graphqljava.Federation; import graphql.introspection.Introspection.DirectiveLocation; +import graphql.schema.Coercing; import graphql.schema.DataFetcher; import graphql.schema.FieldCoordinates; import graphql.schema.GraphQLArgument; @@ -67,7 +68,12 @@ import io.smallrye.graphql.json.JsonBCreator; import io.smallrye.graphql.json.JsonInputRegistry; import io.smallrye.graphql.scalar.GraphQLScalarTypes; +import io.smallrye.graphql.scalar.custom.FloatCoercing; +import io.smallrye.graphql.scalar.custom.IntCoercing; +import io.smallrye.graphql.scalar.custom.StringCoercing; import io.smallrye.graphql.schema.model.Argument; +import io.smallrye.graphql.schema.model.CustomScalarType; +import io.smallrye.graphql.schema.model.CustomScalarType.CustomScalarPrimitiveType; import io.smallrye.graphql.schema.model.DirectiveArgument; import io.smallrye.graphql.schema.model.DirectiveInstance; import io.smallrye.graphql.schema.model.DirectiveType; @@ -160,6 +166,7 @@ private void verifyInjectionIsAvailable() { private void generateGraphQLSchema() { GraphQLSchema.Builder schemaBuilder = GraphQLSchema.newSchema(); + createGraphQLCustomScalarTypes(); createGraphQLEnumTypes(); createGraphQLDirectiveTypes(); @@ -223,6 +230,49 @@ private TypeResolver fetchEntityType() { }; } + private void createGraphQLCustomScalarTypes() { + if (schema.hasCustomScalarTypes()) { + for (CustomScalarType customScalarType : schema.getCustomScalarTypes()) { + createGraphQLCustomScalarType(customScalarType); + } + } + } + + private void createGraphQLCustomScalarType(CustomScalarType customScalarType) { + String scalarName = customScalarType.getName(); + + Coercing coercing = getCoercing(customScalarType); + + GraphQLScalarType graphQLScalarType = GraphQLScalarType.newScalar() + .name(scalarName) + .description("Scalar for " + scalarName) + .coercing(coercing) + .build(); + + GraphQLScalarTypes.registerCustomScalar( + scalarName, + customScalarType.getClassName(), + graphQLScalarType); + } + + private static Coercing getCoercing(CustomScalarType customScalarType) { + CustomScalarPrimitiveType primitiveType = customScalarType.getCustomScalarPrimitiveType(); + + Coercing coercing = null; + switch (primitiveType) { + case STRING_TYPE: + coercing = new StringCoercing(customScalarType.getClassName()); + break; + case INT_TYPE: + coercing = new IntCoercing(customScalarType.getClassName()); + break; + case FLOAT_TYPE: + coercing = new FloatCoercing(customScalarType.getClassName()); + break; + } + return coercing; + } + private void createGraphQLDirectiveTypes() { if (schema.hasDirectiveTypes()) { for (DirectiveType directiveType : schema.getDirectiveTypes()) { @@ -526,7 +576,7 @@ private GraphQLInputObjectType createGraphQLInputObjectType(InputType inputType) if (inputType.hasFields()) { inputObjectTypeBuilder = inputObjectTypeBuilder .fields(createGraphQLInputObjectFieldsFromFields(inputType.getFields().values())); - // Register this input for posible JsonB usage + // Register this input for possible JsonB usage JsonInputRegistry.register(inputType); } diff --git a/server/implementation/src/main/java/io/smallrye/graphql/json/JsonBCreator.java b/server/implementation/src/main/java/io/smallrye/graphql/json/JsonBCreator.java index ee68b910b..7f4ee2f2a 100644 --- a/server/implementation/src/main/java/io/smallrye/graphql/json/JsonBCreator.java +++ b/server/implementation/src/main/java/io/smallrye/graphql/json/JsonBCreator.java @@ -1,15 +1,35 @@ package io.smallrye.graphql.json; +import static io.smallrye.graphql.json.JsonBCreator.CustomScalarSerializers.CUSTOM_FLOAT_DESERIALIZER; +import static io.smallrye.graphql.json.JsonBCreator.CustomScalarSerializers.CUSTOM_FLOAT_SERIALIZER; +import static io.smallrye.graphql.json.JsonBCreator.CustomScalarSerializers.CUSTOM_INT_DESERIALIZER; +import static io.smallrye.graphql.json.JsonBCreator.CustomScalarSerializers.CUSTOM_INT_SERIALIZER; +import static io.smallrye.graphql.json.JsonBCreator.CustomScalarSerializers.CUSTOM_STRING_DESERIALIZER; +import static io.smallrye.graphql.json.JsonBCreator.CustomScalarSerializers.CUSTOM_STRING_SERIALIZER; + +import java.lang.reflect.Type; +import java.math.BigDecimal; import java.util.Collection; import java.util.HashMap; import java.util.Map; +import jakarta.json.JsonValue.ValueType; import jakarta.json.bind.Jsonb; import jakarta.json.bind.JsonbBuilder; import jakarta.json.bind.JsonbConfig; +import jakarta.json.bind.serializer.DeserializationContext; +import jakarta.json.bind.serializer.JsonbDeserializer; +import jakarta.json.bind.serializer.JsonbSerializer; +import jakarta.json.bind.serializer.SerializationContext; +import jakarta.json.stream.JsonGenerator; +import jakarta.json.stream.JsonParser; +import io.smallrye.graphql.api.CustomFloatScalar; +import io.smallrye.graphql.api.CustomIntScalar; +import io.smallrye.graphql.api.CustomStringScalar; import io.smallrye.graphql.schema.model.Field; import io.smallrye.graphql.schema.model.InputType; +import io.smallrye.graphql.spi.ClassloadingService; /** * Here we create JsonB Objects for certain input object. @@ -21,7 +41,13 @@ public class JsonBCreator { private static final Jsonb JSONB = JsonbBuilder.create(new JsonbConfig() .withFormatting(true) - .withNullValues(true)); //null values are required by @JsonbCreator + .withNullValues(true) //null values are required by @JsonbCreator + .withSerializers(CUSTOM_STRING_SERIALIZER, + CUSTOM_INT_SERIALIZER, + CUSTOM_FLOAT_SERIALIZER) + .withDeserializers(CUSTOM_STRING_DESERIALIZER, + CUSTOM_INT_DESERIALIZER, + CUSTOM_FLOAT_DESERIALIZER)); private static final Map jsonMap = new HashMap<>(); @@ -75,4 +101,114 @@ private static JsonbConfig createDefaultConfig() { .withNullValues(Boolean.TRUE) .withFormatting(Boolean.TRUE); } + + static class CustomScalarSerializers { + // Note: using lambdas for the SERIALIZER/DESERIALIZER instances doesn't work because it + // hides the parameterized type from Jsonb. + + /** + * A serializer for CustomScalars based on GraphQL Strings, to inform JsonB how to serialize + * a CustomStringScalar to a String value. + */ + static JsonbSerializer CUSTOM_STRING_SERIALIZER = new JsonbSerializer<>() { + @Override + public void serialize(CustomStringScalar customStringScalar, JsonGenerator jsonGenerator, + SerializationContext serializationContext) { + jsonGenerator.write(customStringScalar.stringValueForSerialization()); + } + }; + + /** + * A deserializer for CustomScalars based on GraphQL Strings, to inform JsonB how to + * deserialize to an instance of a CustomStringScalar. + */ + static JsonbDeserializer CUSTOM_STRING_DESERIALIZER = new JsonbDeserializer<>() { + @Override + public CustomStringScalar deserialize(JsonParser jsonParser, + DeserializationContext deserializationContext, Type type) { + ClassloadingService classloadingService = ClassloadingService.get(); + try { + if (jsonParser.getValue().getValueType() == ValueType.NULL) { + return null; + } else { + return (CustomStringScalar) classloadingService.loadClass(type.getTypeName()) + .getConstructor(String.class) + .newInstance(jsonParser.getString()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }; + + /** + * A serializer for CustomScalars based on a GraphQL Int, to inform JsonB how to serialize + * a CustomStringScalar to a BigInteger value. + */ + static JsonbSerializer CUSTOM_INT_SERIALIZER = new JsonbSerializer<>() { + @Override + public void serialize(CustomIntScalar customIntScalar, JsonGenerator jsonGenerator, + SerializationContext serializationContext) { + jsonGenerator.write(customIntScalar.intValueForSerialization()); + } + }; + + /** + * A deserializer for CustomScalars based on a GraphQL Int, to inform JsonB how to + * deserialize to an instance of a CustomIntScalar. + */ + static JsonbDeserializer CUSTOM_INT_DESERIALIZER = new JsonbDeserializer<>() { + @Override + public CustomIntScalar deserialize(JsonParser jsonParser, + DeserializationContext deserializationContext, Type type) { + ClassloadingService classloadingService = ClassloadingService.get(); + try { + if (jsonParser.getValue().getValueType() == ValueType.NULL) { + return null; + } else { + return (CustomIntScalar) classloadingService.loadClass(type.getTypeName()) + .getConstructor(Integer.class) + .newInstance(jsonParser.getInt()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }; + + /** + * A serializer for CustomScalars based on a GraphQL Float, to inform JsonB how to serialize + * a CustomStringScalar to a BigDecimal value. + */ + static JsonbSerializer CUSTOM_FLOAT_SERIALIZER = new JsonbSerializer<>() { + @Override + public void serialize(CustomFloatScalar customFloatScalar, JsonGenerator jsonGenerator, + SerializationContext serializationContext) { + jsonGenerator.write(customFloatScalar.floatValueForSerialization()); + } + }; + + /** + * A deserializer for CustomScalars based on a GraphQL Float, to inform JsonB how to + * deserialize to an instance of a CustomFloatScalar. + */ + static JsonbDeserializer CUSTOM_FLOAT_DESERIALIZER = new JsonbDeserializer<>() { + @Override + public CustomFloatScalar deserialize(JsonParser jsonParser, + DeserializationContext deserializationContext, Type type) { + ClassloadingService classloadingService = ClassloadingService.get(); + try { + if (jsonParser.getValue().getValueType() == ValueType.NULL) { + return null; + } else { + return (CustomFloatScalar) classloadingService.loadClass(type.getTypeName()) + .getConstructor(BigDecimal.class) + .newInstance(jsonParser.getBigDecimal()); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }; + } } diff --git a/server/implementation/src/main/java/io/smallrye/graphql/scalar/GraphQLScalarTypes.java b/server/implementation/src/main/java/io/smallrye/graphql/scalar/GraphQLScalarTypes.java index 3f33f4180..21babf93a 100644 --- a/server/implementation/src/main/java/io/smallrye/graphql/scalar/GraphQLScalarTypes.java +++ b/server/implementation/src/main/java/io/smallrye/graphql/scalar/GraphQLScalarTypes.java @@ -52,6 +52,14 @@ public static void addUuid() { SCALARS_BY_NAME.put(ExtendedScalars.UUID.getName(), ExtendedScalars.UUID); } + public static void registerCustomScalar( + String graphQlScalarName, + String valueClassName, + GraphQLScalarType graphQLScalarType) { + SCALAR_MAP.put(valueClassName, graphQLScalarType); + SCALARS_BY_NAME.put(graphQlScalarName, graphQLScalarType); + } + // Scalar map we can just create now. private static final Map SCALAR_MAP = new HashMap<>(); diff --git a/server/implementation/src/main/java/io/smallrye/graphql/scalar/custom/FloatCoercing.java b/server/implementation/src/main/java/io/smallrye/graphql/scalar/custom/FloatCoercing.java new file mode 100644 index 000000000..b14aa6dba --- /dev/null +++ b/server/implementation/src/main/java/io/smallrye/graphql/scalar/custom/FloatCoercing.java @@ -0,0 +1,98 @@ +package io.smallrye.graphql.scalar.custom; + +import static io.smallrye.graphql.SmallRyeGraphQLServerMessages.msg; + +import java.lang.reflect.InvocationTargetException; +import java.math.BigDecimal; + +import graphql.language.FloatValue; +import graphql.language.Value; +import graphql.schema.Coercing; +import graphql.schema.CoercingParseLiteralException; +import graphql.schema.CoercingParseValueException; +import graphql.schema.CoercingSerializeException; +import io.smallrye.graphql.api.CustomFloatScalar; +import io.smallrye.graphql.spi.ClassloadingService; + +public class FloatCoercing implements Coercing { + + private final Class customScalarClass; + + public FloatCoercing(String customScalarClass) { + ClassloadingService classloadingService = ClassloadingService.get(); + this.customScalarClass = (Class) classloadingService.loadClass(customScalarClass); + } + + private CustomFloatScalar newInstance(BigDecimal graphqlPrimitiveValue) + throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { + return graphqlPrimitiveValue == null ? null + : customScalarClass.getConstructor(BigDecimal.class).newInstance(graphqlPrimitiveValue); + + } + + /* Coercing implementation. Forgive the deprecated methods. */ + private static String typeName(Object input) { + if (input == null) { + return "null"; + } + return input.getClass().getSimpleName(); + } + + private CustomFloatScalar convertImpl(Object input) + throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { + if (input instanceof BigDecimal) { + return newInstance((BigDecimal) input); + } else if (input.getClass().isAssignableFrom(customScalarClass)) { + return (CustomFloatScalar) input; + } + throw new RuntimeException("Unable to convert null input."); + } + + @Override + public BigDecimal serialize(Object input) throws CoercingSerializeException { + CustomFloatScalar result; + try { + result = convertImpl(input); + } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) { + throw new CoercingSerializeException("Unable to serialize input " + input, e); + } catch (RuntimeException e) { + throw msg.coercingSerializeException("BigDecimal or class extending " + + customScalarClass, typeName(input), null); + } + return result.floatValueForSerialization(); + } + + @Override + public Object parseValue(Object input) throws CoercingParseValueException { + Object result; + try { + result = convertImpl(input); + } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) { + throw new CoercingParseValueException("Unable to parse input: " + input, e); + } catch (RuntimeException e) { + throw msg.coercingParseValueException("BigDecimal or class extending " + + customScalarClass, typeName(input), null); + } + return result; + } + + @Override + public Object parseLiteral(Object input) throws CoercingParseLiteralException { + if (!(input instanceof FloatValue)) { + throw new CoercingParseLiteralException( + "Expected a String AST type object but was '" + typeName(input) + "'."); + } + try { + return newInstance(((FloatValue) input).getValue()); + } catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) { + throw new CoercingParseLiteralException("Unable to parse literal:" + input, e); + } + } + + @Override + public Value valueToLiteral(Object input) { + BigDecimal s = serialize(input); + return FloatValue.newFloatValue(s).build(); + } + +} diff --git a/server/implementation/src/main/java/io/smallrye/graphql/scalar/custom/IntCoercing.java b/server/implementation/src/main/java/io/smallrye/graphql/scalar/custom/IntCoercing.java new file mode 100644 index 000000000..47d951c0b --- /dev/null +++ b/server/implementation/src/main/java/io/smallrye/graphql/scalar/custom/IntCoercing.java @@ -0,0 +1,97 @@ +package io.smallrye.graphql.scalar.custom; + +import static io.smallrye.graphql.SmallRyeGraphQLServerMessages.msg; + +import java.lang.reflect.InvocationTargetException; +import java.math.BigInteger; + +import graphql.language.IntValue; +import graphql.language.Value; +import graphql.schema.Coercing; +import graphql.schema.CoercingParseLiteralException; +import graphql.schema.CoercingParseValueException; +import graphql.schema.CoercingSerializeException; +import io.smallrye.graphql.api.CustomIntScalar; +import io.smallrye.graphql.spi.ClassloadingService; + +public class IntCoercing implements Coercing { + + private final Class customScalarClass; + + public IntCoercing(String customScalarClass) { + ClassloadingService classloadingService = ClassloadingService.get(); + this.customScalarClass = (Class) classloadingService.loadClass(customScalarClass); + } + + private CustomIntScalar newInstance(BigInteger graphqlPrimitiveValue) + throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { + return graphqlPrimitiveValue == null ? null + : customScalarClass.getConstructor(BigInteger.class).newInstance(graphqlPrimitiveValue); + } + + /* Coercing implementation. Forgive the deprecated methods. */ + private static String typeName(Object input) { + if (input == null) { + return "null"; + } + return input.getClass().getSimpleName(); + } + + private CustomIntScalar convertImpl(Object input) + throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { + if (input instanceof BigInteger) { + return newInstance((BigInteger) input); + } else if (input.getClass().isAssignableFrom(customScalarClass)) { + return (CustomIntScalar) input; + } + throw new RuntimeException("Unable to convert null input."); + } + + @Override + public BigInteger serialize(Object input) throws CoercingSerializeException { + CustomIntScalar result; + try { + result = convertImpl(input); + } catch (InvocationTargetException | IllegalAccessException | InstantiationException | NoSuchMethodException e) { + throw new CoercingSerializeException("Unable to serialize input: " + input, e); + } catch (RuntimeException e) { + throw msg.coercingSerializeException("BigInteger or class extending " + + customScalarClass, typeName(input), e); + } + return result.intValueForSerialization(); + } + + @Override + public Object parseValue(Object input) throws CoercingParseValueException { + Object result; + try { + result = convertImpl(input); + } catch (InvocationTargetException | IllegalAccessException | InstantiationException | NoSuchMethodException e) { + throw new CoercingParseValueException("Unable to serialize input: " + input, e); + } catch (RuntimeException e) { + throw msg.coercingParseValueException("BigInteger or class extending " + + customScalarClass, typeName(input), null); + } + return result; + } + + @Override + public Object parseLiteral(Object input) throws CoercingParseLiteralException { + if (!(input instanceof IntValue)) { + throw new CoercingParseLiteralException( + "Expected a String AST type object but was '" + typeName(input) + "'."); + } + try { + return newInstance(((IntValue) input).getValue()); + } catch (InvocationTargetException | IllegalAccessException | InstantiationException | NoSuchMethodException e) { + throw new CoercingParseLiteralException("Unable to parse literal: " + input, e); + } + } + + @Override + public Value valueToLiteral(Object input) { + BigInteger s = serialize(input); + return IntValue.newIntValue(s).build(); + } + +} diff --git a/server/implementation/src/main/java/io/smallrye/graphql/scalar/custom/StringCoercing.java b/server/implementation/src/main/java/io/smallrye/graphql/scalar/custom/StringCoercing.java new file mode 100644 index 000000000..6ddb37da9 --- /dev/null +++ b/server/implementation/src/main/java/io/smallrye/graphql/scalar/custom/StringCoercing.java @@ -0,0 +1,98 @@ +package io.smallrye.graphql.scalar.custom; + +import static io.smallrye.graphql.SmallRyeGraphQLServerMessages.msg; + +import java.lang.reflect.InvocationTargetException; + +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 io.smallrye.graphql.api.CustomStringScalar; +import io.smallrye.graphql.spi.ClassloadingService; + +public class StringCoercing implements Coercing { + + private final Class customScalarClass; + + public StringCoercing(String customScalarClass) { + ClassloadingService classloadingService = ClassloadingService.get(); + this.customScalarClass = (Class) classloadingService.loadClass(customScalarClass); + } + + private CustomStringScalar newInstance(String graphqlPrimitiveValue) + throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { + + return graphqlPrimitiveValue == null ? null + : customScalarClass.getConstructor(String.class).newInstance(graphqlPrimitiveValue); + + } + + /* Coercing implementation. Forgive the deprecated methods. */ + private static String typeName(Object input) { + if (input == null) { + return "null"; + } + return input.getClass().getSimpleName(); + } + + private CustomStringScalar convertImpl(Object input) + throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { + if (input instanceof String) { + return newInstance((String) input); + } else if (input.getClass().isAssignableFrom(customScalarClass)) { + return (CustomStringScalar) input; + } + throw new RuntimeException("Unable to convert null input."); + } + + @Override + public String serialize(Object input) throws CoercingSerializeException { + CustomStringScalar result; + try { + result = convertImpl(input); + } catch (InvocationTargetException | NoSuchMethodException | InstantiationException | IllegalAccessException e) { + throw new CoercingSerializeException("Unable to serialize object.", e); + } catch (RuntimeException e) { + throw msg.coercingSerializeException("String or class extending " + + customScalarClass, typeName(input), null); + } + return result.stringValueForSerialization(); + } + + @Override + public Object parseValue(Object input) throws CoercingParseValueException { + Object result; + try { + result = convertImpl(input); + } catch (InvocationTargetException | NoSuchMethodException | InstantiationException | IllegalAccessException e) { + throw new CoercingParseValueException("Unable to parse value: " + input, e); + } catch (RuntimeException e) { + throw msg.coercingParseValueException("String or class extending " + + customScalarClass, typeName(input), null); + } + return result; + } + + @Override + public Object parseLiteral(Object input) throws CoercingParseLiteralException { + if (!(input instanceof StringValue)) { + throw new CoercingParseLiteralException( + "Expected a String AST type object but was '" + typeName(input) + "'."); + } + try { + return newInstance(((StringValue) input).getValue()); + } catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException | InstantiationException e) { + throw new CoercingParseLiteralException("Unable to parse literal: " + input, e); + } + } + + @Override + public Value valueToLiteral(Object input) { + String s = serialize(input); + return StringValue.newStringValue(s).build(); + } + +} diff --git a/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/BigDecimalString.java b/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/BigDecimalString.java new file mode 100644 index 000000000..b46dd4d13 --- /dev/null +++ b/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/BigDecimalString.java @@ -0,0 +1,40 @@ +package io.smallrye.graphql.tests.customscalars; + +import java.math.BigDecimal; + +import io.smallrye.graphql.api.CustomScalar; +import io.smallrye.graphql.api.CustomStringScalar; + +/** + * An alternative BigDecimal scalar that serializes to a GraphQL String instead of a Float. + *

+ */ +@CustomScalar("BigDecimalString") +public class BigDecimalString implements CustomStringScalar { + + private final BigDecimal value; + + public BigDecimalString(String stringValue) { + this.value = stringValue == null ? null : new BigDecimal(stringValue); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return super.equals(obj) || value.equals(obj); + } + + @Override + public String toString() { + return value == null ? null : value.toPlainString(); + } + + @Override + public String stringValueForSerialization() { + return toString(); + } +} diff --git a/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/CustomScalarTest.java b/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/CustomScalarTest.java new file mode 100644 index 000000000..e9c988a12 --- /dev/null +++ b/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/CustomScalarTest.java @@ -0,0 +1,196 @@ +package io.smallrye.graphql.tests.customscalars; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URL; + +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.container.test.api.RunAsClient; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.arquillian.test.api.ArquillianResource; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Test; +import org.junit.runner.RunWith; + +import io.smallrye.graphql.tests.GraphQLAssured; + +@RunWith(Arquillian.class) +@RunAsClient +public class CustomScalarTest { + + @Deployment + public static WebArchive deployment() { + return ShrinkWrap.create(WebArchive.class, "customscalar-test.war") + .addClasses(SomeApi.class, BigDecimalString.class, TwiceTheFloat.class); + } + + @ArquillianResource + URL testingURL; + + @Test + public void inAsScalarNullableTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { inAsScalarNullable(scalar: \"1234567890.987654321\") }")) + .contains("{\"data\":{\"inAsScalarNullable\":\"1234567890.987654321\"}}") + .doesNotContain("error"); + assertThat(graphQLAssured + .post("query { inAsFScalarNullable(fScalar: 10.0) }")) + .contains("{\"data\":{\"inAsFScalarNullable\":10.0}}") + .doesNotContain("error"); + + assertThat(graphQLAssured + .post("query { inAsScalarNullable(scalar: null) }")) + .contains("{\"data\":{\"inAsScalarNullable\":null}}") + .doesNotContain("error"); + + assertThat(graphQLAssured + .post("query { inAsScalarNullable }")) + .contains("{\"data\":{\"inAsScalarNullable\":null}}") + .doesNotContain("error"); + } + + @Test + public void inAsScalarNullableDefaultNonNullTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { inAsScalarNullableDefaultNonNull }")) + .contains("{\"data\":{\"inAsScalarNullableDefaultNonNull\":\"1234567.89\"}}") + .doesNotContain("error"); + } + + @Test + public void inAsScalarNullableDefaultNullTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { inAsScalarNullableDefaultNull }")) + .contains("{\"data\":{\"inAsScalarNullableDefaultNull\":null}}") + .doesNotContain("error"); + } + + @Test + public void inAsScalarRequiredTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { inAsScalarRequired(scalar: \"1234567890.987654321\") }")) + .contains("{\"data\":{\"inAsScalarRequired\":\"1234567890.987654321\"}}") + .doesNotContain("error"); + + assertThat(graphQLAssured + .post("query { inAsScalarRequired(scalar: null) }")) + .contains("NullValueForNonNullArgument@[inAsScalarRequired]") + .contains("\"data\":null"); + + assertThat(graphQLAssured + .post("query { inAsScalarRequired }")) + .contains("MissingFieldArgument@[inAsScalarRequired]") + .contains("\"data\":null"); + } + + @Test + public void inAsScalarRequiredDefaultTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { inAsScalarRequiredDefault }")) + .contains("{\"data\":{\"inAsScalarRequiredDefault\":\"1234567.89\"}}") + .doesNotContain("error"); + + } + + @Test + public void inAsScalarListNullableTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { inAsScalarListNullable(scalars: [\"1234567890.987654321\"]) }")) + .contains("{\"data\":{\"inAsScalarListNullable\":[\"1234567890.987654321\"]}}") + .doesNotContain("error"); + + assertThat(graphQLAssured + .post("query { inAsScalarListNullable(scalars: [null]) }")) + .contains("{\"data\":{\"inAsScalarListNullable\":[null]}}") + .doesNotContain("error"); + } + + @Test + public void inAsScalarListRequiredTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { inAsScalarListRequired(scalars: [\"1234567890.987654321\"]) }")) + .contains("{\"data\":{\"inAsScalarListRequired\":[\"1234567890.987654321\"]}}") + .doesNotContain("error"); + + assertThat(graphQLAssured + .post("query { inAsScalarListRequired(scalars: [null]) }")) + .contains("WrongType@[inAsScalarListRequired]") + .contains("must not be null") + .contains("\"data\":null"); + } + + @Test + public void inAsFieldNullableTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { inAsFieldNullable(input: { scalar: \"1234567890.987654321\" } ) }")) + .contains("{\"data\":{\"inAsFieldNullable\":\"1234567890.987654321\"}}") + .doesNotContain("error"); + + assertThat(graphQLAssured + .post("query { inAsFieldNullable(input: { scalar: null } ) }")) + .contains("{\"data\":{\"inAsFieldNullable\":null}}") + .doesNotContain("error"); + + assertThat(graphQLAssured + .post("query { inAsFieldNullable(input: { } ) }")) + .contains("{\"data\":{\"inAsFieldNullable\":null}}") + .doesNotContain("error"); + } + + @Test + public void inAsFloatFieldNullableTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { inAsFFieldNullable(input: { fScalar: 20.0 } ) }")) + .contains("{\"data\":{\"inAsFFieldNullable\":20.0}}") + .doesNotContain("error"); + + assertThat(graphQLAssured + .post("query { inAsFFieldNullable(input: { fScalar: null } ) }")) + .contains("{\"data\":{\"inAsFFieldNullable\":null}}") + .doesNotContain("error"); + + assertThat(graphQLAssured + .post("query { inAsFFieldNullable(input: { } ) }")) + .contains("{\"data\":{\"inAsFFieldNullable\":null}}") + .doesNotContain("error"); + } + + @Test + public void outputTwiceTheFloatTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { outputFloat }")) + .contains("{\"data\":{\"outputFloat\":10.0}}") + .doesNotContain("error"); + } + + @Test + public void outputScalarObjectTest() { + GraphQLAssured graphQLAssured = new GraphQLAssured(testingURL); + + assertThat(graphQLAssured + .post("query { outputScalars {fScalar, sScalar}}")) + .contains("{\"data\":{\"outputScalars\":{\"fScalar\":30.0,\"sScalar\":\"98765.56789\"}}}") + .doesNotContain("error"); + } +} diff --git a/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/SomeApi.java b/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/SomeApi.java new file mode 100644 index 000000000..de71f83ab --- /dev/null +++ b/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/SomeApi.java @@ -0,0 +1,94 @@ +package io.smallrye.graphql.tests.customscalars; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +import org.eclipse.microprofile.graphql.DefaultValue; +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.NonNull; +import org.eclipse.microprofile.graphql.Query; + +@GraphQLApi +public class SomeApi { + public static class ObjNullable { + public BigDecimalString scalar; + } + + public static class FObjNullable { + public TwiceTheFloat fScalar; + } + + public static class ObjOfScalars { + public TwiceTheFloat fScalar; + public BigDecimalString sScalar; + } + + @Query + public String inAsScalarNullable(BigDecimalString scalar) { + return null == scalar ? null : scalar.toString(); + } + + @Query + public String inAsScalarNullableDefaultNonNull(@DefaultValue("1234567.89") BigDecimalString scalar) { + return null == scalar ? null : scalar.toString(); + } + + @Query + public String inAsScalarNullableDefaultNull(@DefaultValue() BigDecimalString scalar) { + return null == scalar ? null : scalar.toString(); + } + + @Query + public String inAsScalarRequiredDefault(@NonNull @DefaultValue("1234567.89") BigDecimalString scalar) { + return null == scalar ? null : scalar.toString(); + } + + @Query + public String inAsScalarRequired(@NonNull BigDecimalString scalar) { + return null == scalar ? null : scalar.toString(); + } + + @Query + public List inAsScalarListNullable(List scalars) { + return null == scalars ? null : scalars.stream().map(s -> null == s ? null : s.toString()).collect(Collectors.toList()); + } + + @Query + public List inAsScalarListRequired(List<@NonNull BigDecimalString> scalars) { + return null == scalars ? null : scalars.stream().map(Objects::toString).collect(Collectors.toList()); + } + + @Query + public String inAsFieldNullable(ObjNullable input) { + return null == input ? null : null == input.scalar ? null : input.scalar.toString(); + } + + @Query + public BigDecimal inAsFScalarNullable(TwiceTheFloat fScalar) { + return null == fScalar ? null : fScalar.floatValueForSerialization().setScale(1, RoundingMode.HALF_EVEN); + } + + @Query + public BigDecimal inAsFFieldNullable(FObjNullable input) { + return null == input ? null + : null == input.fScalar ? null + : input.fScalar.floatValueForSerialization().setScale(1, RoundingMode.HALF_EVEN); + } + + @Query + public TwiceTheFloat outputFloat() { + return new TwiceTheFloat(BigDecimal.valueOf(10)); + } + + @Query + public ObjOfScalars outputScalars() { + ObjOfScalars oScalars = new ObjOfScalars(); + oScalars.fScalar = new TwiceTheFloat(BigDecimal.valueOf(30)); + oScalars.sScalar = new BigDecimalString("98765.56789"); + return oScalars; + } + +} diff --git a/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/TwiceTheFloat.java b/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/TwiceTheFloat.java new file mode 100644 index 000000000..89ea30f3f --- /dev/null +++ b/server/integration-tests/src/test/java/io/smallrye/graphql/tests/customscalars/TwiceTheFloat.java @@ -0,0 +1,40 @@ +package io.smallrye.graphql.tests.customscalars; + +import java.math.BigDecimal; + +import io.smallrye.graphql.api.CustomFloatScalar; +import io.smallrye.graphql.api.CustomScalar; + +/** + * A float scalar that serializes to a GraphQL Float equal to twice the internal value. + *

+ */ +@CustomScalar("TwiceTheFloat") +public class TwiceTheFloat implements CustomFloatScalar { + + private final BigDecimal value; + + public TwiceTheFloat(BigDecimal floatValue) { + this.value = floatValue == null ? null : floatValue.multiply(BigDecimal.valueOf(.5)); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return super.equals(obj) || value.equals(obj); + } + + @Override + public String toString() { + return value == null ? null : value.toPlainString(); + } + + @Override + public BigDecimal floatValueForSerialization() { + return value.multiply(BigDecimal.valueOf(2)); + } +}