From 77b1f1a9236a264e6b04a4f905fd42e3df3b45de Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Mon, 25 Mar 2024 13:43:39 +0000 Subject: [PATCH] SchemaMappingInspector support for unions and interfaces See gh-924 --- .../execution/ClassNameTypeResolver.java | 7 + ...ultSchemaResourceGraphQlSourceBuilder.java | 18 +- .../graphql/execution/GraphQlSource.java | 17 +- .../execution/SchemaMappingInspector.java | 339 +++++++++++++++++- .../SchemaMappingInspectorInterfaceTests.java | 156 ++++++++ .../SchemaMappingInspectorTestSupport.java | 176 +++++++++ .../SchemaMappingInspectorTests.java | 168 +-------- .../SchemaMappingInspectorUnionTests.java | 146 ++++++++ 8 files changed, 840 insertions(+), 187 deletions(-) create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorInterfaceTests.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTestSupport.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorUnionTests.java diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/ClassNameTypeResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/ClassNameTypeResolver.java index 2427cf8a..afa7b4e5 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/execution/ClassNameTypeResolver.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/ClassNameTypeResolver.java @@ -66,6 +66,13 @@ public class ClassNameTypeResolver implements TypeResolver { this.mappings.put(clazz, graphQlTypeName); } + /** + * Return the map with configured {@link #addMapping(Class, String) explicit mappings}. + * @since 1.3 + */ + public Map, String> getMappings() { + return this.mappings; + } @Override @Nullable diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultSchemaResourceGraphQlSourceBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultSchemaResourceGraphQlSourceBuilder.java index 2c946379..3edf152a 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultSchemaResourceGraphQlSourceBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultSchemaResourceGraphQlSourceBuilder.java @@ -76,6 +76,8 @@ final class DefaultSchemaResourceGraphQlSourceBuilder @Nullable private Consumer schemaReportConsumer; + private Consumer inspectorInitializerConsumer = initializer -> {}; + @Nullable private Consumer schemaReportRunner; @@ -110,6 +112,15 @@ final class DefaultSchemaResourceGraphQlSourceBuilder return this; } + @Override + public GraphQlSource.SchemaResourceBuilder inspectSchemaMappings( + Consumer initializerConsumer, Consumer reportConsumer) { + + this.inspectorInitializerConsumer = initializerConsumer.andThen(initializerConsumer); + this.schemaReportConsumer = reportConsumer; + return this; + } + @Override public DefaultSchemaResourceGraphQlSourceBuilder schemaFactory( BiFunction schemaFactory) { @@ -152,7 +163,12 @@ final class DefaultSchemaResourceGraphQlSourceBuilder if (this.schemaReportConsumer != null) { this.schemaReportRunner = schema -> { - SchemaReport report = SchemaMappingInspector.inspect(schema, runtimeWiring); + SchemaMappingInspector.Initializer initializer = SchemaMappingInspector.initializer(); + if (this.typeResolver instanceof ClassNameTypeResolver cntr) { + initializer.classResolver(SchemaMappingInspector.ClassResolver.fromClassNameTypeResolver(cntr)); + } + this.inspectorInitializerConsumer.accept(initializer); + SchemaReport report = initializer.inspect(schema, runtimeWiring.getDataFetchers()); this.schemaReportConsumer.accept(report); }; } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java index 119893b0..b9d18b45 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java @@ -210,6 +210,20 @@ public interface GraphQlSource { */ SchemaResourceBuilder inspectSchemaMappings(Consumer reportConsumer); + /** + * Variant of {@link #inspectSchemaMappings(Consumer)} with the option to + * initialize the {@link SchemaMappingInspector}, e.g. in order to assist + * with finding Java representations of GraphQL union member types and + * interface implementation types. + * @param initializerConsumer callback to initialize the {@code SchemaMappingInspector} + * @param reportConsumer a hook to inspect the report + * @return the current builder + * @since 1.3.0 + */ + SchemaResourceBuilder inspectSchemaMappings( + Consumer initializerConsumer, + Consumer reportConsumer); + /** * Configure a function to create the {@link GraphQLSchema} from the * given {@link TypeDefinitionRegistry} and {@link RuntimeWiring}. @@ -219,7 +233,8 @@ public interface GraphQlSource { * @param schemaFactory the function to create the schema * @return the current builder */ - SchemaResourceBuilder schemaFactory(BiFunction schemaFactory); + SchemaResourceBuilder schemaFactory( + BiFunction schemaFactory); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaMappingInspector.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaMappingInspector.java index 9d5a435a..9f10ded1 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaMappingInspector.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaMappingInspector.java @@ -24,12 +24,15 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; import graphql.schema.DataFetcher; import graphql.schema.FieldCoordinates; import graphql.schema.GraphQLEnumType; import graphql.schema.GraphQLFieldDefinition; import graphql.schema.GraphQLFieldsContainer; +import graphql.schema.GraphQLInterfaceType; import graphql.schema.GraphQLList; import graphql.schema.GraphQLNamedOutputType; import graphql.schema.GraphQLNamedType; @@ -38,6 +41,7 @@ import graphql.schema.GraphQLObjectType; import graphql.schema.GraphQLScalarType; import graphql.schema.GraphQLSchema; import graphql.schema.GraphQLType; +import graphql.schema.GraphQLUnionType; import graphql.schema.idl.RuntimeWiring; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -91,6 +95,8 @@ public class SchemaMappingInspector { private final Map> dataFetchers; + private final InterfaceUnionLookup interfaceUnionLookup; + private final Set inspectedTypes = new HashSet<>(); private final ReportBuilder reportBuilder = new ReportBuilder(); @@ -99,11 +105,15 @@ public class SchemaMappingInspector { private SchemaReport report; - private SchemaMappingInspector(GraphQLSchema schema, Map> dataFetchers) { + private SchemaMappingInspector( + GraphQLSchema schema, Map> dataFetchers, + List classResolvers, Function classNameFunction) { + Assert.notNull(schema, "GraphQLSchema is required"); Assert.notNull(dataFetchers, "DataFetcher map is required"); this.schema = schema; this.dataFetchers = dataFetchers; + this.interfaceUnionLookup = new InterfaceUnionLookup(schema, dataFetchers, classResolvers, classNameFunction); } @@ -180,23 +190,40 @@ public class SchemaMappingInspector { return; } - // Can we inspect GraphQL type? - if (!(typePair.outputType() instanceof GraphQLFieldsContainer fieldContainer)) { - if (isNotScalarOrEnumType(typePair.outputType())) { - FieldCoordinates coordinates = FieldCoordinates.coordinates(parent.getName(), field.getName()); - addSkippedType(typePair.outputType(), coordinates, "Unsupported schema type"); + MultiValueMap typePairs = new LinkedMultiValueMap<>(); + if (typePair.outputType() instanceof GraphQLUnionType unionType) { + typePairs.putAll(this.interfaceUnionLookup.resolveUnion(unionType)); + } + else if (typePair.outputType() instanceof GraphQLInterfaceType interfaceType) { + typePairs.putAll(this.interfaceUnionLookup.resolveInterface(interfaceType)); + } + + if (typePairs.isEmpty()) { + typePairs.add(typePair.outputType(), typePair.resolvableType()); + } + + for (Map.Entry> entry : typePairs.entrySet()) { + for (ResolvableType resolvableType : entry.getValue()) { + + // Can we inspect GraphQL type? + if (!(entry.getKey() instanceof GraphQLFieldsContainer fieldContainer)) { + if (isNotScalarOrEnumType(entry.getKey())) { + FieldCoordinates coordinates = FieldCoordinates.coordinates(parent.getName(), field.getName()); + addSkippedType(entry.getKey(), coordinates, "Unsupported schema type"); + } + continue; + } + + // Can we inspect Java type? + if (resolvableType.resolve(Object.class) == Object.class) { + FieldCoordinates coordinates = FieldCoordinates.coordinates(parent.getName(), field.getName()); + addSkippedType(entry.getKey(), coordinates, "No Java type information"); + continue; + } + + checkFieldsContainer(fieldContainer, resolvableType); } - return; } - - // Can we inspect Java type? - if (typePair.resolvableType().resolve(Object.class) == Object.class) { - FieldCoordinates coordinates = FieldCoordinates.coordinates(parent.getName(), field.getName()); - addSkippedType(typePair.outputType(), coordinates, "No Java type information"); - return; - } - - checkFieldsContainer(fieldContainer, typePair.resolvableType()); } private void checkFieldArguments(GraphQLFieldDefinition field, SelfDescribingDataFetcher dataFetcher) { @@ -269,8 +296,284 @@ public class SchemaMappingInspector { * {@code DataFetcher} registrations. * @since 1.2.5 */ - public static SchemaReport inspect(GraphQLSchema schema, Map> dataFetchers) { - return new SchemaMappingInspector(schema, dataFetchers).getOrCreateReport(); + public static SchemaReport inspect(GraphQLSchema schema, Map> fetchers) { + return initializer().inspect(schema, fetchers); + } + + /** + * Return an initializer to configure the {@link SchemaMappingInspector} + * and perform the inspection. + * @since 1.3 + */ + public static Initializer initializer() { + return new DefaultInitializer(); + } + + + /** + * Helps to configure {@link SchemaMappingInspector}. + * @since 1.3 + */ + public interface Initializer { + + /** + * Provide a function to derive the simple class name that corresponds to a + * GraphQL union member type, or a GraphQL interface implementation type. + * This is then used to find a Java class in the same package as that of + * the return type of the controller method for the interface or union. + *

The default, {@link GraphQLObjectType#getName()} is used + * @param function the function to use + * @return the same initializer instance + */ + Initializer classNameFunction(Function function); + + /** + * Add a custom {@link ClassResolver} to use to find the Java class for a + * GraphQL union member type, or a GraphQL interface implementation type. + * @param resolver the resolver to add + * @return the same initializer instance + */ + Initializer classResolver(ClassResolver resolver); + + /** + * Perform the inspection and return a report. + * @param schema the schema to inspect + * @param fetchers the registered data fetchers + * @return the produced report + */ + SchemaReport inspect(GraphQLSchema schema, Map> fetchers); + + } + + + /** + * Strategy to resolve the Java class(es) for a {@code GraphQLObjectType}, effectively + * the reverse of {@link graphql.schema.TypeResolver}, for schema inspection purposes. + */ + public interface ClassResolver { + + /** + * + * @param objectType the {@code GraphQLObjectType} to resolve + * @param interfaceOrUnionType either an interface the object implements, + * or a union the object is a member of + */ + List> resolveClass(GraphQLObjectType objectType, GraphQLNamedOutputType interfaceOrUnionType); + + + /** + * Create a resolver by re-using the explicit, reverse mappings of + * {@link ClassNameTypeResolver}. + */ + static ClassResolver fromClassNameTypeResolver(ClassNameTypeResolver resolver) { + MappingClassResolver mappingResolver = new MappingClassResolver(); + resolver.getMappings().forEach((key, value) -> mappingResolver.addMapping(value, key)); + return mappingResolver; + } + + } + + + /** + * Default implementation of {@link Initializer}. + */ + private static class DefaultInitializer implements Initializer { + + private Function classNameFunction = GraphQLObjectType::getName; + + private final List classResolvers = new ArrayList<>(); + + public DefaultInitializer() { + this.classResolvers.add((objectType, interfaceOrUnionType) -> Collections.emptyList()); + } + + @Override + public Initializer classNameFunction(Function function) { + this.classNameFunction = function; + return this; + } + + @Override + public Initializer classResolver(ClassResolver resolver) { + this.classResolvers.add(resolver); + return this; + } + + @Override + public SchemaReport inspect(GraphQLSchema schema, Map> fetchers) { + return new SchemaMappingInspector( + schema, fetchers, this.classResolvers, this.classNameFunction).getOrCreateReport(); + } + } + + + /** + * ClassResolver with explicit mappings. + */ + private static class MappingClassResolver implements ClassResolver { + + private final MultiValueMap> map = new LinkedMultiValueMap<>(); + + public void addMapping(String typeName, Class clazz) { + this.map.add(typeName, clazz); + } + + @Override + public List> resolveClass(GraphQLObjectType objectType, GraphQLNamedOutputType interfaceOrUnionType) { + return this.map.getOrDefault(objectType.getName(), Collections.emptyList()); + } + } + + + /** + * ClassResolver that uses a function to derive the simple class name from + * the GraphQL object type, and then prepends a prefixes such as a package + * name and/or an outer class name. + */ + private static class ReflectionClassResolver implements ClassResolver { + + private final Function classNameFunction; + + private final MultiValueMap classPrefixes = new LinkedMultiValueMap<>(); + + public ReflectionClassResolver(Function classNameFunction) { + this.classNameFunction = classNameFunction; + } + + public void addClassPrefix(String interfaceOrUnionTypeName, String classPrefix) { + this.classPrefixes.add(interfaceOrUnionTypeName, classPrefix); + } + + @Override + public List> resolveClass(GraphQLObjectType objectType, GraphQLNamedOutputType interfaceOrUnion) { + String className = this.classNameFunction.apply(objectType); + for (String prefix : classPrefixes.getOrDefault(interfaceOrUnion.getName(), Collections.emptyList())) { + try { + Class clazz = Class.forName(prefix + className); + return Collections.singletonList(clazz); + } + catch (ClassNotFoundException ex) { + // Ignore + } + } + return Collections.emptyList(); + } + } + + + /** + * Provides methods to look up GraphQL Object and Java type pairs associated + * with GraphQL interface and union types. + */ + private static class InterfaceUnionLookup { + + private final static Predicate PACKAGE_PREDICATE = name -> !name.startsWith("java."); + + private static final LinkedMultiValueMap EMPTY_MULTI_VALUE_MAP = new LinkedMultiValueMap<>(0); + + /** Interface or union type name to implementing or member GraphQL-Java types pairs. */ + private final Map> mappings = new LinkedHashMap<>(); + + InterfaceUnionLookup( + GraphQLSchema schema, Map> dataFetchers, + List classResolvers, Function classNameFunction) { + + addReflectionClassResolver(schema, dataFetchers, classNameFunction, classResolvers); + + for (GraphQLNamedType type : schema.getAllTypesAsList()) { + if (type instanceof GraphQLUnionType union) { + for (GraphQLNamedOutputType member : union.getTypes()) { + addTypeMapping(union, (GraphQLObjectType) member, classResolvers); + } + } + else if (type instanceof GraphQLObjectType objectType) { + for (GraphQLNamedOutputType interfaceType : objectType.getInterfaces()) { + addTypeMapping(interfaceType, objectType, classResolvers); + } + } + } + } + + private static void addReflectionClassResolver( + GraphQLSchema schema, Map> dataFetchers, + Function classNameFunction, List classResolvers) { + + ReflectionClassResolver resolver = new ReflectionClassResolver(classNameFunction); + classResolvers.add(resolver); + + for (Map.Entry> typeEntry : dataFetchers.entrySet()) { + String typeName = typeEntry.getKey(); + GraphQLType parentType = schema.getType(typeName); + if (parentType == null) { + continue; // Unmapped registration + } + for (Map.Entry fieldEntry : typeEntry.getValue().entrySet()) { + FieldCoordinates coordinates = FieldCoordinates.coordinates(typeName, fieldEntry.getKey()); + GraphQLFieldDefinition field = schema.getFieldDefinition(coordinates); + if (field == null) { + continue; // Unmapped registration + } + TypePair pair = TypePair.resolveTypePair(parentType, field, fieldEntry.getValue(), schema); + GraphQLType outputType = pair.outputType(); + if (outputType instanceof GraphQLUnionType || outputType instanceof GraphQLInterfaceType) { + String outputTypeName = ((GraphQLNamedOutputType) outputType).getName(); + Class clazz = pair.resolvableType().resolve(Object.class); + if (PACKAGE_PREDICATE.test(clazz.getPackageName())) { + int index = clazz.getName().indexOf(clazz.getSimpleName()); + resolver.addClassPrefix(outputTypeName, clazz.getName().substring(0, index)); + } + } + } + } + } + + private void addTypeMapping( + GraphQLNamedOutputType interfaceOrUnionType, GraphQLObjectType objectType, + List classResolvers) { + + List resolvableTypes = new ArrayList<>(); + + for (ClassResolver resolver : classResolvers) { + List> classes = resolver.resolveClass(objectType, interfaceOrUnionType); + if (!classes.isEmpty()) { + for (Class clazz : classes) { + ResolvableType resolvableType = ResolvableType.forClass(clazz); + resolvableTypes.add(resolvableType); + } + break; + } + } + + if (resolvableTypes.isEmpty()) { + resolvableTypes.add(ResolvableType.NONE); + } + + for (ResolvableType resolvableType : resolvableTypes) { + String name = interfaceOrUnionType.getName(); + this.mappings.computeIfAbsent(name, n -> new LinkedMultiValueMap<>()).add(objectType, resolvableType); + } + } + + /** + * Resolve the implementation GraphQL and Java type pairs for the interface. + * @param interfaceType the interface type to resolve type pairs for + * @return {@code MultiValueMap} with one or more pairs, possibly one + * pair with {@link ResolvableType#NONE}. + */ + public MultiValueMap resolveInterface(GraphQLInterfaceType interfaceType) { + return this.mappings.getOrDefault(interfaceType.getName(), EMPTY_MULTI_VALUE_MAP); + } + + /** + * Resolve the member GraphQL and Java type pairs for the union. + * @param unionType the union type to resolve type pairs for + * @return {@code MultiValueMap} with one or more pairs, possibly one + * pair with {@link ResolvableType#NONE}. + */ + public MultiValueMap resolveUnion(GraphQLUnionType unionType) { + return this.mappings.getOrDefault(unionType.getName(), EMPTY_MULTI_VALUE_MAP); + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorInterfaceTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorInterfaceTests.java new file mode 100644 index 00000000..952ce3da --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorInterfaceTests.java @@ -0,0 +1,156 @@ +/* + * Copyright 2020-2024 the original author or authors. + * + * 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 + * + * https://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 org.springframework.graphql.execution; + +import java.util.List; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.execution.SchemaMappingInspector.ClassResolver; +import org.springframework.stereotype.Controller; + +/** + * Tests for {@link SchemaMappingInspector} with interface types. + * + * @author Rossen Stoyanchev + */ +public class SchemaMappingInspectorInterfaceTests extends SchemaMappingInspectorTestSupport { + + private final static String schema = """ + type Query { + vehicles: [Vehicle!]! + } + interface Vehicle { + name: String! + price: Int! + } + type Car implements Vehicle { + name: String! + price: Int! + engineType: String! + } + type Bike implements Vehicle { + name: String! + price: Int! + } + """; + + + @Nested + class InterfaceFieldsNotOnJavaInterface { + + @Test + void reportUnmappedFields() { + SchemaReport report = inspectSchema(schema, VehicleController.class); + assertThatReport(report) + .hasSkippedTypeCount(0) + .hasUnmappedFieldCount(3) + .containsUnmappedFields("Car", "price", "engineType") + .containsUnmappedFields("Bike", "price"); + } + + interface Vehicle { + String name(); + } + record Car(String name) implements Vehicle {} + record Bike(String name) implements Vehicle {} + + @Controller + static class VehicleController { + + @QueryMapping + List vehicles() { + throw new UnsupportedOperationException(); + } + } + } + + + @Nested + class GraphQlAndJavaTypeNameMismatch { + + @Test + void useClassNameFunction() { + + SchemaReport report = inspectSchema(schema, + initializer -> initializer.classNameFunction(type -> type.getName() + "Impl"), + VehicleController.class); + + assertThatReport(report) + .hasSkippedTypeCount(0) + .hasUnmappedFieldCount(3) + .containsUnmappedFields("Car", "price", "engineType") + .containsUnmappedFields("Bike", "price"); + } + + @Test + void useClassNameTypeResolver() { + + ClassNameTypeResolver typeResolver = new ClassNameTypeResolver(); + typeResolver.addMapping(CarImpl.class, "Car"); + + SchemaReport report = inspectSchema(schema, + initializer -> initializer.classResolver(ClassResolver.fromClassNameTypeResolver(typeResolver)), + VehicleController.class); + + assertThatReport(report) + .hasUnmappedFieldCount(2).containsUnmappedFields("Car", "price", "engineType") + .hasSkippedTypeCount(1).containsSkippedTypes("Bike"); + } + + interface Vehicle { + String name(); + } + record CarImpl(String name) implements Vehicle {} + record BikeImpl(String name) implements Vehicle {} + + @Controller + static class VehicleController { + + @QueryMapping + List vehicles() { + throw new UnsupportedOperationException(); + } + } + } + + + @Nested + class SkippedTypes { + + @Test + void reportSkippedImplementations() { + SchemaReport report = inspectSchema(schema, VehicleController.class); + assertThatReport(report).hasSkippedTypeCount(2).containsSkippedTypes("Car", "Bike"); + } + + interface Vehicle { + String name(); + } + + @Controller + static class VehicleController { + + @QueryMapping + List vehicles() { + throw new UnsupportedOperationException(); + } + } + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTestSupport.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTestSupport.java new file mode 100644 index 00000000..395aa3aa --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTestSupport.java @@ -0,0 +1,176 @@ +/* + * Copyright 2020-2024 the original author or authors. + * + * 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 + * + * https://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 org.springframework.graphql.execution; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; + +import graphql.schema.FieldCoordinates; +import graphql.schema.GraphQLNamedType; +import graphql.schema.GraphQLSchema; +import graphql.schema.idl.RuntimeWiring; +import graphql.schema.idl.SchemaGenerator; +import org.assertj.core.api.AbstractAssert; + +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer; + +/** + * Base class for {@link SchemaMappingInspector} tests. + * + * @author Rossen Stoyanchev + */ +public class SchemaMappingInspectorTestSupport { + + protected SchemaReport inspectSchema(String schemaContent, Class... controllers) { + return inspectSchema(schemaContent, initializer -> {}, controllers); + } + + protected SchemaReport inspectSchema( + String schemaContent, Consumer consumer, Class... controllers) { + + GraphQLSchema schema = SchemaGenerator.createdMockedSchema(schemaContent); + RuntimeWiring runtimeWiring = createRuntimeWiring(controllers); + SchemaMappingInspector.Initializer initializer = SchemaMappingInspector.initializer(); + consumer.accept(initializer); + return initializer.inspect(schema, runtimeWiring.getDataFetchers()); + } + + private RuntimeWiring createRuntimeWiring(Class... controllerTypes) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + for (Class controllerType : controllerTypes) { + context.registerBean(controllerType); + } + context.registerBean(BatchLoaderRegistry.class, () -> new DefaultBatchLoaderRegistry()); + context.refresh(); + + AnnotatedControllerConfigurer configurer = new AnnotatedControllerConfigurer(); + configurer.setApplicationContext(context); + configurer.afterPropertiesSet(); + + RuntimeWiring.Builder wiringBuilder = RuntimeWiring.newRuntimeWiring(); + configurer.configure(wiringBuilder); + return wiringBuilder.build(); + } + + protected static SchemaInspectionReportAssert assertThatReport(SchemaReport actual) { + return new SchemaInspectionReportAssert(actual); + } + + + protected static class SchemaInspectionReportAssert + extends AbstractAssert { + + public SchemaInspectionReportAssert(SchemaReport actual) { + super(actual, SchemaInspectionReportAssert.class); + } + + public void isEmpty() { + isNotNull(); + if (!this.actual.unmappedFields().isEmpty()) { + failWithMessage("Report contains missing fields: %s", this.actual.unmappedFields()); + } + if (!this.actual.unmappedRegistrations().isEmpty()) { + failWithMessage("Report contains missing DataFetcher registrations for %s", this.actual.unmappedRegistrations()); + } + if (!this.actual.skippedTypes().isEmpty()) { + failWithMessage("Report contains skipped types: %s", this.actual.skippedTypes()); + } + } + + public SchemaInspectionReportAssert hasUnmappedFieldCount(int expected) { + isNotNull(); + if (this.actual.unmappedFields().size() != expected) { + failWithMessage("Expected %s unmapped fields, found %s.", expected, this.actual.unmappedFields()); + } + return this; + } + + public SchemaInspectionReportAssert hasUnmappedDataFetcherCount(int expected) { + isNotNull(); + if (this.actual.unmappedRegistrations().size() != expected) { + failWithMessage("Expected %s unmapped fields, found %s.", expected, this.actual.unmappedFields()); + } + return this; + } + + public SchemaInspectionReportAssert hasUnmappedArgumentCount(int expected) { + isNotNull(); + if (this.actual.unmappedArguments().size() != expected) { + failWithMessage("Expected %s unmapped arguments, found %s.", expected, this.actual.unmappedArguments()); + } + return this; + } + + public SchemaInspectionReportAssert hasSkippedTypeCount(int expected) { + isNotNull(); + if (this.actual.skippedTypes().size() != expected) { + failWithMessage("Expected %s skipped types, found %s.", expected, this.actual.skippedTypes()); + } + return this; + } + + public SchemaInspectionReportAssert containsUnmappedFields(String typeName, String... fieldNames) { + isNotNull(); + List expected = Arrays.asList(fieldNames); + List actual = this.actual.unmappedFields().stream() + .filter(coordinates -> coordinates.getTypeName().equals(typeName)) + .map(FieldCoordinates::getFieldName) + .toList(); + if (!actual.containsAll(expected)) { + failWithMessage("Expected unmapped fields for %s: %s, found %s", typeName, expected, actual); + } + return this; + } + + public SchemaInspectionReportAssert containsUnmappedDataFetchersFor(String typeName, String... fieldNames) { + isNotNull(); + List expected = Arrays.stream(fieldNames) + .map(field -> FieldCoordinates.coordinates(typeName, field)) + .toList(); + if (!this.actual.unmappedRegistrations().keySet().containsAll(expected)) { + failWithMessage("Expected unmapped DataFetchers for %s, found %s", expected, this.actual.unmappedRegistrations()); + } + return this; + } + + public SchemaInspectionReportAssert containsUnmappedArguments(String... arguments) { + isNotNull(); + List expected = Arrays.asList(arguments); + List actual = this.actual.unmappedArguments().entrySet().stream() + .flatMap(entry -> entry.getValue().stream()) + .toList(); + if (!actual.containsAll(expected)) { + failWithMessage("Expected unmapped arguments: %s, found %s", expected, actual); + } + return this; + } + + public SchemaInspectionReportAssert containsSkippedTypes(String... fieldCoordinates) { + isNotNull(); + List expected = Arrays.asList(fieldCoordinates); + List actual = this.actual.skippedTypes().stream() + .map(skippedType -> ((GraphQLNamedType) skippedType.type()).getName()) + .toList(); + if (!actual.containsAll(expected)) { + failWithMessage("Expected skipped types: %s, found %s", expected, actual); + } + return this; + } + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTests.java index fd378b8b..21c88535 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTests.java @@ -16,25 +16,20 @@ package org.springframework.graphql.execution; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; -import graphql.schema.FieldCoordinates; -import graphql.schema.GraphQLNamedType; import graphql.schema.GraphQLSchema; import graphql.schema.idl.RuntimeWiring; import graphql.schema.idl.SchemaGenerator; -import org.assertj.core.api.AbstractAssert; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Window; import org.springframework.graphql.Author; @@ -45,7 +40,6 @@ import org.springframework.graphql.data.method.annotation.MutationMapping; import org.springframework.graphql.data.method.annotation.QueryMapping; import org.springframework.graphql.data.method.annotation.SchemaMapping; import org.springframework.graphql.data.method.annotation.SubscriptionMapping; -import org.springframework.graphql.data.method.annotation.support.AnnotatedControllerConfigurer; import org.springframework.stereotype.Controller; import static org.assertj.core.api.Assertions.assertThat; @@ -57,7 +51,7 @@ import static org.assertj.core.api.Assertions.assertThat; * @author Rossen Stoyanchev * @author Oliver Drotbohm */ -class SchemaMappingInspectorTests { +class SchemaMappingInspectorTests extends SchemaMappingInspectorTestSupport{ @Nested @@ -447,27 +441,6 @@ class SchemaMappingInspectorTests { assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Book", "missing"); } - @Test - void reportHasSkippedUnionType() { - String schema = """ - type Query { - fooBar: FooBar - } - - union FooBar = Foo | Bar - - type Foo { - name: String - } - - type Bar { - name: String - } - """; - SchemaReport report = inspectSchema(schema, UnionController.class); - assertThatReport(report).hasUnmappedFieldCount(0).hasSkippedTypeCount(1).containsSkippedTypes("FooBar"); - } - @Test void reportHasSkippedTypeForUnknownDataFetcherType() { String schemaContent = """ @@ -567,33 +540,6 @@ class SchemaMappingInspectorTests { } - private SchemaReport inspectSchema(String schemaContent, Class... controllers) { - GraphQLSchema schema = SchemaGenerator.createdMockedSchema(schemaContent); - RuntimeWiring runtimeWiring = createRuntimeWiring(controllers); - return SchemaMappingInspector.inspect(schema, runtimeWiring); - } - - private RuntimeWiring createRuntimeWiring(Class... controllerTypes) { - AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); - for (Class controllerType : controllerTypes) { - context.registerBean(controllerType); - } - context.registerBean(BatchLoaderRegistry.class, () -> new DefaultBatchLoaderRegistry()); - context.refresh(); - - AnnotatedControllerConfigurer configurer = new AnnotatedControllerConfigurer(); - configurer.setApplicationContext(context); - configurer.afterPropertiesSet(); - - RuntimeWiring.Builder wiringBuilder = RuntimeWiring.newRuntimeWiring(); - configurer.configure(wiringBuilder); - return wiringBuilder.build(); - } - - static SchemaInspectionReportAssert assertThatReport(SchemaReport actual) { - return new SchemaInspectionReportAssert(actual); - } - @Controller static class EmptyController { @@ -705,116 +651,4 @@ class SchemaMappingInspectorTests { } - - @Controller - static class UnionController { - - @QueryMapping - Object fooBar() { - return "Hello"; - } - } - - - private static class SchemaInspectionReportAssert - extends AbstractAssert { - - public SchemaInspectionReportAssert(SchemaReport actual) { - super(actual, SchemaInspectionReportAssert.class); - } - - public void isEmpty() { - isNotNull(); - if (!this.actual.unmappedFields().isEmpty()) { - failWithMessage("Report contains missing fields: %s", this.actual.unmappedFields()); - } - if (!this.actual.unmappedRegistrations().isEmpty()) { - failWithMessage("Report contains missing DataFetcher registrations for %s", this.actual.unmappedRegistrations()); - } - if (!this.actual.skippedTypes().isEmpty()) { - failWithMessage("Report contains skipped types: %s", this.actual.skippedTypes()); - } - } - - public SchemaInspectionReportAssert hasUnmappedFieldCount(int expected) { - isNotNull(); - if (this.actual.unmappedFields().size() != expected) { - failWithMessage("Expected %s unmapped fields, found %s.", expected, this.actual.unmappedFields()); - } - return this; - } - - public SchemaInspectionReportAssert hasUnmappedDataFetcherCount(int expected) { - isNotNull(); - if (this.actual.unmappedRegistrations().size() != expected) { - failWithMessage("Expected %s unmapped fields, found %s.", expected, this.actual.unmappedFields()); - } - return this; - } - - public SchemaInspectionReportAssert hasUnmappedArgumentCount(int expected) { - isNotNull(); - if (this.actual.unmappedArguments().size() != expected) { - failWithMessage("Expected %s unmapped arguments, found %s.", expected, this.actual.unmappedArguments()); - } - return this; - } - - public SchemaInspectionReportAssert hasSkippedTypeCount(int expected) { - isNotNull(); - if (this.actual.skippedTypes().size() != expected) { - failWithMessage("Expected %s skipped types, found %s.", expected, this.actual.skippedTypes()); - } - return this; - } - - public SchemaInspectionReportAssert containsUnmappedFields(String typeName, String... fieldNames) { - isNotNull(); - List expected = Arrays.asList(fieldNames); - List actual = this.actual.unmappedFields().stream() - .filter(coordinates -> coordinates.getTypeName().equals(typeName)) - .map(FieldCoordinates::getFieldName) - .toList(); - if (!actual.containsAll(expected)) { - failWithMessage("Expected unmapped fields for %s: %s, found %s", typeName, expected, actual); - } - return this; - } - - public SchemaInspectionReportAssert containsUnmappedDataFetchersFor(String typeName, String... fieldNames) { - isNotNull(); - List expected = Arrays.stream(fieldNames) - .map(field -> FieldCoordinates.coordinates(typeName, field)) - .toList(); - if (!this.actual.unmappedRegistrations().keySet().containsAll(expected)) { - failWithMessage("Expected unmapped DataFetchers for %s, found %s", expected, this.actual.unmappedRegistrations()); - } - return this; - } - - public SchemaInspectionReportAssert containsUnmappedArguments(String... arguments) { - isNotNull(); - List expected = Arrays.asList(arguments); - List actual = this.actual.unmappedArguments().entrySet().stream() - .flatMap(entry -> entry.getValue().stream()) - .toList(); - if (!actual.containsAll(expected)) { - failWithMessage("Expected unmapped arguments: %s, found %s", expected, actual); - } - return this; - } - - public SchemaInspectionReportAssert containsSkippedTypes(String... fieldCoordinates) { - isNotNull(); - List expected = Arrays.asList(fieldCoordinates); - List actual = this.actual.skippedTypes().stream() - .map(skippedType -> ((GraphQLNamedType) skippedType.type()).getName()) - .toList(); - if (!actual.containsAll(expected)) { - failWithMessage("Expected skipped types: %s, found %s", expected, actual); - } - return this; - } - } - } \ No newline at end of file diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorUnionTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorUnionTests.java new file mode 100644 index 00000000..ec14f060 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorUnionTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2020-2024 the original author or authors. + * + * 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 + * + * https://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 org.springframework.graphql.execution; + +import java.util.List; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import org.springframework.graphql.data.method.annotation.QueryMapping; +import org.springframework.graphql.execution.SchemaMappingInspector.ClassResolver; +import org.springframework.stereotype.Controller; + +/** + * Tests for {@link SchemaMappingInspector} with union types. + * + * @author Rossen Stoyanchev + */ +public class SchemaMappingInspectorUnionTests extends SchemaMappingInspectorTestSupport { + + private final static String schema = """ + type Query { + search: [SearchResult!]! + } + union SearchResult = Photo | Video + type Photo { + height: Int + width: Int + } + type Video { + title: String + } + """; + + + @Nested + class InterfaceFieldsNotOnJavaInterface { + + @Test + void reportUnmappedFields() { + SchemaReport report = inspectSchema(schema, SearchController.class); + assertThatReport(report) + .hasSkippedTypeCount(0) + .hasUnmappedFieldCount(3) + .containsUnmappedFields("Photo", "height", "width") + .containsUnmappedFields("Video", "title"); + } + + + sealed interface ResultItem permits Photo, Video {} + record Photo() implements ResultItem {} + record Video() implements ResultItem {} + + @Controller + static class SearchController { + + @QueryMapping + List search() { + throw new UnsupportedOperationException(); + } + } + } + + + @Nested + class GraphQlAndJavaTypeNameMismatch { + + @Test + void useClassNameFunction() { + + SchemaReport report = inspectSchema(schema, + initializer -> initializer.classNameFunction(type -> type.getName() + "Impl"), + SearchController.class); + + assertThatReport(report) + .hasSkippedTypeCount(0) + .hasUnmappedFieldCount(3) + .containsUnmappedFields("Photo", "height", "width") + .containsUnmappedFields("Video", "title"); + } + + @Test + void useClassNameTypeResolver() { + + ClassNameTypeResolver typeResolver = new ClassNameTypeResolver(); + typeResolver.addMapping(PhotoImpl.class, "Photo"); + + SchemaReport report = inspectSchema(schema, + initializer -> initializer.classResolver(ClassResolver.fromClassNameTypeResolver(typeResolver)), + SearchController.class); + + assertThatReport(report) + .hasUnmappedFieldCount(2).containsUnmappedFields("Photo", "height", "width") + .hasSkippedTypeCount(1).containsSkippedTypes("Video"); + } + + sealed interface ResultItem permits PhotoImpl, VideoImpl {} + record PhotoImpl() implements ResultItem {} + record VideoImpl() implements ResultItem {} + + @Controller + static class SearchController { + + @QueryMapping + List search() { + throw new UnsupportedOperationException(); + } + } + } + + + @Nested + class SkippedTypes { + + @Test + void reportSkippedImplementations() { + SchemaReport report = inspectSchema(schema, SearchController.class); + assertThatReport(report).hasSkippedTypeCount(2).containsSkippedTypes("Photo", "Video"); + } + + interface ResultItem {} + + @Controller + static class SearchController { + + @QueryMapping + List search() { + throw new UnsupportedOperationException(); + } + } + } + +}