diff --git a/graphql-builder/pom.xml b/graphql-builder/pom.xml index e6e72ed5..6f5e95ec 100644 --- a/graphql-builder/pom.xml +++ b/graphql-builder/pom.xml @@ -11,99 +11,108 @@ the License. --> - - 4.0.0 - graphql-builder - Builds a graphql schema from a model using reflection + + 4.0.0 + graphql-builder + Builds a graphql schema from a model using reflection - - com.phocassoftware - graphql-builder-parent - 1.1.0-SNAPSHOT - + + com.phocassoftware + graphql-builder-parent + 1.1.0-SNAPSHOT + - graphql-builder + graphql-builder - - 5.13.0 - UTF-8 - 2.19.0 - 24.1 - + + 5.13.0 + UTF-8 + 2.19.0 + 24.1 + - - - Apache License, Version 2.0 - http://www.apache.org/licenses/LICENSE-2.0.txt - repo - - + + + Apache License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + repo + + - - - com.graphql-java - graphql-java - ${graphql.version} - - - com.graphql-java - graphql-java-extended-scalars - 21.0 - test - - - org.reflections - reflections - 0.10.2 - - - jakarta.annotation - jakarta.annotation-api - 3.0.0 - - - com.fasterxml.jackson.core - jackson-databind - ${jackson.version} - test - - - com.fasterxml.jackson.module - jackson-module-parameter-names - ${jackson.version} - test - - - com.fasterxml.jackson.datatype - jackson-datatype-jdk8 - ${jackson.version} - test - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - ${jackson.version} - test - - - io.reactivex.rxjava3 - rxjava - 3.1.10 - test - + + + com.graphql-java + graphql-java + ${graphql.version} + + + com.graphql-java + graphql-java-extended-scalars + 24.0 + + + com.graphql-java + graphql-java-extended-validation + 24.0 + + + org.reflections + reflections + 0.10.2 + + + jakarta.annotation + jakarta.annotation-api + 3.0.0 + + + jakarta.validation + jakarta.validation-api + 3.1.0 + - - org.junit.jupiter - junit-jupiter - ${junit.jupiter.version} - test - - - - org.skyscreamer - jsonassert - 1.5.3 - test - - + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + test + + + com.fasterxml.jackson.module + jackson-module-parameter-names + ${jackson.version} + test + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${jackson.version} + test + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson.version} + test + + + io.reactivex.rxjava3 + rxjava + 3.1.10 + test + + + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + + + org.skyscreamer + jsonassert + 1.5.3 + test + + diff --git a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/DirectiveProcessor.java b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/DirectiveProcessor.java index 1126b8a4..2fd35869 100644 --- a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/DirectiveProcessor.java +++ b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/DirectiveProcessor.java @@ -13,11 +13,16 @@ import com.phocassoftware.graphql.builder.annotations.Directive; import graphql.introspection.Introspection; -import graphql.schema.*; +import graphql.schema.GraphQLAppliedDirective; +import graphql.schema.GraphQLAppliedDirectiveArgument; +import graphql.schema.GraphQLArgument; +import graphql.schema.GraphQLDirective; + import java.lang.annotation.Annotation; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.util.*; +import java.util.HashMap; +import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; @@ -31,17 +36,26 @@ public DirectiveProcessor(GraphQLDirective directive, Map directive) { + public static DirectiveProcessor build(EntityProcessor entityProcessor, Class directive, boolean isJakarta) { var builder = GraphQLDirective.newDirective().name(directive.getSimpleName()); - var validLocations = directive.getAnnotation(Directive.class).value(); + + Introspection.DirectiveLocation[] validLocations; + if (isJakarta) { + validLocations = new Introspection.DirectiveLocation[] { + Introspection.DirectiveLocation.ARGUMENT_DEFINITION, + Introspection.DirectiveLocation.INPUT_FIELD_DEFINITION }; + } else { + validLocations = directive.getAnnotation(Directive.class).value(); + + // Check for repeatable tag in annotation and add it + builder.repeatable(directive.getAnnotation(Directive.class).repeatable()); + } + // loop through and add valid locations for (Introspection.DirectiveLocation location : validLocations) { builder.validLocation(location); } - // Check for repeatable tag in annotation and add it - builder.repeatable(directive.getAnnotation(Directive.class).repeatable()); - // Go through each argument and add name/type to directive var methods = directive.getDeclaredMethods(); Map> builders = new HashMap<>(); diff --git a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/DirectivesSchema.java b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/DirectivesSchema.java index c30541c4..7da6dbd8 100644 --- a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/DirectivesSchema.java +++ b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/DirectivesSchema.java @@ -17,6 +17,8 @@ import graphql.schema.DataFetchingEnvironment; import graphql.schema.GraphQLAppliedDirective; import graphql.schema.GraphQLDirective; +import org.reactivestreams.Publisher; + import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.InvocationTargetException; @@ -28,27 +30,33 @@ import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.reactivestreams.Publisher; class DirectivesSchema { private final Collection> global; private final Map, DirectiveCaller> targets; private final Collection> directives; + private final Collection> jakartaDirectives; private Map, DirectiveProcessor> directiveProcessors; private DirectivesSchema( Collection> global, Map, DirectiveCaller> targets, - Collection> directives + Collection> directives, + Collection> jakartaDirectives ) { this.global = global; this.targets = targets; this.directives = directives; + this.jakartaDirectives = jakartaDirectives; } // TODO:mess of exceptions - public static DirectivesSchema build(List> globalDirectives, Set> directiveTypes) throws ReflectiveOperationException { + public static DirectivesSchema build( + List> globalDirectives, + Set> directiveTypes, + Set> jakartaDirectiveTypes + ) throws ReflectiveOperationException { Map, DirectiveCaller> targets = new HashMap<>(); Collection> allDirectives = new ArrayList<>(); @@ -71,7 +79,15 @@ public static DirectivesSchema build(List> globalDirectiv allDirectives.add((Class) directiveType); } - return new DirectivesSchema(globalDirectives, targets, allDirectives); + Collection> jakartaDirectives = new ArrayList<>(); + for (Class jakartaDirectiveType : jakartaDirectiveTypes) { + if (!jakartaDirectiveType.isAnnotation()) { + continue; + } + jakartaDirectives.add((Class) jakartaDirectiveType); + } + + return new DirectivesSchema(globalDirectives, targets, allDirectives, jakartaDirectives); } private DirectiveCaller get(Annotation annotation) { @@ -79,9 +95,7 @@ private DirectiveCaller get(Annotation annotation) { } private DataFetcher wrap(DirectiveCaller directive, T annotation, DataFetcher fetcher) { - return env -> { - return directive.process(annotation, env, fetcher); - }; + return env -> directive.process(annotation, env, fetcher); } public Stream getSchemaDirective() { @@ -102,8 +116,8 @@ private DataFetcher wrap(RestrictTypeFactory directive, DataFetcher fet } return applyRestrict(restrict, response); } catch (Exception e) { - if (e instanceof RuntimeException) { - throw (RuntimeException) e; + if (e instanceof RuntimeException runtimeException) { + throw runtimeException; } throw new RuntimeException(e); } @@ -111,9 +125,9 @@ private DataFetcher wrap(RestrictTypeFactory directive, DataFetcher fet } public boolean target(Method method, TypeMeta meta) { - for (var global : this.global) { + for (var globalRestricts : this.global) { // TODO: extract class - if (global.extractType().isAssignableFrom(meta.getType())) { + if (globalRestricts.extractType().isAssignableFrom(meta.getType())) { return true; } } @@ -132,7 +146,7 @@ public DataFetcher wrap(Method method, TypeMeta meta, DataFetcher fetcher) } } for (Annotation annotation : method.getAnnotations()) { - DirectiveCaller directive = (DirectiveCaller) get(annotation); + DirectiveCaller directive = get(annotation); if (directive != null) { fetcher = wrap(directive, annotation, fetcher); } @@ -140,7 +154,7 @@ public DataFetcher wrap(Method method, TypeMeta meta, DataFetcher fetcher) return fetcher; } - private CompletableFuture applyRestrict(RestrictType restrict, Object response) { + private CompletableFuture applyRestrict(RestrictType restrict, Object response) { if (response instanceof List) { return restrict.filter((List) response); } else if (response instanceof Publisher) { @@ -177,23 +191,6 @@ private CompletableFuture applyRestrict(RestrictType restrict, Objec } } - private static CompletableFuture> all(List> toReturn) { - return CompletableFuture - .allOf(toReturn.toArray(CompletableFuture[]::new)) - .thenApply( - __ -> toReturn - .stream() - .map(m -> { - try { - return m.get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - }) - .collect(Collectors.toList()) - ); - } - public void addSchemaDirective(AnnotatedElement element, Class location, Consumer builder) { for (Annotation annotation : element.getAnnotations()) { var processor = this.directiveProcessors.get(annotation.annotationType()); @@ -208,9 +205,10 @@ public void addSchemaDirective(AnnotatedElement element, Class location, Cons } public void processDirectives(EntityProcessor ep) { // Replacement of processSDL - Map, DirectiveProcessor> directiveProcessors = new HashMap<>(); + Map, DirectiveProcessor> directiveProcessorsList = new HashMap<>(); - this.directives.forEach(dir -> directiveProcessors.put(dir, DirectiveProcessor.build(ep, dir))); - this.directiveProcessors = directiveProcessors; + this.directives.forEach(dir -> directiveProcessorsList.put(dir, DirectiveProcessor.build(ep, dir, false))); + this.jakartaDirectives.forEach(dir -> directiveProcessorsList.put(dir, DirectiveProcessor.build(ep, dir, true))); + this.directiveProcessors = directiveProcessorsList; } } diff --git a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/EntityProcessor.java b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/EntityProcessor.java index 519942c9..1d4041fb 100644 --- a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/EntityProcessor.java +++ b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/EntityProcessor.java @@ -21,6 +21,7 @@ import graphql.schema.GraphQLOutputType; import graphql.schema.GraphQLScalarType; import graphql.schema.GraphQLType; + import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Type; @@ -38,13 +39,13 @@ public class EntityProcessor { private final Map entities; private final MethodProcessor methodProcessor; - EntityProcessor(DataFetcherRunner dataFetcherRunner, List scalars, DirectivesSchema diretives) { - this.methodProcessor = new MethodProcessor(dataFetcherRunner, this, diretives); + EntityProcessor(DataFetcherRunner dataFetcherRunner, List scalars, DirectivesSchema directives) { + this.methodProcessor = new MethodProcessor(dataFetcherRunner, this, directives); this.entities = new HashMap<>(); addDefaults(); addScalars(scalars); - this.directives = diretives; + this.directives = directives; } private void addDefaults() { diff --git a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/MethodProcessor.java b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/MethodProcessor.java index d1a4fa82..c635619f 100644 --- a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/MethodProcessor.java +++ b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/MethodProcessor.java @@ -13,13 +13,14 @@ import static com.phocassoftware.graphql.builder.EntityUtil.isContext; -import com.phocassoftware.graphql.builder.annotations.Directive; import com.phocassoftware.graphql.builder.annotations.GraphQLDeprecated; import com.phocassoftware.graphql.builder.annotations.GraphQLDescription; import com.phocassoftware.graphql.builder.annotations.Mutation; import com.phocassoftware.graphql.builder.annotations.Query; import com.phocassoftware.graphql.builder.annotations.Subscription; import graphql.GraphQLContext; +import graphql.GraphQLError; +import graphql.execution.DataFetcherResult; import graphql.schema.DataFetcher; import graphql.schema.DataFetchingEnvironment; import graphql.schema.FieldCoordinates; @@ -30,17 +31,20 @@ import graphql.schema.GraphQLFieldDefinition; import graphql.schema.GraphQLFieldDefinition.Builder; import graphql.schema.GraphQLObjectType; +import graphql.validation.rules.ValidationRules; + import java.lang.annotation.Annotation; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.List; import java.util.function.Function; class MethodProcessor { private final DataFetcherRunner dataFetcherRunner; private final EntityProcessor entityProcessor; - private final DirectivesSchema diretives; + private final DirectivesSchema directives; private final GraphQLCodeRegistry.Builder codeRegistry; @@ -48,10 +52,10 @@ class MethodProcessor { private final GraphQLObjectType.Builder graphMutations; private final GraphQLObjectType.Builder graphSubscriptions; - public MethodProcessor(DataFetcherRunner dataFetcherRunner, EntityProcessor entityProcessor, DirectivesSchema diretives) { + public MethodProcessor(DataFetcherRunner dataFetcherRunner, EntityProcessor entityProcessor, DirectivesSchema directives) { this.dataFetcherRunner = dataFetcherRunner; this.entityProcessor = entityProcessor; - this.diretives = diretives; + this.directives = directives; this.codeRegistry = GraphQLCodeRegistry.newCodeRegistry(); this.graphQuery = GraphQLObjectType.newObject(); @@ -62,7 +66,7 @@ public MethodProcessor(DataFetcherRunner dataFetcherRunner, EntityProcessor enti graphSubscriptions.name("Subscriptions"); } - void process(AuthorizerSchema authorizer, Method method) throws ReflectiveOperationException { + void process(AuthorizerSchema authorizer, Method method, boolean shouldValidate) { if (!Modifier.isStatic(method.getModifiers())) { throw new RuntimeException("End point must be a static method"); } @@ -81,11 +85,14 @@ void process(AuthorizerSchema authorizer, Method method) throws ReflectiveOperat return; } - object.field(process(authorizer, coordinates, null, method)); + object.field(process(authorizer, coordinates, null, method, shouldValidate)); + } + + Builder process(AuthorizerSchema authorizer, FieldCoordinates coordinates, TypeMeta parentMeta, Method method) { + return process(authorizer, coordinates, parentMeta, method, false); } - Builder process(AuthorizerSchema authorizer, FieldCoordinates coordinates, TypeMeta parentMeta, Method method) - throws InvocationTargetException, IllegalAccessException { + Builder process(AuthorizerSchema authorizer, FieldCoordinates coordinates, TypeMeta parentMeta, Method method, boolean shouldValidate) { GraphQLFieldDefinition.Builder field = GraphQLFieldDefinition.newFieldDefinition(); entityProcessor.addSchemaDirective(method, method.getDeclaringClass(), field::withAppliedDirective); @@ -120,50 +127,27 @@ Builder process(AuthorizerSchema authorizer, FieldCoordinates coordinates, TypeM argument.description(description.value()); } - for (Annotation annotation : parameter.getAnnotations()) { - // Check to see if the annotation is a directive - if (!annotation.annotationType().isAnnotationPresent(Directive.class)) { - continue; - } - var annotationType = annotation.annotationType(); - // Get the values out of the directive annotation - var methods = annotationType.getDeclaredMethods(); - - // Get the applied directive and add it to the argument - var appliedDirective = getAppliedDirective(annotation, annotationType, methods); - argument.withAppliedDirective(appliedDirective); - } + entityProcessor.addSchemaDirective(parameter, method.getDeclaringClass(), argument::withAppliedDirective); argument.name(EntityUtil.getName(parameter.getName(), parameter)); // TODO: argument.defaultValue(defaultValue) field.argument(argument); } - DataFetcher fetcher = buildFetcher(diretives, authorizer, method, meta); + DataFetcher fetcher = buildFetcher(directives, authorizer, method, meta, shouldValidate); codeRegistry.dataFetcher(coordinates, fetcher); return field; } - private GraphQLAppliedDirective getAppliedDirective(Annotation annotation, Class annotationType, Method[] methods) - throws IllegalAccessException, InvocationTargetException { - var appliedDirective = new GraphQLAppliedDirective.Builder().name(annotationType.getSimpleName()); - for (var definedMethod : methods) { - var name = definedMethod.getName(); - var value = definedMethod.invoke(annotation); - if (value == null) { - continue; - } - - TypeMeta innerMeta = new TypeMeta(null, definedMethod.getReturnType(), definedMethod.getGenericReturnType()); - var argumentType = entityProcessor.getEntity(innerMeta).getInputType(innerMeta, definedMethod.getAnnotations()); - appliedDirective.argument(GraphQLAppliedDirectiveArgument.newArgument().name(name).type(argumentType).valueProgrammatic(value).build()); - } - return appliedDirective.build(); - } - - private DataFetcher buildFetcher(DirectivesSchema diretives, AuthorizerSchema authorizer, Method method, TypeMeta meta) { - DataFetcher fetcher = buildDataFetcher(meta, method); - fetcher = diretives.wrap(method, meta, fetcher); + private DataFetcher buildFetcher( + DirectivesSchema directives, + AuthorizerSchema authorizer, + Method method, + TypeMeta meta, + boolean shouldValidate + ) { + DataFetcher fetcher = buildDataFetcher(meta, method, shouldValidate); + fetcher = directives.wrap(method, meta, fetcher); if (authorizer != null) { fetcher = authorizer.wrap(fetcher, method); @@ -171,7 +155,7 @@ private DataFetcher buildFetcher(DirectivesSchema dire return fetcher; } - private DataFetcher buildDataFetcher(TypeMeta meta, Method method) { + private DataFetcher buildDataFetcher(TypeMeta meta, Method method, boolean shouldValidate) { Function[] resolvers = new Function[method.getParameterCount()]; method.setAccessible(true); @@ -185,8 +169,17 @@ private DataFetcher buildDataFetcher(TypeMeta meta, Method method) { resolvers[i] = buildResolver(name, argMeta, parameter.getAnnotations()); } + ValidationRules validationRules = ValidationRules.newValidationRules().build(); + DataFetcher fetcher = env -> { try { + if (shouldValidate) { + List errors = validationRules.runValidationRules(env); + if (!errors.isEmpty()) { + return DataFetcherResult.newResult().errors(errors).data(null).build(); + } + } + Object[] args = new Object[resolvers.length]; for (int i = 0; i < resolvers.length; i++) { args[i] = resolvers[i].apply(env); diff --git a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/SchemaBuilder.java b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/SchemaBuilder.java index 5a3c58c2..df939412 100644 --- a/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/SchemaBuilder.java +++ b/graphql-builder/src/main/java/com/phocassoftware/graphql/builder/SchemaBuilder.java @@ -12,16 +12,24 @@ package com.phocassoftware.graphql.builder; import com.phocassoftware.graphql.builder.annotations.*; +import graphql.scalars.ExtendedScalars; import graphql.schema.GraphQLScalarType; import graphql.schema.GraphQLSchema; + +import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; + +import jakarta.validation.Constraint; import org.reflections.Reflections; import org.reflections.scanners.Scanners; +import static org.reflections.scanners.Scanners.SubTypes; + public class SchemaBuilder { private final DirectivesSchema directives; @@ -49,10 +57,10 @@ private SchemaBuilder processTypes(Set> types) { return this; } - private SchemaBuilder process(HashSet endPoints) throws ReflectiveOperationException { + private SchemaBuilder process(HashSet endPoints, boolean shouldValidate) throws ReflectiveOperationException { var methodProcessor = this.entityProcessor.getMethodProcessor(); for (var method : endPoints) { - methodProcessor.process(authorizer, method); + methodProcessor.process(authorizer, method, shouldValidate); } return this; @@ -75,7 +83,7 @@ private graphql.schema.GraphQLSchema.Builder build(Set builder.additionalDirective(directive)); + directives.getSchemaDirective().forEach(builder::additionalDirective); for (var schema : schemaConfiguration) { this.directives.addSchemaDirective(schema, schema, builder::withSchemaAppliedDirective); @@ -102,8 +110,9 @@ public static Builder builder() { public static class Builder { private DataFetcherRunner dataFetcherRunner = (method, fetcher) -> fetcher; - private List classpaths = new ArrayList<>(); - private List scalars = new ArrayList<>(); + private final List classpaths = new ArrayList<>(); + private final List scalars = new ArrayList<>(); + private boolean shouldValidate = false; private Builder() {} @@ -122,50 +131,23 @@ public Builder scalar(GraphQLScalarType scalar) { return this; } + public Builder validate() { + this.shouldValidate = true; + return this; + } + public GraphQLSchema.Builder build() { try { - Reflections reflections = new Reflections(classpaths, Scanners.SubTypes, Scanners.MethodsAnnotated, Scanners.TypesAnnotated); + this.scalar(ExtendedScalars.GraphQLLong); + + Reflections reflections = new Reflections(classpaths, SubTypes, Scanners.MethodsAnnotated, Scanners.TypesAnnotated); Set> authorizers = reflections.getSubTypesOf(Authorizer.class); // want to make everything split by package AuthorizerSchema authorizer = AuthorizerSchema.build(dataFetcherRunner, new HashSet<>(classpaths), authorizers); Set> schemaConfiguration = reflections.getSubTypesOf(SchemaConfiguration.class); - Set> directivesTypes = reflections.getTypesAnnotatedWith(Directive.class); - directivesTypes.addAll(reflections.getTypesAnnotatedWith(DataFetcherWrapper.class)); - - Set> restrict = reflections.getTypesAnnotatedWith(Restrict.class); - Set> restricts = reflections.getTypesAnnotatedWith(Restricts.class); - List> globalRestricts = new ArrayList<>(); - - for (var r : restrict) { - Restrict annotation = EntityUtil.getAnnotation(r, Restrict.class); - var factoryClass = annotation.value(); - var factory = factoryClass.getConstructor().newInstance(); - if (!factory.extractType().isAssignableFrom(r)) { - throw new RuntimeException( - "Restrict annotation does match class applied to targets" + factory.extractType() + " but was on class " + r - ); - } - globalRestricts.add(factory); - } - - for (var r : restricts) { - Restricts annotations = EntityUtil.getAnnotation(r, Restricts.class); - for (Restrict annotation : annotations.value()) { - var factoryClass = annotation.value(); - var factory = factoryClass.getConstructor().newInstance(); - - if (!factory.extractType().isAssignableFrom(r)) { - throw new RuntimeException( - "Restrict annotation does match class applied to targets" + factory.extractType() + " but was on class " + r - ); - } - globalRestricts.add(factory); - } - } - - DirectivesSchema directivesSchema = DirectivesSchema.build(globalRestricts, directivesTypes); // Entry point for directives + DirectivesSchema directivesSchema = getDirectivesSchema(reflections); Set> types = reflections.getTypesAnnotatedWith(Entity.class); @@ -178,15 +160,69 @@ public GraphQLSchema.Builder build() { endPoints.addAll(queries); types.removeIf(t -> t.getDeclaredAnnotation(Entity.class) == null); - types.removeIf(t -> t.isAnonymousClass()); + types.removeIf(Class::isAnonymousClass); return new SchemaBuilder(dataFetcherRunner, scalars, directivesSchema, authorizer) .processTypes(types) - .process(endPoints) + .process(endPoints, shouldValidate) .build(schemaConfiguration); } catch (ReflectiveOperationException e) { throw new RuntimeException(e); } } + + private static DirectivesSchema getDirectivesSchema(Reflections reflections) throws ReflectiveOperationException { + Set> directivesTypes = reflections.getTypesAnnotatedWith(Directive.class); + directivesTypes.addAll(reflections.getTypesAnnotatedWith(DataFetcherWrapper.class)); + + List> globalRestricts = getGlobalRestricts(reflections); + + return DirectivesSchema.build(globalRestricts, directivesTypes, getJakartaAnnotations()); + } + + private static Set> getJakartaAnnotations() { + Reflections reflections = new Reflections("jakarta.validation.constraints", SubTypes.filterResultsBy(c -> true)); + return reflections + .getSubTypesOf(Object.class) + .stream() + .filter(a -> a.isAnnotationPresent(Constraint.class)) + .collect(Collectors.toSet()); + } + + private static List> getGlobalRestricts(Reflections reflections) + throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { + Set> restrict = reflections.getTypesAnnotatedWith(Restrict.class); + Set> restricts = reflections.getTypesAnnotatedWith(Restricts.class); + List> globalRestricts = new ArrayList<>(); + + for (var r : restrict) { + Restrict annotation = EntityUtil.getAnnotation(r, Restrict.class); + var factoryClass = annotation.value(); + var factory = factoryClass.getConstructor().newInstance(); + if (!factory.extractType().isAssignableFrom(r)) { + throw new RuntimeException( + "Restrict annotation does match class applied to targets" + factory.extractType() + " but was on class " + r + ); + } + globalRestricts.add(factory); + } + + for (var r : restricts) { + Restricts annotations = EntityUtil.getAnnotation(r, Restricts.class); + for (Restrict annotation : annotations.value()) { + var factoryClass = annotation.value(); + var factory = factoryClass.getConstructor().newInstance(); + + if (!factory.extractType().isAssignableFrom(r)) { + throw new RuntimeException( + "Restrict annotation does match class applied to targets" + factory.extractType() + " but was on class " + r + ); + } + globalRestricts.add(factory); + } + } + + return globalRestricts; + } } } diff --git a/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/DirectiveTest.java b/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/DirectiveTest.java index e4f28bee..a23d8c90 100644 --- a/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/DirectiveTest.java +++ b/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/DirectiveTest.java @@ -19,10 +19,12 @@ import graphql.introspection.IntrospectionWithDirectivesSupport; import graphql.schema.FieldCoordinates; import graphql.schema.GraphQLSchema; + import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; + import org.junit.jupiter.api.Test; public class DirectiveTest { @@ -87,7 +89,7 @@ public void testDirectiveArgumentDefinition() { List> dir = (List>) ((Map) response.get("__schema")).get("directives"); LinkedHashMap input = dir.stream().filter(map -> map.get("name").equals("Input")).collect(Collectors.toList()).get(0); - assertEquals(10, dir.size()); + assertEquals(32, dir.size()); assertEquals("ARGUMENT_DEFINITION", ((List) input.get("locations")).get(0)); assertEquals(1, ((List) input.get("args")).size()); // getNickname(nickName: String! @Input(value : "TT")): String! diff --git a/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/JakartaValidationDirectiveTest.java b/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/JakartaValidationDirectiveTest.java new file mode 100644 index 00000000..8406e685 --- /dev/null +++ b/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/JakartaValidationDirectiveTest.java @@ -0,0 +1,122 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.phocassoftware.graphql.builder; + +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.introspection.IntrospectionWithDirectivesSupport; +import graphql.schema.FieldCoordinates; +import graphql.schema.GraphQLSchema; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class JakartaValidationDirectiveTest { + @Test + void testJakartaSizeAnnotationAddedAsDirective() { + GraphQL schema = GraphQL.newGraphQL(SchemaBuilder.build("com.phocassoftware.graphql.builder.type.directive")).build(); + var name = schema.getGraphQLSchema().getFieldDefinition(FieldCoordinates.coordinates(schema.getGraphQLSchema().getMutationType(), "setName")); + var directive = name.getArgument("name").getAppliedDirective("Size"); + var argument = directive.getArgument("min"); + var min = argument.getValue(); + assertEquals(3, min); + } + + @Test + void testJakartaSizeAnnotationAddedAsDirectiveOnARecord() { + GraphQL schema = GraphQL.newGraphQL(SchemaBuilder.build("com.phocassoftware.graphql.builder.type.directive.record")).build(); + var name = schema.getGraphQLSchema().getFieldDefinition(FieldCoordinates.coordinates(schema.getGraphQLSchema().getMutationType(), "setName")); + var directive = name.getArgument("name").getAppliedDirective("Size"); + var argument = directive.getArgument("min"); + var min = argument.getValue(); + assertEquals(3, min); + } + + @Test + void testJakartaSizeDirectiveArgumentDefinition() { + Map response = execute("query IntrospectionQuery { __schema { directives { name locations args { name } } } }", null, true).getData(); + List> dir = (List>) ((Map) response.get("__schema")).get("directives"); + LinkedHashMap constraint = dir.stream().filter(map -> map.get("name").equals("Size")).collect(Collectors.toList()).get(0); + + assertEquals(32, dir.size()); + assertEquals("ARGUMENT_DEFINITION", ((List) constraint.get("locations")).get(0)); + assertEquals("INPUT_FIELD_DEFINITION", ((List) constraint.get("locations")).get(1)); + assertEquals(5, ((List) constraint.get("args")).size()); + assertEquals("{name=payload}", ((List) constraint.get("args")).getFirst().toString()); + assertEquals("{name=min}", ((List) constraint.get("args")).get(1).toString()); + assertEquals("{name=max}", ((List) constraint.get("args")).get(2).toString()); + assertEquals("{name=message}", ((List) constraint.get("args")).get(3).toString()); + assertEquals("{name=groups}", ((List) constraint.get("args")).get(4).toString()); + } + + @Test + void testJakartaSizeValidationIsApplied() { + var name = "Roger"; + Map response = execute("mutation setName($name: String!){setName(name: $name)} ", Map.of("name", name), true).getData(); + var result = response.get("setName"); + + assertEquals(name, result); + + name = "Po"; + var error = execute("mutation setName($name: String!){setName(name: $name)} ", Map.of("name", name), true).getErrors().getFirst(); + + assertEquals("size must be between 3 and 2147483647", error.getMessage()); + } + + @Test + void testJakartaSizeValidationIsNotAppliedWhenFlagIsFalse() { + var name = "Po"; + Map response = execute("mutation setName($name: String!){setName(name: $name)} ", Map.of("name", name), false).getData(); + var result = response.get("setName"); + + assertEquals(name, result); + } + + @Test + void testJakartaMinAndMaxValidationIsApplied() { + var age = 4; + Map response = execute("mutation setAge($age: Int!){setAge(age: $age)} ", Map.of("age", age), true).getData(); + var result = response.get("setAge"); + + assertEquals(age, result); + + age = 2; + var error = execute("mutation setAge($age: Int!){setAge(age: $age)} ", Map.of("age", age), true).getErrors().getFirst(); + + assertEquals("must be greater than or equal to 3", error.getMessage()); + + age = 100; + error = execute("mutation setAge($age: Int!){setAge(age: $age)} ", Map.of("age", age), true).getErrors().getFirst(); + + assertEquals("must be less than or equal to 99", error.getMessage()); + } + + private ExecutionResult execute(String query, Map variables, boolean validate) { + var builder = SchemaBuilder.builder().classpath("com.phocassoftware.graphql.builder.type.directive"); + if (validate) builder = builder.validate(); + GraphQLSchema preSchema = builder.build().build(); + GraphQL schema = GraphQL.newGraphQL(new IntrospectionWithDirectivesSupport().apply(preSchema)).build(); + + var input = ExecutionInput.newExecutionInput(); + input.query(query); + if (variables != null) { + input.variables(variables); + } + return schema.execute(input); + } +} diff --git a/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/type/directive/Cat.java b/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/type/directive/Cat.java index bd8c30f0..ed0b7295 100644 --- a/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/type/directive/Cat.java +++ b/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/type/directive/Cat.java @@ -12,7 +12,11 @@ package com.phocassoftware.graphql.builder.type.directive; import com.phocassoftware.graphql.builder.annotations.Entity; +import com.phocassoftware.graphql.builder.annotations.Mutation; import com.phocassoftware.graphql.builder.annotations.Query; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; @Entity public class Cat { @@ -41,6 +45,16 @@ public static Cat getUpper() { return new Cat(); } + @Mutation + public static String setName(@Size(min = 3) String name) { + return name; + } + + @Mutation + public static int setAge(@Min(value = 3) @Max(value = 99) int age) { + return age; + } + @Query @Admin("tabby") public static String allowed(String name) { diff --git a/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/type/directive/record/CatRecord.java b/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/type/directive/record/CatRecord.java new file mode 100644 index 00000000..1983409d --- /dev/null +++ b/graphql-builder/src/test/java/com/phocassoftware/graphql/builder/type/directive/record/CatRecord.java @@ -0,0 +1,37 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package com.phocassoftware.graphql.builder.type.directive.record; + +import com.phocassoftware.graphql.builder.annotations.Entity; +import com.phocassoftware.graphql.builder.annotations.Mutation; +import com.phocassoftware.graphql.builder.annotations.Query; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +@Entity +public record CatRecord(int age, String name) { + @Mutation + public static String setName(@Size(min = 3) String name) { + return name; + } + + @Mutation + public static int setAge(@Min(value = 3) @Max(value = 99) int age) { + return age; + } + + @Query + public static String getName(String name) { + return name; + } +} diff --git a/graphql-database-manager-core/src/main/java/com/phocassoftware/graphql/database/manager/Database.java b/graphql-database-manager-core/src/main/java/com/phocassoftware/graphql/database/manager/Database.java index e8766fb9..fe3e9fcc 100644 --- a/graphql-database-manager-core/src/main/java/com/phocassoftware/graphql/database/manager/Database.java +++ b/graphql-database-manager-core/src/main/java/com/phocassoftware/graphql/database/manager/Database.java @@ -17,6 +17,7 @@ import com.phocassoftware.graphql.database.manager.util.BackupItem; import com.phocassoftware.graphql.database.manager.util.HistoryBackupItem; import com.phocassoftware.graphql.database.manager.util.TableCoreUtil; + import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -32,6 +33,7 @@ import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; + import org.dataloader.DataLoader; import org.dataloader.DataLoaderFactory; import org.dataloader.DataLoaderOptions; @@ -254,8 +256,7 @@ public CompletableFuture destroyOrganisation(final String organisationI * * @param database entity type to update * @param entity revision must match database or request will fail - * @return updated entity with the revision incremented by one - * CompletableFuture will fail with a RevisionMismatchException + * @return updated entity with the revision incremented by one CompletableFuture will fail with a RevisionMismatchException */ public CompletableFuture put(T entity) { return put(entity, true); @@ -265,8 +266,7 @@ public CompletableFuture put(T entity) { * @param database entity type to update * @param entity revision must match database or request will fail * @param check Will only pass if the entity revision matches what is currently in the database - * @return updated entity with the revision incremented by one - * CompletableFuture will fail with a RevisionMismatchException + * @return updated entity with the revision incremented by one CompletableFuture will fail with a RevisionMismatchException */ public CompletableFuture put(T entity, boolean check) { return putAllow