diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/TypedDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/TypedDataFetcher.java
new file mode 100644
index 00000000..4ee14891
--- /dev/null
+++ b/spring-graphql/src/main/java/org/springframework/graphql/data/TypedDataFetcher.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020-2023 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.data;
+
+import graphql.schema.DataFetcher;
+import graphql.schema.DataFetchingEnvironment;
+
+import org.springframework.core.ResolvableType;
+
+/**
+ * Specialized {@link DataFetcher} that can provide {@link ResolvableType type information}
+ * about the {@link #get(DataFetchingEnvironment) instances returned}.
+ *
Such {@code DataFetchers} are often backed by actual Java methods with declared return types.
+ * Declared types might not reflect the concrete type of the returned instance.
+ * @author Brian Clozel
+ * @since 1.2.0
+ */
+public interface TypedDataFetcher extends DataFetcher {
+
+ /**
+ * The type declared by this {@link DataFetcher}.
+ * The concrete type of the returned instance might differ from the declared one.
+ * @return the declared type for the data to be fetched.
+ */
+ ResolvableType getDeclaredType();
+
+}
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java
index 774578fc..9c40589e 100644
--- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java
+++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java
@@ -54,6 +54,7 @@ import org.springframework.context.expression.BeanFactoryResolver;
import org.springframework.core.KotlinDetector;
import org.springframework.core.MethodIntrospector;
import org.springframework.core.MethodParameter;
+import org.springframework.core.ResolvableType;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.convert.ConversionService;
import org.springframework.expression.BeanResolver;
@@ -61,6 +62,7 @@ import org.springframework.format.FormatterRegistrar;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.format.support.FormattingConversionService;
import org.springframework.graphql.data.GraphQlArgumentBinder;
+import org.springframework.graphql.data.TypedDataFetcher;
import org.springframework.graphql.data.method.HandlerMethod;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite;
@@ -538,7 +540,7 @@ public class AnnotatedControllerConfigurer
/**
* {@link DataFetcher} that wrap and invokes a {@link HandlerMethod}.
*/
- static class SchemaMappingDataFetcher implements DataFetcher {
+ static class SchemaMappingDataFetcher implements TypedDataFetcher {
private final MappingInfo info;
@@ -629,6 +631,11 @@ public class AnnotatedControllerConfigurer
.flatMap(errors -> Mono.error(new SubscriptionPublisherException(errors, ex)));
}
+ @Override
+ public ResolvableType getDeclaredType() {
+ return ResolvableType.forMethodReturnType(this.info.getHandlerMethod().getMethod());
+ }
+
}
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java
index 6ad36fa1..711142e5 100644
--- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java
+++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QueryByExampleDataFetcher.java
@@ -41,6 +41,7 @@ import org.springframework.data.repository.query.ReactiveQueryByExampleExecutor;
import org.springframework.data.util.TypeInformation;
import org.springframework.graphql.data.GraphQlArgumentBinder;
import org.springframework.graphql.data.GraphQlRepository;
+import org.springframework.graphql.data.TypedDataFetcher;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
import org.springframework.util.Assert;
import org.springframework.validation.BindException;
@@ -441,7 +442,7 @@ public abstract class QueryByExampleDataFetcher {
}
- private static class SingleEntityFetcher extends QueryByExampleDataFetcher implements DataFetcher {
+ private static class SingleEntityFetcher extends QueryByExampleDataFetcher implements TypedDataFetcher {
private final QueryByExampleExecutor executor;
@@ -480,10 +481,14 @@ public abstract class QueryByExampleDataFetcher {
}).orElse(null);
}
+ @Override
+ public ResolvableType getDeclaredType() {
+ return ResolvableType.forClass(this.resultType);
+ }
}
- private static class ManyEntityFetcher extends QueryByExampleDataFetcher implements DataFetcher> {
+ private static class ManyEntityFetcher extends QueryByExampleDataFetcher implements TypedDataFetcher> {
private final QueryByExampleExecutor executor;
@@ -522,10 +527,15 @@ public abstract class QueryByExampleDataFetcher {
});
}
+ @Override
+ public ResolvableType getDeclaredType() {
+ return ResolvableType.forClassWithGenerics(Iterable.class, this.resultType);
+ }
+
}
- private static class ReactiveSingleEntityFetcher extends QueryByExampleDataFetcher implements DataFetcher> {
+ private static class ReactiveSingleEntityFetcher extends QueryByExampleDataFetcher implements TypedDataFetcher> {
private final ReactiveQueryByExampleExecutor executor;
@@ -564,10 +574,15 @@ public abstract class QueryByExampleDataFetcher {
});
}
+ @Override
+ public ResolvableType getDeclaredType() {
+ return ResolvableType.forClassWithGenerics(Mono.class, this.resultType);
+ }
+
}
- private static class ReactiveManyEntityFetcher extends QueryByExampleDataFetcher implements DataFetcher> {
+ private static class ReactiveManyEntityFetcher extends QueryByExampleDataFetcher implements TypedDataFetcher> {
private final ReactiveQueryByExampleExecutor executor;
@@ -606,6 +621,11 @@ public abstract class QueryByExampleDataFetcher {
});
}
+ @Override
+ public ResolvableType getDeclaredType() {
+ return ResolvableType.forClassWithGenerics(Flux.class, this.resultType);
+ }
+
}
}
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java
index edcb695f..4f2cdb25 100644
--- a/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java
+++ b/spring-graphql/src/main/java/org/springframework/graphql/data/query/QuerydslDataFetcher.java
@@ -34,6 +34,7 @@ import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
+import org.springframework.core.ResolvableType;
import org.springframework.core.convert.support.DefaultConversionService;
import org.springframework.data.domain.Sort;
import org.springframework.data.querydsl.QuerydslPredicateExecutor;
@@ -46,6 +47,7 @@ import org.springframework.data.repository.query.FluentQuery;
import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery;
import org.springframework.data.util.TypeInformation;
import org.springframework.graphql.data.GraphQlRepository;
+import org.springframework.graphql.data.TypedDataFetcher;
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
@@ -538,7 +540,7 @@ public abstract class QuerydslDataFetcher {
}
- private static class SingleEntityFetcher extends QuerydslDataFetcher implements DataFetcher {
+ private static class SingleEntityFetcher extends QuerydslDataFetcher implements TypedDataFetcher {
private final QuerydslPredicateExecutor executor;
@@ -581,10 +583,14 @@ public abstract class QuerydslDataFetcher {
}).orElse(null);
}
+ @Override
+ public ResolvableType getDeclaredType() {
+ return ResolvableType.forClass(this.resultType);
+ }
}
- private static class ManyEntityFetcher extends QuerydslDataFetcher implements DataFetcher> {
+ private static class ManyEntityFetcher extends QuerydslDataFetcher implements TypedDataFetcher> {
private final QuerydslPredicateExecutor executor;
@@ -625,10 +631,15 @@ public abstract class QuerydslDataFetcher {
});
}
+ @Override
+ public ResolvableType getDeclaredType() {
+ return ResolvableType.forClassWithGenerics(Iterable.class, this.resultType);
+ }
+
}
- private static class ReactiveSingleEntityFetcher extends QuerydslDataFetcher implements DataFetcher> {
+ private static class ReactiveSingleEntityFetcher extends QuerydslDataFetcher implements TypedDataFetcher> {
private final ReactiveQuerydslPredicateExecutor executor;
@@ -670,10 +681,15 @@ public abstract class QuerydslDataFetcher {
});
}
+ @Override
+ public ResolvableType getDeclaredType() {
+ return ResolvableType.forClassWithGenerics(Mono.class, this.resultType);
+ }
+
}
- private static class ReactiveManyEntityFetcher extends QuerydslDataFetcher implements DataFetcher> {
+ private static class ReactiveManyEntityFetcher extends QuerydslDataFetcher implements TypedDataFetcher> {
private final ReactiveQuerydslPredicateExecutor executor;
@@ -715,6 +731,11 @@ public abstract class QuerydslDataFetcher {
});
}
+ @Override
+ public ResolvableType getDeclaredType() {
+ return ResolvableType.forClassWithGenerics(Flux.class, this.resultType);
+ }
+
}
}
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 67ca0bcd..3c96076c 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
@@ -139,6 +139,12 @@ final class DefaultSchemaResourceGraphQlSourceBuilder
}
});
+ SchemaInspector.Report schemaInspectionReport = new SchemaInspector().inspectSchema(registry, runtimeWiring);
+ if(!schemaInspectionReport.isEmpty()) {
+ logger.info(schemaInspectionReport.getSummary());
+ logger.info(schemaInspectionReport.getDetailedReport());
+ }
+
return (this.schemaFactory != null ?
this.schemaFactory.apply(registry, runtimeWiring) :
new SchemaGenerator().makeExecutableSchema(registry, runtimeWiring));
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaInspector.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaInspector.java
new file mode 100644
index 00000000..86f3ea4e
--- /dev/null
+++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaInspector.java
@@ -0,0 +1,270 @@
+/*
+ * Copyright 2020-2023 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.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import graphql.language.FieldDefinition;
+import graphql.language.ImplementingTypeDefinition;
+import graphql.language.ListType;
+import graphql.language.NonNullType;
+import graphql.language.ObjectTypeDefinition;
+import graphql.language.ObjectTypeExtensionDefinition;
+import graphql.language.SDLExtensionDefinition;
+import graphql.language.Type;
+import graphql.language.TypeDefinition;
+import graphql.language.TypeName;
+import graphql.schema.DataFetcher;
+import graphql.schema.idl.RuntimeWiring;
+import graphql.schema.idl.TypeDefinitionRegistry;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.BeansException;
+import org.springframework.core.ReactiveAdapter;
+import org.springframework.core.ReactiveAdapterRegistry;
+import org.springframework.core.ResolvableType;
+import org.springframework.graphql.data.TypedDataFetcher;
+import org.springframework.lang.Nullable;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+/**
+ * Inspect the GraphQL schema and look for inconsistencies with declared {@code @SchemaMapping} handlers and {@link DataFetcher}.
+ * The inspector will produce a {@link Report}, its content can be used for logging purposes.
+ * This inspection utility will report to developers:
+ *
+ * {@code Query}, {@code Mutation} and {@code Subscription} fields that have no corresponding {@link DataFetcher} registered
+ * Fields in other schema types that have no property on the relevant Java type, or no DataFetcher registered
+ *
+ * This approach has several known limitations; the corresponding Java types are only discovered through registered
+ * {@code DataFetcher} instances, if they implement the {@link TypedDataFetcher} contract. Union types are not supported,
+ * even if a common interface is declared by a {@link TypedDataFetcher}.
+ *
+ * @author Brian Clozel
+ * @since 1.2.0
+ */
+class SchemaInspector {
+
+ private static final Log logger = LogFactory.getLog(SchemaInspector.class);
+
+ Report inspectSchema(TypeDefinitionRegistry typeDefinitionRegistry, RuntimeWiring runtimeWiring) {
+ ReportBuilder report = ReportBuilder.create();
+ SchemaInspection inspection = new SchemaInspection(typeDefinitionRegistry, runtimeWiring);
+ inspection.inspectOperation("Query", report);
+ inspection.inspectOperation("Mutation", report);
+ inspection.inspectOperation("Subscription", report);
+ return report.build();
+ }
+
+ private static class SchemaInspection {
+
+ private final TypeDefinitionRegistry typeDefinitionRegistry;
+
+ private final RuntimeWiring runtimeWiring;
+
+ private final Set seenTypes = new HashSet<>();
+
+ SchemaInspection(TypeDefinitionRegistry typeDefinitionRegistry, RuntimeWiring runtimeWiring) {
+ this.typeDefinitionRegistry = typeDefinitionRegistry;
+ this.runtimeWiring = runtimeWiring;
+ }
+
+ @SuppressWarnings("rawtypes")
+ void inspectOperation(String operationName, ReportBuilder report) {
+ Map queryFetchers = this.runtimeWiring.getDataFetcherForType(operationName);
+ this.typeDefinitionRegistry.getType(operationName, ObjectTypeDefinition.class)
+ .ifPresent(queryType -> inspectOperation(queryType, queryFetchers, report));
+ forEachObjectTypeExtension(operationName, objectTypeExtension -> inspectOperation(objectTypeExtension, queryFetchers, report));
+ }
+
+ @SuppressWarnings("rawtypes")
+ private void inspectOperation(ObjectTypeDefinition operationDefinition, Map operationDataFetchers, ReportBuilder report) {
+ for (FieldDefinition fieldDefinition : operationDefinition.getFieldDefinitions()) {
+ if (operationDataFetchers.containsKey(fieldDefinition.getName())) {
+ DataFetcher fieldDataFetcher = operationDataFetchers.get(fieldDefinition.getName());
+ if (fieldDataFetcher instanceof TypedDataFetcher> typedDataFetcher) {
+ inspectType(fieldDefinition.getType(), typedDataFetcher.getDeclaredType(), report);
+ }
+ }
+ else {
+ report.missingOperation(operationDefinition, fieldDefinition);
+ }
+ }
+ }
+
+ private void inspectType(Type> fieldType, ResolvableType declaredType, ReportBuilder report) {
+ if (fieldType instanceof TypeName typeName) {
+ this.typeDefinitionRegistry.getType(typeName)
+ .ifPresent(typeDefinition -> inspectTypeDefinition(typeDefinition, declaredType, report));
+ forEachObjectTypeExtension(typeName.getName(),
+ objectTypeExtension -> inspectTypeDefinition(objectTypeExtension, declaredType, report));
+ }
+ else if (fieldType instanceof ListType listType) {
+ inspectType(listType.getType(), declaredType.getNested(2), report);
+ }
+ else if (fieldType instanceof NonNullType nonNullType) {
+ inspectType(nonNullType.getType(), declaredType, report);
+ }
+ }
+
+ private void inspectTypeDefinition(TypeDefinition> typeDefinition, ResolvableType declaredType, ReportBuilder report) {
+ if (typeDefinition instanceof ImplementingTypeDefinition> implementingTypeDefinition) {
+ inspectImplementingType(implementingTypeDefinition, declaredType, report);
+ }
+ else if (logger.isDebugEnabled()){
+ logger.debug("Cannot inspect type '" + typeDefinition.getName() + "', inspector does not support "
+ + typeDefinition.getClass().getSimpleName());
+ }
+ }
+
+ @SuppressWarnings("rawtypes")
+ private void inspectImplementingType(ImplementingTypeDefinition> typeDefinition, ResolvableType declaredType, ReportBuilder report) {
+ if (isTypeAlreadyInspected(typeDefinition)) {
+ return;
+ }
+ Map typeDataFetcher = this.runtimeWiring.getDataFetcherForType(typeDefinition.getName());
+ Class> declaredClass = unwrapPublisherTypes(declaredType);
+ for (FieldDefinition field : typeDefinition.getFieldDefinitions()) {
+ if (typeDataFetcher.containsKey(field.getName())) {
+ DataFetcher fieldDataFetcher = typeDataFetcher.get(field.getName());
+ if (fieldDataFetcher instanceof TypedDataFetcher> typedFieldDataFetcher) {
+ inspectType(field.getType(), typedFieldDataFetcher.getDeclaredType(), report);
+ }
+ }
+ else {
+ try {
+ if (declaredClass == null || BeanUtils.getPropertyDescriptor(declaredClass, field.getName()) == null) {
+ report.missingField(typeDefinition, field);
+ }
+ }
+ catch (BeansException exc) {
+ logger.debug("Failed while inspecting " + declaredType + " for property " + field.getName() + "", exc);
+ }
+ }
+ }
+ for (Type interfaceType : typeDefinition.getImplements()) {
+ inspectType(interfaceType, declaredType, report);
+ }
+ }
+
+ private void forEachObjectTypeExtension(String typeName, Consumer extensionsConsumer) {
+ List objectTypeExtensions = this.typeDefinitionRegistry.objectTypeExtensions().get(typeName);
+ if (objectTypeExtensions != null) {
+ objectTypeExtensions.forEach(extensionsConsumer);
+ }
+ }
+
+ @Nullable
+ private Class> unwrapPublisherTypes(ResolvableType declaredType) {
+ Class> rawClass = declaredType.getRawClass();
+ if (rawClass != null) {
+ ReactiveAdapter adapter = ReactiveAdapterRegistry.getSharedInstance().getAdapter(declaredType.getRawClass());
+ if (adapter != null) {
+ return declaredType.getNested(2).getRawClass();
+ }
+ }
+ return rawClass;
+ }
+
+ private boolean isTypeAlreadyInspected(ImplementingTypeDefinition> typeDefinition) {
+ if (typeDefinition instanceof SDLExtensionDefinition) {
+ return false;
+ }
+ boolean inspectedType = this.seenTypes.contains(typeDefinition.getName());
+ if (!inspectedType) {
+ this.seenTypes.add(typeDefinition.getName());
+ }
+ return inspectedType;
+ }
+
+ }
+
+ record Report(MultiValueMap missingOperations, MultiValueMap missingFields) {
+
+ String getSummary() {
+ StringBuilder builder = new StringBuilder("GraphQL schema inspection found ");
+ if (this.missingOperations.isEmpty()) {
+ builder.append("no missing mappings for operations");
+ }
+ else {
+ builder.append("missing mappings for ").append(this.missingOperations.keySet());
+ }
+ if (this.missingFields.isEmpty()) {
+ builder.append(", no missing data fetchers for inspected types.");
+ }
+ else {
+ builder.append(", missing data fetchers for types ").append(this.missingFields.keySet()).append('.');
+ }
+ return builder.toString();
+ }
+
+ String getDetailedReport() {
+ StringBuilder builder = new StringBuilder();
+ this.missingOperations.keySet().forEach(operationName -> {
+ builder.append(String.format("- on %s: %s", operationName, this.missingOperations.get(operationName)))
+ .append(System.lineSeparator());
+ });
+ this.missingFields.keySet().forEach(typeName -> {
+ builder.append(String.format("- on %s: %s", typeName, this.missingFields.get(typeName)))
+ .append(System.lineSeparator());
+ });
+ return builder.toString();
+ }
+
+ boolean isEmpty() {
+ return this.missingOperations.isEmpty() && this.missingFields.isEmpty();
+ }
+
+ }
+
+ private static class ReportBuilder {
+
+ private final MultiValueMap missingOperations = new LinkedMultiValueMap<>();
+
+ private final MultiValueMap missingFields = new LinkedMultiValueMap<>();
+
+ private ReportBuilder() {
+
+ }
+
+ static ReportBuilder create() {
+ return new ReportBuilder();
+ }
+
+ ReportBuilder missingOperation(ImplementingTypeDefinition> operationType, FieldDefinition operationDefinition) {
+ this.missingOperations.add(operationType.getName(), operationDefinition.getName());
+ return this;
+ }
+
+ ReportBuilder missingField(ImplementingTypeDefinition> type, FieldDefinition field) {
+ this.missingFields.add(type.getName(), field.getName());
+ return this;
+ }
+
+ Report build() {
+ return new Report(this.missingOperations, this.missingFields);
+ }
+
+ }
+
+}
diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaInspectorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaInspectorTests.java
new file mode 100644
index 00000000..2f28e5d3
--- /dev/null
+++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaInspectorTests.java
@@ -0,0 +1,556 @@
+/*
+ * Copyright 2020-2023 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.Collections;
+import java.util.List;
+
+import graphql.schema.idl.RuntimeWiring;
+import graphql.schema.idl.SchemaParser;
+import graphql.schema.idl.TypeDefinitionRegistry;
+import org.assertj.core.api.AbstractAssert;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.graphql.Author;
+import org.springframework.graphql.Book;
+import org.springframework.graphql.data.method.annotation.Argument;
+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;
+
+/**
+ * Tests for {@link SchemaInspector}.
+ *
+ * @author Brian Clozel
+ */
+class SchemaInspectorTests {
+
+
+ @Nested
+ class QueriesInspectionTests {
+
+ @Test
+ void hasMissingQueryEntryWhenMissingQueryMapping() {
+ String schema = """
+ type Query {
+ greeting: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, EmptyController.class);
+ assertThatReport(report).hasSize(1).missesOperations("Query", "greeting");
+ }
+
+ @Test
+ void reportIsEmptyWhenQueryMapping() {
+ String schema = """
+ type Query {
+ greeting: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, GreetingController.class);
+ assertThatReport(report).isEmpty();
+ }
+
+ @Test
+ void inspectTypeForCollections() {
+ String schema = """
+ type Query {
+ allBooks: [Book]
+ }
+
+ type Book {
+ id: ID
+ name: String
+ missing: Boolean
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThatReport(report).hasSize(1).missesFields("Book", "missing");
+ }
+
+ @Test
+ void inspectExtensionTypesForQueries() {
+ String schema = """
+ type Query {
+ }
+ extend type Query {
+ greeting: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, EmptyController.class);
+ assertThatReport(report).hasSize(1).missesOperations("Query", "greeting");
+ }
+
+ }
+
+ @Nested
+ class MutationInspectionTests {
+ @Test
+ void hasMissingOperationEntryWhenMissingQueryMapping() {
+ String schema = """
+ type Mutation {
+ createBook: Book
+ }
+
+ type Book {
+ id: ID
+ name: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, EmptyController.class);
+ assertThatReport(report).hasSize(1).missesOperations("Mutation", "createBook");
+ }
+
+ @Test
+ void reportIsEmptyWhenMutationMapping() {
+ String schema = """
+ type Mutation {
+ createBook: Book
+ }
+
+ type Book {
+ id: ID
+ name: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThatReport(report).isEmpty();
+ }
+
+ @Test
+ void inspectExtensionTypesForMutations() {
+ String schema = """
+ type Mutation {
+ }
+ extend type Mutation {
+ createBook: Book
+ }
+ type Book {
+ id: ID
+ name: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, EmptyController.class);
+ assertThatReport(report).hasSize(1).missesOperations("Mutation", "createBook");
+ }
+
+ }
+
+ @Nested
+ class SubscriptionInspectionTests {
+ @Test
+ void hasMissingOperationEntryWhenMissingSubscriptionMapping() {
+ String schema = """
+ type Subscription {
+ bookSearch(author: String) : Book!
+ }
+
+ type Book {
+ id: ID
+ name: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, EmptyController.class);
+ assertThatReport(report).hasSize(1).missesOperations("Subscription", "bookSearch");
+ }
+
+ @Test
+ void reportIsEmptyWhenSubscriptionMapping() {
+ String schema = """
+ type Subscription {
+ bookSearch(author: String) : Book!
+ }
+
+ type Book {
+ id: ID
+ name: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThatReport(report).isEmpty();
+ }
+
+ @Test
+ void inspectExtensionTypesForSubscriptions() {
+ String schema = """
+ type Subscription {
+ }
+ extend type Subscription {
+ bookSearch(author: String) : Book!
+ }
+ type Book {
+ id: ID
+ name: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, EmptyController.class);
+ assertThatReport(report).hasSize(1).missesOperations("Subscription", "bookSearch");
+ }
+
+ }
+
+ @Nested
+ class TypesInspectionTests {
+ @Test
+ void reportIsEmptyWhenPropertyOnType() {
+ String schema = """
+ type Query {
+ bookById(id: ID): Book
+ }
+
+ type Book {
+ id: ID
+ name: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThatReport(report).isEmpty();
+ }
+
+ @Test
+ void reportIsEmptyWhenDataFetcherForField() {
+ String schema = """
+ type Query {
+ bookById(id: ID): Book
+ }
+
+ type Book {
+ id: ID
+ name: String
+ fetcher: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThatReport(report).isEmpty();
+ }
+
+ @Test
+ void hasMissingFieldEntryWhenMissingPropertyOnType() {
+ String schema = """
+ type Query {
+ bookById(id: ID): Book
+ }
+
+ type Book {
+ id: ID
+ name: String
+ missing: Boolean
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThatReport(report).hasSize(1).missesFields("Book", "missing");
+ }
+
+ @Test
+ void hasMissingFieldEntryWhenMissingPropertyOnNestedType() {
+ String schema = """
+ type Query {
+ bookById(id: ID): Book
+ }
+
+ type Book {
+ id: ID
+ name: String
+ author: Author
+ }
+
+ type Author {
+ id: ID
+ firstName: String
+ missing: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThatReport(report).hasSize(1).missesFields("Author", "missing");
+ }
+
+ @Test
+ void cyclicRelationBetweenTypesDoNotFail() {
+ String schema = """
+ type Query {
+ teamById(id: ID): Team
+ }
+
+ type Team {
+ name: String
+ members: [TeamMember]
+ }
+
+ type TeamMember {
+ name: String
+ team: Team
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, TeamController.class);
+ assertThatReport(report).isEmpty();
+ }
+
+ @Test
+ void hasMissingFieldEntryWhenMissingPropertyOnTypeProvidedByExtension() {
+ String schema = """
+ type Query {
+ bookById(id: ID): Book
+ }
+
+ type Book {
+ id: ID
+ name: String
+ }
+ extend type Book {
+ missing: Boolean
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThatReport(report).hasSize(1).missesFields("Book", "missing");
+ }
+
+ @Test
+ void hasMissingFieldEntryWhenMissingPropertyOnTypeProvidedByInterface() {
+ String schema = """
+ type Query {
+ bookById(id: ID): Book
+ }
+
+ interface LibraryItem {
+ missing: Boolean
+ }
+ type Book implements LibraryItem {
+ id: ID
+ name: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThatReport(report).hasSize(1).missesFields("LibraryItem", "missing");
+ }
+
+ }
+
+ @Nested
+ class ReportFormatTests {
+
+ @Test
+ void reportsMissingQuery() {
+ String schema = """
+ type Query {
+ greeting: String
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, EmptyController.class);
+ assertThat(report.getSummary()).isEqualTo("GraphQL schema inspection found missing mappings for [Query], no missing data fetchers for inspected types.");
+ assertThat(report.getDetailedReport()).isEqualTo("""
+ - on Query: [greeting]
+ """);
+ }
+
+ @Test
+ void reportMissingField() {
+ String schema = """
+ type Query {
+ allBooks: [Book]
+ }
+
+ type Book {
+ id: ID
+ name: String
+ missing: Boolean
+ }
+ """;
+ SchemaInspector.Report report = inspectSchema(schema, BookController.class);
+ assertThat(report.getSummary()).isEqualTo("GraphQL schema inspection found no missing mappings for operations, missing data fetchers for types [Book].");
+ assertThat(report.getDetailedReport()).isEqualTo("""
+ - on Book: [missing]
+ """);
+ }
+
+ }
+
+
+ @Controller
+ static class EmptyController {
+
+ }
+
+ @Controller
+ static class GreetingController {
+
+ @QueryMapping
+ String greeting() {
+ return "Hello";
+ }
+ }
+
+ @Controller
+ static class BookController {
+
+ @QueryMapping
+ public Book bookById(@Argument Long id) {
+ return new Book();
+ }
+
+ @SchemaMapping
+ public Author author(Book book) {
+ return new Author();
+ }
+
+ @QueryMapping
+ public List allBooks() {
+ return List.of(new Book());
+ }
+
+ @SchemaMapping
+ public String fetcher(Book book) {
+ return "custom fetcher";
+ }
+
+ @MutationMapping
+ public Book createBook() {
+ return new Book();
+ }
+
+ @SubscriptionMapping
+ public Mono bookSearch(@Argument String author) {
+ return Mono.empty();
+ }
+ }
+
+ @Controller
+ static class TeamController {
+ @QueryMapping
+ public Team teamById(@Argument Long id) {
+ return new Team("spring", Collections.emptyList());
+ }
+
+ @SchemaMapping
+ public List members(Team team) {
+ return List.of();
+ }
+
+ @SchemaMapping
+ public Team team(TeamMember teamMember) {
+ return null;
+ }
+
+ }
+
+ record Team(String name, List members) {
+
+ }
+
+ record TeamMember(String name, Team team) {
+
+ }
+
+ SchemaInspector.Report inspectSchema(String schema, Class>... controllers) {
+ TypeDefinitionRegistry typeDefinitionRegistry = loadTypeDefinitionRegistryFromSchema(schema);
+ RuntimeWiring.Builder builder = createRuntimeWiring(controllers);
+ return new SchemaInspector().inspectSchema(typeDefinitionRegistry, builder.build());
+ }
+
+
+ TypeDefinitionRegistry loadTypeDefinitionRegistryFromSchema(String schema) {
+ return new SchemaParser().parse(schema);
+ }
+
+
+ RuntimeWiring.Builder createRuntimeWiring(Class>... handlerTypes) {
+ AnnotationConfigApplicationContext appContext = new AnnotationConfigApplicationContext();
+ for (Class> handlerType : handlerTypes) {
+ appContext.registerBean(handlerType);
+ }
+ appContext.refresh();
+
+ AnnotatedControllerConfigurer configurer = new AnnotatedControllerConfigurer();
+ configurer.setApplicationContext(appContext);
+ configurer.afterPropertiesSet();
+
+ RuntimeWiring.Builder wiringBuilder = RuntimeWiring.newRuntimeWiring();
+ configurer.configure(wiringBuilder);
+ return wiringBuilder;
+ }
+
+ static SchemaInspectionReportAssert assertThatReport(SchemaInspector.Report actual) {
+ return new SchemaInspectionReportAssert(actual);
+ }
+
+ static class SchemaInspectionReportAssert extends AbstractAssert {
+
+ public SchemaInspectionReportAssert(SchemaInspector.Report actual) {
+ super(actual, SchemaInspectionReportAssert.class);
+ }
+
+ public void isEmpty() {
+ isNotNull();
+ if (!this.actual.missingOperations().isEmpty()) {
+ failWithMessage("Report contains missing operations for %s",
+ this.actual.missingOperations().keySet());
+ }
+ if (!this.actual.missingFields().isEmpty()) {
+ failWithMessage("Report contains missing fields for %s",
+ this.actual.missingFields().keySet());
+ }
+ }
+
+ public SchemaInspectionReportAssert hasSize(int size) {
+ isNotNull();
+ Integer missingOps = this.actual.missingOperations().values().stream().map(List::size).reduce(0, Integer::sum);
+ Integer missingFields = this.actual.missingFields().values().stream().map(List::size).reduce(0, Integer::sum);
+ if ((missingOps + missingFields) != size) {
+ failWithMessage("Expected report with %s entries, found %d.", size, (missingOps + missingFields));
+ }
+ return this;
+ }
+
+ public SchemaInspectionReportAssert missesOperations(String operationType, String... names) {
+ isNotNull();
+ List expectedOperations = Arrays.asList(names);
+ List actualOperations = this.actual.missingOperations().get(operationType);
+ if (actualOperations != null) {
+ if (!actualOperations.containsAll(expectedOperations)) {
+ failWithMessage("Expected missing DataFetchers for %s: %s, found %s", operationType, expectedOperations, actualOperations);
+ }
+ }
+ else {
+ failWithMessage("No missing DataFetcher for %s", operationType);
+ }
+ return this;
+ }
+
+ public SchemaInspectionReportAssert missesFields(String typeName, String... fieldNames) {
+ isNotNull();
+ List expectedFields = Arrays.asList(fieldNames);
+ List actualFields = this.actual.missingFields().get(typeName);
+ if (actualFields != null) {
+ if (!actualFields.containsAll(expectedFields)) {
+ failWithMessage("Expected missing fields for %s: %s, found %s", typeName, expectedFields, actualFields);
+ }
+ }
+ else {
+ failWithMessage("No missing field for %s", typeName);
+ }
+ return this;
+ }
+ }
+}
\ No newline at end of file