From f58eda67c7133b1b96453f3af59c61a21e0bb64b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Marti=C5=A1ka?= Date: Wed, 30 Mar 2022 14:02:48 +0200 Subject: [PATCH] Transform Bean Validation annotations into directives --- common/schema-builder/pom.xml | 8 + .../graphql/schema/SchemaBuilder.java | 4 + .../schema/creator/ArgumentCreator.java | 19 +++ .../graphql/schema/creator/FieldCreator.java | 31 +++- .../graphql/schema/creator/ModelCreator.java | 2 +- .../BeanValidationDirectivesHelper.java | 137 ++++++++++++++++++ .../graphql/schema/helper/Directives.java | 15 +- .../graphql/index/SchemaBuilderTest.java | 2 +- .../ConstraintsInSchemaTest.java | 111 ++++++++++++++ .../schema/model/DirectiveInstance.java | 2 +- .../graphql/schema/model/DirectiveType.java | 48 ++++-- .../smallrye/graphql/schema/model/Field.java | 9 ++ docs/directives.md | 26 ++++ mkdocs.yml | 1 + .../smallrye/graphql/bootstrap/Bootstrap.java | 26 +++- .../datafetcher/helper/DefaultMapAdapter.java | 2 +- 16 files changed, 422 insertions(+), 21 deletions(-) create mode 100644 common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/BeanValidationDirectivesHelper.java create mode 100644 common/schema-builder/src/test/java/io/smallrye/graphql/index/beanvalidation/ConstraintsInSchemaTest.java create mode 100644 docs/directives.md diff --git a/common/schema-builder/pom.xml b/common/schema-builder/pom.xml index 74d70012b..d8bc7540c 100644 --- a/common/schema-builder/pom.xml +++ b/common/schema-builder/pom.xml @@ -19,6 +19,14 @@ ${project.groupId} smallrye-graphql-schema-model + + + + jakarta.validation + jakarta.validation-api + test + 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 e3ebbca62..cf1711924 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 @@ -28,6 +28,7 @@ import io.smallrye.graphql.schema.creator.type.InputTypeCreator; import io.smallrye.graphql.schema.creator.type.InterfaceCreator; import io.smallrye.graphql.schema.creator.type.TypeCreator; +import io.smallrye.graphql.schema.helper.BeanValidationDirectivesHelper; import io.smallrye.graphql.schema.helper.Directives; import io.smallrye.graphql.schema.helper.GroupHelper; import io.smallrye.graphql.schema.helper.TypeAutoNameStrategy; @@ -136,10 +137,13 @@ private Schema generateSchema() { } private void addDirectiveTypes(Schema schema) { + // custom directives from annotations for (AnnotationInstance annotationInstance : ScanningContext.getIndex().getAnnotations(DIRECTIVE)) { ClassInfo classInfo = annotationInstance.target().asClass(); schema.addDirectiveType(directiveTypeCreator.create(classInfo)); } + // bean validation directives + schema.addDirectiveType(BeanValidationDirectivesHelper.CONSTRAINT_DIRECTIVE_TYPE); } private void setupDirectives(Directives directives) { diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ArgumentCreator.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ArgumentCreator.java index 5349ebfc8..80b6379a9 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ArgumentCreator.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ArgumentCreator.java @@ -1,17 +1,21 @@ package io.smallrye.graphql.schema.creator; +import java.util.List; import java.util.Optional; import org.jboss.jandex.DotName; import org.jboss.jandex.MethodInfo; import org.jboss.jandex.Type; +import org.jboss.logging.Logger; import io.smallrye.graphql.schema.Annotations; import io.smallrye.graphql.schema.SchemaBuilderException; +import io.smallrye.graphql.schema.helper.BeanValidationDirectivesHelper; import io.smallrye.graphql.schema.helper.Direction; import io.smallrye.graphql.schema.helper.IgnoreHelper; import io.smallrye.graphql.schema.helper.MethodHelper; import io.smallrye.graphql.schema.model.Argument; +import io.smallrye.graphql.schema.model.DirectiveInstance; import io.smallrye.graphql.schema.model.Operation; import io.smallrye.graphql.schema.model.Reference; import io.smallrye.graphql.schema.model.ReferenceType; @@ -23,8 +27,13 @@ */ public class ArgumentCreator extends ModelCreator { + private BeanValidationDirectivesHelper validationHelper; + + private Logger logger = Logger.getLogger(ArgumentCreator.class.getName()); + public ArgumentCreator(ReferenceCreator referenceCreator) { super(referenceCreator); + validationHelper = new BeanValidationDirectivesHelper(); } /** @@ -76,6 +85,16 @@ public Optional createArgument(Operation operation, MethodInfo methodI argument.setSourceArgument(true); } + if (validationHelper != null) { + List constraintDirectives = validationHelper + .transformBeanValidationConstraintsToDirectives(annotationsForThisArgument); + if (!constraintDirectives.isEmpty()) { + logger.debug("Adding constraint directives " + constraintDirectives + " to argument '" + argument.getName() + + "' of method '" + argument.getMethodName() + "'"); + argument.addDirectiveInstances(constraintDirectives); + } + } + populateField(Direction.IN, argument, argumentType, annotationsForThisArgument); return Optional.of(argument); diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/FieldCreator.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/FieldCreator.java index 5836d62a4..4cc4a72e1 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/FieldCreator.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/FieldCreator.java @@ -1,18 +1,22 @@ package io.smallrye.graphql.schema.creator; import java.lang.reflect.Modifier; +import java.util.List; import java.util.Optional; import org.jboss.jandex.FieldInfo; import org.jboss.jandex.MethodInfo; import org.jboss.jandex.Type; +import org.jboss.logging.Logger; import io.smallrye.graphql.schema.Annotations; import io.smallrye.graphql.schema.Classes; import io.smallrye.graphql.schema.SchemaBuilderException; +import io.smallrye.graphql.schema.helper.BeanValidationDirectivesHelper; import io.smallrye.graphql.schema.helper.Direction; import io.smallrye.graphql.schema.helper.IgnoreHelper; import io.smallrye.graphql.schema.helper.MethodHelper; +import io.smallrye.graphql.schema.model.DirectiveInstance; import io.smallrye.graphql.schema.model.Field; import io.smallrye.graphql.schema.model.Reference; @@ -23,8 +27,13 @@ */ public class FieldCreator extends ModelCreator { + private Logger logger = Logger.getLogger(FieldCreator.class.getName()); + + private BeanValidationDirectivesHelper validationHelper; + public FieldCreator(ReferenceCreator referenceCreator) { super(referenceCreator); + validationHelper = new BeanValidationDirectivesHelper(); } /** @@ -86,6 +95,9 @@ public Optional createFieldForPojo(Direction direction, FieldInfo fieldIn Field field = new Field(methodInfo.name(), MethodHelper.getPropertyName(direction, methodInfo.name()), name, reference); + if (direction == Direction.IN) { + addDirectivesForBeanValidationConstraints(annotationsForPojo, field, parentObjectReference); + } populateField(direction, field, fieldType, methodType, annotationsForPojo); @@ -109,7 +121,7 @@ public Optional createFieldForParameter(MethodInfo method, short position String fieldName = fieldInfo != null ? fieldInfo.name() : null; Field field = new Field(null, fieldName, name, reference); - + addDirectivesForBeanValidationConstraints(annotationsForPojo, field, parentObjectReference); populateField(Direction.IN, field, fieldType, method.parameters().get(position), annotationsForPojo); return Optional.of(field); @@ -141,7 +153,9 @@ public Optional createFieldForPojo(Direction direction, FieldInfo fieldIn fieldInfo.name(), name, reference); - + if (direction == Direction.IN) { + addDirectivesForBeanValidationConstraints(annotationsForPojo, field, parentObjectReference); + } populateField(direction, field, fieldType, annotationsForPojo); return Optional.of(field); @@ -149,6 +163,19 @@ public Optional createFieldForPojo(Direction direction, FieldInfo fieldIn return Optional.empty(); } + private void addDirectivesForBeanValidationConstraints(Annotations annotationsForPojo, Field field, + Reference parentObjectReference) { + if (validationHelper != null) { + List constraintDirectives = validationHelper + .transformBeanValidationConstraintsToDirectives(annotationsForPojo); + if (!constraintDirectives.isEmpty()) { + logger.debug("Adding constraint directives " + constraintDirectives + " to field '" + field.getName() + + "' of parent type '" + parentObjectReference.getName() + "'"); + field.addDirectiveInstances(constraintDirectives); + } + } + } + /** * Checks if method and/or field are use-able as a GraphQL-Field. * diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ModelCreator.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ModelCreator.java index 418678a5b..7b27d67e8 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ModelCreator.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/creator/ModelCreator.java @@ -100,7 +100,7 @@ private void doPopulateField(Direction direction, Field field, Type type, Annota // Directives if (directives != null) { // this happens while scanning for the directive types - field.setDirectiveInstances( + field.addDirectiveInstances( directives.buildDirectiveInstances(name -> annotations.getOneOfTheseAnnotations(name).orElse(null))); } } diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/BeanValidationDirectivesHelper.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/BeanValidationDirectivesHelper.java new file mode 100644 index 000000000..62aa5107d --- /dev/null +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/BeanValidationDirectivesHelper.java @@ -0,0 +1,137 @@ +package io.smallrye.graphql.schema.helper; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.DotName; + +import io.smallrye.graphql.schema.Annotations; +import io.smallrye.graphql.schema.model.DirectiveArgument; +import io.smallrye.graphql.schema.model.DirectiveInstance; +import io.smallrye.graphql.schema.model.DirectiveType; +import io.smallrye.graphql.schema.model.Reference; +import io.smallrye.graphql.schema.model.ReferenceType; + +public class BeanValidationDirectivesHelper { + + private static final DotName VALIDATION_ANNOTATION_EMAIL = DotName.createSimple("javax.validation.constraints.Email"); + private static final DotName VALIDATION_ANNOTATION_MAX = DotName.createSimple("javax.validation.constraints.Max"); + private static final DotName VALIDATION_ANNOTATION_MIN = DotName.createSimple("javax.validation.constraints.Min"); + private static final DotName VALIDATION_ANNOTATION_PATTERN = DotName.createSimple("javax.validation.constraints.Pattern"); + private static final DotName VALIDATION_ANNOTATION_SIZE = DotName.createSimple("javax.validation.constraints.Size"); + + public final static DirectiveType CONSTRAINT_DIRECTIVE_TYPE; + + static { + CONSTRAINT_DIRECTIVE_TYPE = new DirectiveType(); + CONSTRAINT_DIRECTIVE_TYPE.setName("constraint"); + Set locations = new LinkedHashSet<>(); + locations.add("INPUT_FIELD_DEFINITION"); + locations.add("ARGUMENT_DEFINITION"); + CONSTRAINT_DIRECTIVE_TYPE.setLocations(locations); + CONSTRAINT_DIRECTIVE_TYPE.setDescription("Indicates a Bean Validation constraint"); + CONSTRAINT_DIRECTIVE_TYPE.setRepeatable(true); + + Reference INTEGER = new Reference(); + INTEGER.setType(ReferenceType.SCALAR); + INTEGER.setClassName("java.lang.Integer"); + INTEGER.setGraphQlClassName("Int"); + INTEGER.setName("Int"); + + Reference LONG = new Reference(); + LONG.setType(ReferenceType.SCALAR); + LONG.setClassName("java.lang.Long"); + LONG.setGraphQlClassName("BigInteger"); + LONG.setName("BigInteger"); + + Reference STRING = new Reference(); + STRING.setType(ReferenceType.SCALAR); + STRING.setClassName("java.lang.String"); + STRING.setGraphQlClassName("String"); + STRING.setName("String"); + + addArgument("minLength", INTEGER); + addArgument("maxLength", INTEGER); + addArgument("format", STRING); + addArgument("min", LONG); + addArgument("max", LONG); + addArgument("pattern", STRING); + } + + private static void addArgument(String name, Reference reference) { + DirectiveArgument arg = new DirectiveArgument(); + arg.setName(name); + arg.setReference(reference); + CONSTRAINT_DIRECTIVE_TYPE.addArgumentType(arg); + } + + /** + * Finds supported bean validation annotations within the `annotations` list and for each of them, generates + * a `DirectiveInstance` containing a corresponding `@constraint` GraphQL directive. + */ + public List transformBeanValidationConstraintsToDirectives(Annotations annotations) { + List result = new ArrayList<>(); + Set annotationNames = annotations.getAnnotationNames(); + for (DotName annotationName : annotationNames) { + if (annotationName.equals(VALIDATION_ANNOTATION_SIZE)) { + DirectiveInstance directive = new DirectiveInstance(); + directive.setType(CONSTRAINT_DIRECTIVE_TYPE); + + Integer min = getIntValue(annotations, annotationName, "min"); + if (min != null) { + directive.setValue("minLength", min); + } + + Integer max = getIntValue(annotations, annotationName, "max"); + if (max != null) { + directive.setValue("maxLength", max); + } + result.add(directive); + } + if (annotationName.equals(VALIDATION_ANNOTATION_EMAIL)) { + DirectiveInstance directive = new DirectiveInstance(); + directive.setType(CONSTRAINT_DIRECTIVE_TYPE); + directive.setValue("format", "email"); + result.add(directive); + } + if (annotationName.equals(VALIDATION_ANNOTATION_MAX)) { + DirectiveInstance directive = new DirectiveInstance(); + directive.setType(CONSTRAINT_DIRECTIVE_TYPE); + directive.setValue("max", getLongValue(annotations, annotationName, "value")); + result.add(directive); + } + if (annotationName.equals(VALIDATION_ANNOTATION_MIN)) { + DirectiveInstance directive = new DirectiveInstance(); + directive.setType(CONSTRAINT_DIRECTIVE_TYPE); + directive.setValue("min", getLongValue(annotations, annotationName, "value")); + result.add(directive); + } + if (annotationName.equals(VALIDATION_ANNOTATION_PATTERN)) { + DirectiveInstance directive = new DirectiveInstance(); + directive.setType(CONSTRAINT_DIRECTIVE_TYPE); + directive.setValue("pattern", getStringValue(annotations, annotationName, "regexp")); + result.add(directive); + } + } + return result; + } + + private String getStringValue(Annotations annotations, DotName annotationName, String parameterName) { + AnnotationValue aValue = annotations.getAnnotationValue(annotationName, parameterName); + return aValue != null ? aValue.asString() : null; + } + + private Integer getIntValue(Annotations annotations, DotName annotationName, String parameterName) { + AnnotationValue aValue = annotations.getAnnotationValue(annotationName, parameterName); + return aValue != null ? aValue.asInt() : null; + } + + private Long getLongValue(Annotations annotations, DotName annotationName, String parameterName) { + AnnotationValue aValue = annotations.getAnnotationValue(annotationName, parameterName); + return aValue != null ? aValue.asLong() : null; + } + +} diff --git a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/Directives.java b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/Directives.java index f16730a8c..a008e1e4d 100644 --- a/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/Directives.java +++ b/common/schema-builder/src/main/java/io/smallrye/graphql/schema/helper/Directives.java @@ -16,18 +16,31 @@ import io.smallrye.graphql.schema.model.DirectiveType; public class Directives { + + // Directives generated from application annotations that have a `@Directive` on them. + // These directive types are expected to have the `className` field defined private final Map directiveTypes; + // Other directive types - for example, directives from bean validation constraints. + private final List directiveTypesOther; + public Directives(List directiveTypes) { // not with streams/collector, so duplicate keys are allowed and overwritten this.directiveTypes = new HashMap<>(); + this.directiveTypesOther = new ArrayList<>(); for (DirectiveType directiveType : directiveTypes) { - this.directiveTypes.put(DotName.createSimple(directiveType.getClassName()), directiveType); + if (directiveType.getClassName() != null) { + this.directiveTypes.put(DotName.createSimple(directiveType.getClassName()), directiveType); + } else { + this.directiveTypesOther.add(directiveType); + } } } public List buildDirectiveInstances(Function getAnnotation) { List result = null; + // only build directive instances from `@Directive` annotations here (that means the `directiveTypes` map), + // because `directiveTypesOther` directives get their instances added on-the-go by classes that extend `ModelCreator` for (DotName directiveTypeName : directiveTypes.keySet()) { AnnotationInstance annotationInstance = getAnnotation.apply(directiveTypeName); if (annotationInstance == null) { diff --git a/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java b/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java index 343b05386..c2fffb1af 100644 --- a/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java +++ b/common/schema-builder/src/test/java/io/smallrye/graphql/index/SchemaBuilderTest.java @@ -157,7 +157,7 @@ public void testSchemaWithDirectives() throws IOException { assertNotNull(someDirective); assertEquals("someDirective", someDirective.getName()); assertEquals(SomeDirective.class.getName(), someDirective.getClassName()); - assertEquals(singleton("value"), someDirective.getArgumentNames()); + assertEquals(singleton("value"), someDirective.argumentNames()); assertEquals(new HashSet<>(asList("INTERFACE", "FIELD", "OBJECT")), someDirective.getLocations()); // check directive instances on type diff --git a/common/schema-builder/src/test/java/io/smallrye/graphql/index/beanvalidation/ConstraintsInSchemaTest.java b/common/schema-builder/src/test/java/io/smallrye/graphql/index/beanvalidation/ConstraintsInSchemaTest.java new file mode 100644 index 000000000..c9421b2ad --- /dev/null +++ b/common/schema-builder/src/test/java/io/smallrye/graphql/index/beanvalidation/ConstraintsInSchemaTest.java @@ -0,0 +1,111 @@ +package io.smallrye.graphql.index.beanvalidation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import javax.validation.constraints.Email; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.Pattern; +import javax.validation.constraints.Size; + +import org.eclipse.microprofile.graphql.GraphQLApi; +import org.eclipse.microprofile.graphql.Query; +import org.jboss.jandex.Index; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.smallrye.graphql.schema.IndexCreator; +import io.smallrye.graphql.schema.SchemaBuilder; +import io.smallrye.graphql.schema.model.Argument; +import io.smallrye.graphql.schema.model.DirectiveInstance; +import io.smallrye.graphql.schema.model.Field; +import io.smallrye.graphql.schema.model.InputType; +import io.smallrye.graphql.schema.model.Schema; + +public class ConstraintsInSchemaTest { + + @GraphQLApi + public static class ConstraintsApi { + + @Query + public String query(FieldConstraints constraints) { + return null; + } + + @Query + public String queryWithConstrainedArgument(@Size(max = 123) String constrainedArgument) { + return null; + } + } + + public static class FieldConstraints { + + @Size(min = 5, max = 10) + public String stringWithMinMax; + + @Size(min = 7) + public String stringWithMinOnly; + + @Email + public String email; + + @Max(3500) + public Long numberWithMax; + + @Min(2000) + public Long numberWithMin; + + @Pattern(regexp = ".*") + public String stringWithPattern; + + } + + @Test + public void testFieldConstraints() { + Index index = IndexCreator.index(ConstraintsApi.class, FieldConstraints.class); + Schema schema = SchemaBuilder.build(index); + InputType inputType = schema.getInputs().get("FieldConstraintsInput"); + + Field stringWithMinMax = inputType.getFields().get("stringWithMinMax"); + assertHasValue(stringWithMinMax, "minLength", 5); + assertHasValue(stringWithMinMax, "maxLength", 10); + + Field stringWithMinOnly = inputType.getFields().get("stringWithMinOnly"); + assertHasValue(stringWithMinOnly, "minLength", 7); + assertHasValue(stringWithMinOnly, "maxLength", null); + + Field email = inputType.getFields().get("email"); + assertHasValue(email, "format", "email"); + + Field numberWithMinMax = inputType.getFields().get("numberWithMax"); + assertHasValue(numberWithMinMax, "max", 3500L); + + Field numberWithMin = inputType.getFields().get("numberWithMin"); + assertHasValue(numberWithMin, "min", 2000L); + + Field stringWithPattern = inputType.getFields().get("stringWithPattern"); + assertHasValue(stringWithPattern, "pattern", ".*"); + } + + @Test + public void testArgumentConstraints() { + Index index = IndexCreator.index(ConstraintsApi.class, FieldConstraints.class); + Schema schema = SchemaBuilder.build(index); + Argument argument = schema.getQueries().stream() + .filter(q -> q.getName().equals("queryWithConstrainedArgument")) + .findFirst().get() + .getArguments().get(0); + Assertions.assertEquals(123, argument.getDirectiveInstances().get(0).getValue("maxLength")); + } + + private void assertHasValue(Field field, String constraintName, Object value) { + List directiveInstances = field.getDirectiveInstances(); + assertEquals(1, directiveInstances.size()); + DirectiveInstance constraintDirective = directiveInstances.get(0); + assertEquals("constraint", constraintDirective.getType().getName()); + assertEquals(value, constraintDirective.getValue(constraintName), + "Directive values: " + constraintDirective.getValues()); + } +} diff --git a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/DirectiveInstance.java b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/DirectiveInstance.java index 7ac644085..77b1702eb 100644 --- a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/DirectiveInstance.java +++ b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/DirectiveInstance.java @@ -38,6 +38,6 @@ public void setValues(Map values) { @Override public String toString() { - return "DirectiveInstance{" + "type=" + type + ", values=" + values + '}'; + return "DirectiveInstance{" + "type=" + type.getName() + ", values=" + values + '}'; } } diff --git a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/DirectiveType.java b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/DirectiveType.java index cc91048b6..0127e92c7 100644 --- a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/DirectiveType.java +++ b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/DirectiveType.java @@ -1,9 +1,11 @@ package io.smallrye.graphql.schema.model; -import java.util.LinkedHashMap; +import java.util.ArrayList; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; /** * A custom directive in the Schema, i.e. the thing that gets declared in the SDL. @@ -11,15 +13,13 @@ * * @see Custom Directive */ -public final class DirectiveType { +public class DirectiveType { private String className; private String name; private String description; private Set locations = new LinkedHashSet<>(); - private final Map argumentTypes = new LinkedHashMap<>(); - - public DirectiveType() { - } + private List argumentTypes = new ArrayList<>(); + private boolean repeatable; public void setClassName(String className) { this.className = className; @@ -53,16 +53,41 @@ public Set getLocations() { return this.locations; } - public Set getArgumentNames() { - return this.argumentTypes.keySet(); + public List getArgumentTypes() { + return argumentTypes; + } + + public void setArgumentTypes(List argumentTypes) { + this.argumentTypes = argumentTypes; + } + + public boolean isRepeatable() { + return repeatable; + } + + public void setRepeatable(boolean repeatable) { + this.repeatable = repeatable; + } + + /** + * Helper 'getter' methods, but DON'T add 'get' into their names, otherwise it breaks Quarkus bytecode recording, + * because they would be detected as actual property getters while they are actually not + */ + + public Map argumentTypesAsMap() { + return argumentTypes.stream().collect(Collectors.toMap(Field::getName, arg -> arg)); } - public DirectiveArgument getArgumentType(String name) { - return this.argumentTypes.get(name); + public Set argumentNames() { + return this.argumentTypesAsMap().keySet(); + } + + public DirectiveArgument argumentType(String name) { + return this.argumentTypesAsMap().get(name); } public void addArgumentType(DirectiveArgument type) { - this.argumentTypes.put(type.getName(), type); + this.argumentTypes.add(type); } @Override @@ -75,4 +100,5 @@ public String toString() { ((argumentTypes.isEmpty()) ? "" : ", argumentTypes=" + argumentTypes) + ")"; } + } diff --git a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Field.java b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Field.java index b86548bd1..61f32d905 100644 --- a/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Field.java +++ b/common/schema-model/src/main/java/io/smallrye/graphql/schema/model/Field.java @@ -1,6 +1,7 @@ package io.smallrye.graphql.schema.model; import java.io.Serializable; +import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -67,6 +68,7 @@ public class Field implements Serializable { private List directiveInstances; public Field() { + this.directiveInstances = new ArrayList<>(); } public Field(String methodName, String propertyName, String name, Reference reference) { @@ -74,6 +76,7 @@ public Field(String methodName, String propertyName, String name, Reference refe this.propertyName = propertyName; this.name = name; this.reference = reference; + this.directiveInstances = new ArrayList<>(); } public String getMethodName() { @@ -196,6 +199,12 @@ public void setDirectiveInstances(List directiveInstances) { this.directiveInstances = directiveInstances; } + public void addDirectiveInstances(List directiveInstances) { + if (directiveInstances != null) { + this.directiveInstances.addAll(directiveInstances); + } + } + @Override public String toString() { return "Field{" + "methodName=" + methodName + ", propertyName=" + propertyName + ", name=" + name + ", description=" diff --git a/docs/directives.md b/docs/directives.md new file mode 100644 index 000000000..139e1e2a4 --- /dev/null +++ b/docs/directives.md @@ -0,0 +1,26 @@ +# Directives + +## Directives generated from Bean Validation annotations + +If your project uses Bean Validation to validate fields on input types and operation arguments, and you enable +inclusion of directives in the schema (by setting `smallrye.graphql.schema.includeDirectives=true`), +then constraints decoded from annotations will be added to your schema as directives. This is currently only +supported for some built-in constraints (annotations from the +`javax.validation.constraints` package), and custom constraints aren't supported at all. + +Each bean validation annotation is mapped to a single `@constraint` directive. The directive is declared as repeatable, +so if you have multiple constraints on an input field, the field will contain multiple `@constraint` directives. +The following table describes the mapping between BV annotations and `@constraint` directives (all currently supported +BV annotations are listed here): + +| BV annotation | GraphQL directive | +| ------------ | ------------- | +| `@Size(MIN, MAX)` | `@constraint(minLength=MIN, maxLength=MAX)` | +| `@Email` | `@constraint(format='email')` | +| `@Max` | `@constraint(max=VALUE)` | +| `@Min` | `@constraint(min=VALUE)` | +| `@Pattern(REGEXP)` | `@constraint(pattern=REGEXP)` | + +Note: The `@NotNull` annotation does not map to a directive, instead it makes the GraphQL type non-nullable. + +Constraints will only appear on fields of input types and operation arguments. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index ead60d6aa..45928dae6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,7 @@ nav: - Overview: 'index.md' - Server side features: - Customizing JSON deserializers: 'custom-json-deserializers.md' + - Directives: 'directives.md' # - Power annotations: # Power annotations are unused right now # - Power annotations: 'power-annotations.md' - Typesafe client: 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 063dec45c..35d7ec048 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 @@ -201,11 +201,12 @@ private void createGraphQLDirectiveType(DirectiveType directiveType) { for (String location : directiveType.getLocations()) { directiveBuilder.validLocation(DirectiveLocation.valueOf(location)); } - for (String argumentName : directiveType.getArgumentNames()) { - GraphQLInputType argumentType = argumentType(directiveType.getArgumentType(argumentName)); + for (String argumentName : directiveType.argumentNames()) { + GraphQLInputType argumentType = argumentType(directiveType.argumentType(argumentName)); directiveBuilder = directiveBuilder .argument(GraphQLArgument.newArgument().type(argumentType).name(argumentName).build()); } + directiveBuilder.repeatable(directiveType.isRepeatable()); directiveTypes.add(directiveBuilder.build()); } @@ -512,9 +513,16 @@ private GraphQLDirective createGraphQLDirectiveFrom(DirectiveInstance directiveI DirectiveType directiveType = directiveInstance.getType(); GraphQLDirective.Builder directive = GraphQLDirective.newDirective(); directive.name(directiveType.getName()); + directive.repeatable(directiveType.isRepeatable()); for (Entry entry : directiveInstance.getValues().entrySet()) { String argumentName = entry.getKey(); - DirectiveArgument argumentType = directiveType.getArgumentType(argumentName); + DirectiveArgument argumentType = directiveType.argumentType(argumentName); + if (argumentType == null) { + throw new IllegalArgumentException( + "Definition of directive type @" + directiveType.getName() + " does not contain" + + " an argument named " + argumentName + ", but directive instance " + directiveInstance + + " does contain a value for it"); + } directive.argument(GraphQLArgument.newArgument().type(argumentType(argumentType)).name(argumentName) .value(entry.getValue()).build()); } @@ -668,6 +676,12 @@ private GraphQLInputObjectField createGraphQLInputObjectFieldFromField(Field fie // Type inputFieldBuilder = inputFieldBuilder.type(createGraphQLInputType(field)); + if (field.hasDirectiveInstances()) { + for (DirectiveInstance directiveInstance : field.getDirectiveInstances()) { + inputFieldBuilder.withDirective(createGraphQLDirectiveFrom(directiveInstance)); + } + } + // Default value (on method) if (field.hasDefaultValue()) { inputFieldBuilder = inputFieldBuilder.defaultValue(sanitizeDefaultValue(field)); @@ -836,6 +850,12 @@ private GraphQLArgument createGraphQLArgument(Argument argument) { argumentBuilder = argumentBuilder.type(graphQLInputType); + if (argument.hasDirectiveInstances()) { + for (DirectiveInstance directiveInstance : argument.getDirectiveInstances()) { + argumentBuilder.withDirective(createGraphQLDirectiveFrom(directiveInstance)); + } + } + return argumentBuilder.build(); } diff --git a/server/implementation/src/main/java/io/smallrye/graphql/execution/datafetcher/helper/DefaultMapAdapter.java b/server/implementation/src/main/java/io/smallrye/graphql/execution/datafetcher/helper/DefaultMapAdapter.java index ca9941796..4e95f70bd 100644 --- a/server/implementation/src/main/java/io/smallrye/graphql/execution/datafetcher/helper/DefaultMapAdapter.java +++ b/server/implementation/src/main/java/io/smallrye/graphql/execution/datafetcher/helper/DefaultMapAdapter.java @@ -111,7 +111,7 @@ public Field getAdaptedField(Field original) { adaptedField.setAdaptTo(original.getAdaptTo()); adaptedField.setDefaultValue(original.getDefaultValue()); adaptedField.setDescription(original.getDescription()); - adaptedField.setDirectiveInstances(original.getDirectiveInstances()); + adaptedField.addDirectiveInstances(original.getDirectiveInstances()); adaptedField.setNotNull(original.isNotNull()); adaptedField.setTransformation(original.getTransformation());