SchemaMappingInspector support for unions and interfaces

See gh-924
This commit is contained in:
rstoyanchev
2024-03-25 13:43:39 +00:00
parent 4a14dc2697
commit 77b1f1a923
8 changed files with 840 additions and 187 deletions

View File

@@ -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<Class<?>, String> getMappings() {
return this.mappings;
}
@Override
@Nullable

View File

@@ -76,6 +76,8 @@ final class DefaultSchemaResourceGraphQlSourceBuilder
@Nullable
private Consumer<SchemaReport> schemaReportConsumer;
private Consumer<SchemaMappingInspector.Initializer> inspectorInitializerConsumer = initializer -> {};
@Nullable
private Consumer<GraphQLSchema> schemaReportRunner;
@@ -110,6 +112,15 @@ final class DefaultSchemaResourceGraphQlSourceBuilder
return this;
}
@Override
public GraphQlSource.SchemaResourceBuilder inspectSchemaMappings(
Consumer<SchemaMappingInspector.Initializer> initializerConsumer, Consumer<SchemaReport> reportConsumer) {
this.inspectorInitializerConsumer = initializerConsumer.andThen(initializerConsumer);
this.schemaReportConsumer = reportConsumer;
return this;
}
@Override
public DefaultSchemaResourceGraphQlSourceBuilder schemaFactory(
BiFunction<TypeDefinitionRegistry, RuntimeWiring, GraphQLSchema> 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);
};
}

View File

@@ -210,6 +210,20 @@ public interface GraphQlSource {
*/
SchemaResourceBuilder inspectSchemaMappings(Consumer<SchemaReport> 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<SchemaMappingInspector.Initializer> initializerConsumer,
Consumer<SchemaReport> 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<TypeDefinitionRegistry, RuntimeWiring, GraphQLSchema> schemaFactory);
SchemaResourceBuilder schemaFactory(
BiFunction<TypeDefinitionRegistry, RuntimeWiring, GraphQLSchema> schemaFactory);
}

View File

@@ -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<String, Map<String, DataFetcher>> dataFetchers;
private final InterfaceUnionLookup interfaceUnionLookup;
private final Set<String> inspectedTypes = new HashSet<>();
private final ReportBuilder reportBuilder = new ReportBuilder();
@@ -99,11 +105,15 @@ public class SchemaMappingInspector {
private SchemaReport report;
private SchemaMappingInspector(GraphQLSchema schema, Map<String, Map<String, DataFetcher>> dataFetchers) {
private SchemaMappingInspector(
GraphQLSchema schema, Map<String, Map<String, DataFetcher>> dataFetchers,
List<ClassResolver> classResolvers, Function<GraphQLObjectType, String> 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<GraphQLType, ResolvableType> 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<GraphQLType, List<ResolvableType>> 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<String, Map<String, DataFetcher>> dataFetchers) {
return new SchemaMappingInspector(schema, dataFetchers).getOrCreateReport();
public static SchemaReport inspect(GraphQLSchema schema, Map<String, Map<String, DataFetcher>> 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.
* <p>The default, {@link GraphQLObjectType#getName()} is used
* @param function the function to use
* @return the same initializer instance
*/
Initializer classNameFunction(Function<GraphQLObjectType, String> 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<String, Map<String, DataFetcher>> 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<Class<?>> 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<GraphQLObjectType, String> classNameFunction = GraphQLObjectType::getName;
private final List<ClassResolver> classResolvers = new ArrayList<>();
public DefaultInitializer() {
this.classResolvers.add((objectType, interfaceOrUnionType) -> Collections.emptyList());
}
@Override
public Initializer classNameFunction(Function<GraphQLObjectType, String> 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<String, Map<String, DataFetcher>> fetchers) {
return new SchemaMappingInspector(
schema, fetchers, this.classResolvers, this.classNameFunction).getOrCreateReport();
}
}
/**
* ClassResolver with explicit mappings.
*/
private static class MappingClassResolver implements ClassResolver {
private final MultiValueMap<String, Class<?>> map = new LinkedMultiValueMap<>();
public void addMapping(String typeName, Class<?> clazz) {
this.map.add(typeName, clazz);
}
@Override
public List<Class<?>> 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<GraphQLObjectType, String> classNameFunction;
private final MultiValueMap<String, String> classPrefixes = new LinkedMultiValueMap<>();
public ReflectionClassResolver(Function<GraphQLObjectType, String> classNameFunction) {
this.classNameFunction = classNameFunction;
}
public void addClassPrefix(String interfaceOrUnionTypeName, String classPrefix) {
this.classPrefixes.add(interfaceOrUnionTypeName, classPrefix);
}
@Override
public List<Class<?>> 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<String> PACKAGE_PREDICATE = name -> !name.startsWith("java.");
private static final LinkedMultiValueMap<GraphQLType, ResolvableType> EMPTY_MULTI_VALUE_MAP = new LinkedMultiValueMap<>(0);
/** Interface or union type name to implementing or member GraphQL-Java types pairs. */
private final Map<String, MultiValueMap<GraphQLType, ResolvableType>> mappings = new LinkedHashMap<>();
InterfaceUnionLookup(
GraphQLSchema schema, Map<String, Map<String, DataFetcher>> dataFetchers,
List<ClassResolver> classResolvers, Function<GraphQLObjectType, String> 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<String, Map<String, DataFetcher>> dataFetchers,
Function<GraphQLObjectType, String> classNameFunction, List<ClassResolver> classResolvers) {
ReflectionClassResolver resolver = new ReflectionClassResolver(classNameFunction);
classResolvers.add(resolver);
for (Map.Entry<String, Map<String, DataFetcher>> typeEntry : dataFetchers.entrySet()) {
String typeName = typeEntry.getKey();
GraphQLType parentType = schema.getType(typeName);
if (parentType == null) {
continue; // Unmapped registration
}
for (Map.Entry<String, DataFetcher> 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<ClassResolver> classResolvers) {
List<ResolvableType> resolvableTypes = new ArrayList<>();
for (ClassResolver resolver : classResolvers) {
List<Class<?>> 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<GraphQLType, ResolvableType> 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<GraphQLType, ResolvableType> resolveUnion(GraphQLUnionType unionType) {
return this.mappings.getOrDefault(unionType.getName(), EMPTY_MULTI_VALUE_MAP);
}
}

View File

@@ -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<Vehicle> 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<Vehicle> 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<Vehicle> vehicles() {
throw new UnsupportedOperationException();
}
}
}
}

View File

@@ -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<SchemaMappingInspector.Initializer> 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<SchemaInspectionReportAssert, SchemaReport> {
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<String> expected = Arrays.asList(fieldNames);
List<String> 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<FieldCoordinates> 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<String> expected = Arrays.asList(arguments);
List<String> 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<String> expected = Arrays.asList(fieldCoordinates);
List<String> 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;
}
}
}

View File

@@ -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<SchemaInspectionReportAssert, SchemaReport> {
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<String> expected = Arrays.asList(fieldNames);
List<String> 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<FieldCoordinates> 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<String> expected = Arrays.asList(arguments);
List<String> 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<String> expected = Arrays.asList(fieldCoordinates);
List<String> 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;
}
}
}

View File

@@ -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<ResultItem> 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<ResultItem> 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<ResultItem> search() {
throw new UnsupportedOperationException();
}
}
}
}