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 e078995d..57a2689b 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
@@ -136,9 +136,7 @@ final class DefaultSchemaResourceGraphQlSourceBuilder
configureGraphQl(builder -> {
GraphQLSchema schema = builder.build().getGraphQLSchema();
SchemaMappingInspector.Report report = SchemaMappingInspector.inspect(schema, runtimeWiring);
- if (!report.isEmpty()) {
- logger.info(report.getSummary());
- }
+ logger.info(report);
});
return (this.schemaFactory != null ?
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 33384ce7..ed753fba 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
@@ -17,23 +17,23 @@
package org.springframework.graphql.execution;
import java.util.HashSet;
+import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
import graphql.schema.DataFetcher;
+import graphql.schema.GraphQLEnumType;
import graphql.schema.GraphQLFieldDefinition;
+import graphql.schema.GraphQLFieldsContainer;
import graphql.schema.GraphQLList;
+import graphql.schema.GraphQLNamedOutputType;
import graphql.schema.GraphQLNamedType;
import graphql.schema.GraphQLNonNull;
import graphql.schema.GraphQLObjectType;
+import graphql.schema.GraphQLScalarType;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLType;
import graphql.schema.idl.RuntimeWiring;
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-import org.jetbrains.annotations.NotNull;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.BeansException;
@@ -41,20 +41,29 @@ import org.springframework.core.ReactiveAdapter;
import org.springframework.core.ReactiveAdapterRegistry;
import org.springframework.core.ResolvableType;
import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
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 SelfDescribingDataFetcher} contract. Union types are not supported,
- * even if a common interface is declared by a {@link SelfDescribingDataFetcher}.
+ * Provide {@link #inspect(GraphQLSchema, RuntimeWiring)} method that checks if
+ * schema fields are covered by either a {@link DataFetcher} registration, or a
+ * Java object property. Fields that have neither are reported as unmapped in
+ * the output {@link Report}.
+ *
+ *
The inspection depends on {@code DataFetcher}s to expose return type
+ * information by implementing {@link SelfDescribingDataFetcher}. This allows
+ * checking if Java object types have properties that match schema fields.
+ * If a {@code DataFetcher} does not implement this interface, then the Java
+ * object type is not known, and the field type is reported as skipped.
+ *
+ *
The {@link SelfDescribingDataFetcher} for annotated controller methods
+ * exposes the declared return type of the controller method. If the return type
+ * is {@link Object} such as for a union, then the Java object structure is
+ * not known, and the field output type is reported as skipped.
+ *
+ *
Union types are automatically skipped because there is no way for an
+ * annotated controller method to declare the actual Java types.
*
* @author Brian Clozel
* @author Rossen Stoyanchev
@@ -62,196 +71,202 @@ import org.springframework.util.MultiValueMap;
*/
class SchemaMappingInspector {
- private static final Log logger = LogFactory.getLog(SchemaMappingInspector.class);
-
-
private final GraphQLSchema schema;
private final RuntimeWiring runtimeWiring;
+ private final ReportBuilder reportBuilder = new ReportBuilder();
+
private final Set seenTypes = new HashSet<>();
+ private final ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance();
+
private SchemaMappingInspector(GraphQLSchema schema, RuntimeWiring runtimeWiring) {
+ Assert.notNull(schema, "GraphQLSchema is required");
+ Assert.notNull(runtimeWiring, "RuntimeWiring is required");
this.schema = schema;
this.runtimeWiring = runtimeWiring;
}
- public Report inspectMappings() {
- ReportBuilder report = ReportBuilder.create();
- inspectOperation(schema.getQueryType(), report);
- inspectOperation(schema.getMutationType(), report);
- inspectOperation(schema.getSubscriptionType(), report);
- return report.build();
- }
+ /**
+ * Inspect all fields, starting from Query, Mutation, and Subscription, and
+ * working recursively down through the types they return.
+ * @return a report with unmapped fields and skipped types.
+ */
+ public Report inspect() {
+ inspectType(this.schema.getQueryType(), null);
+
+ if (this.schema.isSupportingMutations()) {
+ inspectType(this.schema.getMutationType(), null);
+ }
+
+ if (this.schema.isSupportingSubscriptions()) {
+ inspectType(this.schema.getSubscriptionType(), null);
+ }
+
+ return this.reportBuilder.build();
+ }
@SuppressWarnings("rawtypes")
- private void inspectOperation(@Nullable GraphQLObjectType operationType, ReportBuilder report) {
- if (operationType != null) {
- Map operationDataFetchers = this.runtimeWiring.getDataFetcherForType(operationType.getName());
- for (GraphQLFieldDefinition fieldDefinition : operationType.getFieldDefinitions()) {
- if (operationDataFetchers.containsKey(fieldDefinition.getName())) {
- DataFetcher fieldDataFetcher = operationDataFetchers.get(fieldDefinition.getName());
- if (fieldDataFetcher instanceof SelfDescribingDataFetcher> selfDescribingDataFetcher) {
- inspectType(fieldDefinition.getType(), selfDescribingDataFetcher.getReturnType(), report);
- }
- }
- else {
- report.missingOperation(operationType, fieldDefinition);
- }
- }
- }
- }
+ private void inspectType(GraphQLType type, @Nullable ResolvableType resolvableType) {
+ Assert.notNull(type, "No GraphQLType");
- private void inspectType(GraphQLType type, ResolvableType declaredType, ReportBuilder report) {
- if (type instanceof GraphQLObjectType objectType) {
- inspectObjectType(objectType, declaredType, report);
+ type = unwrapNonNull(type);
+ if (isConnectionType(type)) {
+ type = getConnectionNodeType(type);
+ resolvableType = nest(resolvableType, type);
}
else if (type instanceof GraphQLList listType) {
- inspectType(listType.getWrappedType(), declaredType.getNested(2), report);
+ type = unwrapNonNull(listType.getWrappedType());
+ resolvableType = nest(resolvableType, type);
}
- else if (type instanceof GraphQLNonNull nonNullType) {
- inspectType(nonNullType.getWrappedType(), declaredType, report);
- }
- else if (type instanceof GraphQLNamedType namedType && logger.isTraceEnabled()){
- logger.trace("Cannot inspect type '" + namedType.getName() + "', inspector does not support "
- + type.getClass().getSimpleName());
- }
- }
- @SuppressWarnings("rawtypes")
- private void inspectObjectType(GraphQLObjectType objectType, ResolvableType declaredType, ReportBuilder report) {
- if (isTypeAlreadyInspected(objectType)) {
+ if (type instanceof GraphQLNamedOutputType outputType) {
+ if (!this.seenTypes.add(outputType.getName())) {
+ return;
+ }
+ }
+
+ if (!(type instanceof GraphQLFieldsContainer fieldContainer)) {
+ if (isNotScalarOrEnumType(type)) {
+ this.reportBuilder.addSkippedType(getTypeName(type));
+ }
return;
}
- if (isConnectionType(objectType)) {
- objectType = getEdgeNodeType(objectType);
- declaredType = declaredType.getNested(2);
+ else if (resolvableType != null && resolveClassToCompare(resolvableType) == Object.class) {
+ this.reportBuilder.addSkippedType(getTypeName(type));
+ return;
}
- Map typeDataFetcher = this.runtimeWiring.getDataFetcherForType(objectType.getName());
- Class> declaredClass = unwrapPublisherTypes(declaredType);
- for (GraphQLFieldDefinition field : objectType.getFieldDefinitions()) {
- if (typeDataFetcher.containsKey(field.getName())) {
- DataFetcher fieldDataFetcher = typeDataFetcher.get(field.getName());
- if (fieldDataFetcher instanceof SelfDescribingDataFetcher> typedFieldDataFetcher) {
- inspectType(field.getType(), typedFieldDataFetcher.getReturnType(), report);
+
+ String typeName = fieldContainer.getName();
+ Map dataFetcherMap = this.runtimeWiring.getDataFetcherForType(typeName);
+
+ for (GraphQLFieldDefinition field : fieldContainer.getFieldDefinitions()) {
+ String fieldName = field.getName();
+ if (dataFetcherMap.containsKey(fieldName)) {
+ DataFetcher fetcher = dataFetcherMap.get(fieldName);
+ if (fetcher instanceof SelfDescribingDataFetcher> selfDescribingDataFetcher) {
+ inspectType(field.getType(), selfDescribingDataFetcher.getReturnType());
+ }
+ else if (isNotScalarOrEnumType(field.getType())) {
+ this.reportBuilder.addSkippedType(getTypeName(field.getType()));
}
}
- else {
- try {
- if (declaredClass == null || BeanUtils.getPropertyDescriptor(declaredClass, field.getName()) == null) {
- report.missingField(objectType, field);
- }
- }
- catch (BeansException exc) {
- logger.debug("Failed while inspecting " + declaredType + " for property " + field.getName() + "", exc);
- }
+ else if (resolvableType == null || !hasProperty(resolvableType, fieldName)) {
+ this.reportBuilder.addUnmappedField(typeName, fieldName);
}
}
}
- private boolean isConnectionType(GraphQLObjectType objectType) {
- return (objectType.getName().endsWith("Connection") &&
+ private GraphQLType unwrapNonNull(GraphQLType type) {
+ return (type instanceof GraphQLNonNull graphQLNonNull ? graphQLNonNull.getWrappedType() : type);
+ }
+
+ private boolean isConnectionType(GraphQLType type) {
+ return (type instanceof GraphQLObjectType objectType &&
+ objectType.getName().endsWith("Connection") &&
objectType.getField("edges") != null && objectType.getField("pageInfo") != null);
}
- @NotNull
- private GraphQLObjectType getEdgeNodeType(GraphQLObjectType objectType) {
- GraphQLList edgesListType = (GraphQLList) unwrapNonNull(objectType.getField("edges").getType());
- GraphQLObjectType edgeType = (GraphQLObjectType) edgesListType.getWrappedType();
- return (GraphQLObjectType) unwrapNonNull(edgeType.getField("node").getType());
+ private GraphQLType getConnectionNodeType(GraphQLType type) {
+ String name = ((GraphQLObjectType) type).getName();
+ name = name.substring(0, name.length() - 10);
+ type = this.schema.getType(name);
+ Assert.state(type != null, "No '" + name + "' type");
+ return type;
}
- private GraphQLType unwrapNonNull(GraphQLType outputType) {
- return (outputType instanceof GraphQLNonNull wrapper ? wrapper.getWrappedType() : outputType);
+ private static ResolvableType nest(@Nullable ResolvableType resolvableType, GraphQLType type) {
+ Assert.notNull(resolvableType, "No declaredType for " + getTypeName(type));
+ resolvableType = resolvableType.getNested(2);
+ return resolvableType;
}
- @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();
- }
+ private static String getTypeName(GraphQLType type) {
+ return (type instanceof GraphQLNamedType namedType ? namedType.getName() : type.toString());
+ }
+
+ private static boolean isNotScalarOrEnumType(GraphQLType type) {
+ return !(type instanceof GraphQLScalarType || type instanceof GraphQLEnumType);
+ }
+
+ private boolean hasProperty(ResolvableType resolvableType, String fieldName) {
+ try {
+ Class> clazz = resolveClassToCompare(resolvableType);
+ return (BeanUtils.getPropertyDescriptor(clazz, fieldName) != null);
+ }
+ catch (BeansException ex) {
+ throw new IllegalStateException(
+ "Failed to introspect " + resolvableType + " for field '" + fieldName + "'", ex);
}
- return rawClass;
}
- private boolean isTypeAlreadyInspected(GraphQLObjectType objectType) {
- return !this.seenTypes.add(objectType.getName());
+ private Class> resolveClassToCompare(ResolvableType resolvableType) {
+ Class> clazz = resolvableType.resolve(Object.class);
+ ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(clazz);
+ return (adapter != null ? resolvableType.getNested(2).resolve(Object.class) : clazz);
}
/**
- * Inspect the schema mappings and produce a report.
+ * Check the schema against {@code DataFetcher} registrations, and produce a report.
* @param schema the schema to inspect
- * @param runtimeWiring for {@code DataFetcher} mappings
- * @return the resulting report
+ * @param runtimeWiring for {@code DataFetcher} registrations
+ * @return the created report
*/
public static Report inspect(GraphQLSchema schema, RuntimeWiring runtimeWiring) {
- return new SchemaMappingInspector(schema, runtimeWiring).inspectMappings();
+ SchemaMappingInspector inspector = new SchemaMappingInspector(schema, runtimeWiring);
+ return inspector.inspect();
}
- record Report(MultiValueMap missingOperations, MultiValueMap missingFields) {
+ /**
+ * Container of unmapped fields and skipped types.
+ * @param unmappedFields fields with neither {@link DataFetcher} mapping nor Object property
+ * @param skippedTypes types that could not be verified, e.g. union
+ */
+ public record Report(MultiValueMap unmappedFields, Set skippedTypes) {
- String getSummary() {
- StringBuilder builder = new StringBuilder("GraphQL schema inspection found ");
- if (this.missingOperations.isEmpty() && this.missingFields.isEmpty()) {
- builder.append("no missing mapping.");
- }
- else {
- builder.append(getDetailedReport());
- }
- return builder.toString();
+ @Override
+ public String toString() {
+ return "GraphQL schema inspection:\n" +
+ "\tUnmapped fields: " + this.unmappedFields + "\n" +
+ "\tSkipped types: " + this.skippedTypes;
}
-
- private String getDetailedReport() {
- Stream missingOperationsReport = this.missingOperations.keySet().stream()
- .map(operationName -> String.format("%s%s", operationName, this.missingOperations.get(operationName)));
- Stream missingFieldsReport = this.missingFields.keySet().stream()
- .map(typeName -> String.format("%s%s", typeName, this.missingFields.get(typeName)));
- return Stream.concat(missingOperationsReport, missingFieldsReport)
- .collect(Collectors.joining(", ", "missing mappings for: ", "."));
- }
-
- boolean isEmpty() {
- return this.missingOperations.isEmpty() && this.missingFields.isEmpty();
- }
-
}
+ /**
+ * Builder for a {@link Report}.
+ */
private static class ReportBuilder {
- private final MultiValueMap missingOperations = new LinkedMultiValueMap<>();
+ private final MultiValueMap unmappedFields = new LinkedMultiValueMap<>();
- private final MultiValueMap missingFields = new LinkedMultiValueMap<>();
-
- private ReportBuilder() {
+ private final Set skippedTypes = new LinkedHashSet<>();
+ /**
+ * Add an unmapped field.
+ */
+ public void addUnmappedField(String typeName, String fieldName) {
+ this.unmappedFields.add(typeName, fieldName);
}
- static ReportBuilder create() {
- return new ReportBuilder();
+ /**
+ * Add a skipped type name.
+ */
+ public void addSkippedType(String typeName) {
+ this.skippedTypes.add(typeName);
}
- ReportBuilder missingOperation(GraphQLObjectType operationType, GraphQLFieldDefinition operationDefinition) {
- this.missingOperations.add(operationType.getName(), operationDefinition.getName());
- return this;
- }
-
- ReportBuilder missingField(GraphQLObjectType objectType, GraphQLFieldDefinition field) {
- this.missingFields.add(objectType.getName(), field.getName());
- return this;
- }
-
- Report build() {
- return new Report(this.missingOperations, this.missingFields);
+ public Report build() {
+ return new Report(
+ new LinkedMultiValueMap<>(this.unmappedFields),
+ new LinkedHashSet<>(this.skippedTypes));
}
}
diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/SelfDescribingDataFetcher.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/SelfDescribingDataFetcher.java
index 1252f2a1..12860f60 100644
--- a/spring-graphql/src/main/java/org/springframework/graphql/execution/SelfDescribingDataFetcher.java
+++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/SelfDescribingDataFetcher.java
@@ -36,5 +36,5 @@ public interface SelfDescribingDataFetcher extends DataFetcher {
* @return the type of the data returned by the data fetcher.
*/
ResolvableType getReturnType();
-
+
}
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 6d4f7115..899a763f 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
@@ -19,9 +19,11 @@ package org.springframework.graphql.execution;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
+import java.util.Set;
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;
@@ -53,21 +55,21 @@ class SchemaMappingInspectorTests {
@Nested
- class QueriesInspectionTests {
+ class QueryTests {
@Test
- void hasMissingQueryEntryWhenMissingQueryMapping() {
+ void reportHasUnmappedQuery() {
String schema = """
type Query {
greeting: String
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, EmptyController.class);
- assertThatReport(report).hasSize(1).missesOperations("Query", "greeting");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Query", "greeting");
}
@Test
- void reportIsEmptyWhenQueryMapping() {
+ void reportIsEmptyWhenQueryIsMapped() {
String schema = """
type Query {
greeting: String
@@ -78,7 +80,7 @@ class SchemaMappingInspectorTests {
}
@Test
- void inspectTypeForCollections() {
+ void reportWorksForQueryWithList() {
String schema = """
type Query {
allBooks: [Book]
@@ -91,15 +93,30 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, BookController.class);
- assertThatReport(report).hasSize(1).missesFields("Book", "missing");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Book", "missing");
}
@Test
- void inspectTypeForConnection() {
+ void reportWorksForQueryWithConnection() {
String schema = """
type Query {
paginatedBooks: BookConnection
}
+
+ type BookConnection {
+ edges: [BookEdge]!
+ pageInfo: PageInfo!
+ }
+
+ type BookEdge {
+ cursor: String!
+ # ...
+ }
+
+ type PageInfo {
+ startCursor: String
+ # ...
+ }
type Book {
id: ID
@@ -108,11 +125,11 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, BookController.class);
- assertThatReport(report).hasSize(1).missesFields("Book", "missing");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Book", "missing");
}
@Test
- void inspectExtensionTypesForQueries() {
+ void reportWorksForQueryWithExtensionType() {
String schema = """
type Query {
}
@@ -121,15 +138,17 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, EmptyController.class);
- assertThatReport(report).hasSize(1).missesOperations("Query", "greeting");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Query", "greeting");
}
}
+
@Nested
- class MutationInspectionTests {
+ class MutationTests {
+
@Test
- void hasMissingOperationEntryWhenMissingQueryMapping() {
+ void reportContainsUnmappedMutation() {
String schema = """
type Query{
greeting: String
@@ -144,11 +163,11 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, GreetingController.class);
- assertThatReport(report).hasSize(1).missesOperations("Mutation", "createBook");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Mutation", "createBook");
}
@Test
- void reportIsEmptyWhenMutationMapping() {
+ void reportIsEmptyWhenMutationIsMapped() {
String schema = """
type Query{
greeting: String
@@ -167,7 +186,7 @@ class SchemaMappingInspectorTests {
}
@Test
- void inspectExtensionTypesForMutations() {
+ void reportWorksForMutationWithExtensionType() {
String schema = """
type Query {
greeting: String
@@ -183,15 +202,17 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, GreetingController.class);
- assertThatReport(report).hasSize(1).missesOperations("Mutation", "createBook");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Mutation", "createBook");
}
}
+
@Nested
- class SubscriptionInspectionTests {
+ class SubscriptionTests {
+
@Test
- void hasMissingOperationEntryWhenMissingSubscriptionMapping() {
+ void reportContainsUnmappedSubscription() {
String schema = """
type Query{
greeting: String
@@ -206,11 +227,11 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, GreetingController.class);
- assertThatReport(report).hasSize(1).missesOperations("Subscription", "bookSearch");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Subscription", "bookSearch");
}
@Test
- void reportIsEmptyWhenSubscriptionMapping() {
+ void reportIsEmptyWhenSubscriptionIsMapped() {
String schema = """
type Query{
greeting: String
@@ -229,7 +250,7 @@ class SchemaMappingInspectorTests {
}
@Test
- void inspectExtensionTypesForSubscriptions() {
+ void reportWorksForSubscriptionWithExtensionType() {
String schema = """
type Query{
greeting: String
@@ -245,15 +266,17 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, GreetingController.class);
- assertThatReport(report).hasSize(1).missesOperations("Subscription", "bookSearch");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Subscription", "bookSearch");
}
}
+
@Nested
class TypesInspectionTests {
+
@Test
- void reportIsEmptyWhenPropertyOnType() {
+ void reportIsEmptyWhenFieldHasMatchingObjectProperty() {
String schema = """
type Query {
bookById(id: ID): Book
@@ -269,7 +292,7 @@ class SchemaMappingInspectorTests {
}
@Test
- void reportIsEmptyWhenDataFetcherForField() {
+ void reportIsEmptyWhenFieldHasDataFetcherMapping() {
String schema = """
type Query {
bookById(id: ID): Book
@@ -286,7 +309,7 @@ class SchemaMappingInspectorTests {
}
@Test
- void hasMissingFieldEntryWhenMissingPropertyOnType() {
+ void reportHasUnmappedField() {
String schema = """
type Query {
bookById(id: ID): Book
@@ -299,11 +322,11 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, BookController.class);
- assertThatReport(report).hasSize(1).missesFields("Book", "missing");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Book", "missing");
}
@Test
- void hasMissingFieldEntryWhenMissingPropertyOnNestedType() {
+ void reportHasUnmappedFieldOnNestedType() {
String schema = """
type Query {
bookById(id: ID): Book
@@ -322,11 +345,11 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, BookController.class);
- assertThatReport(report).hasSize(1).missesFields("Author", "missing");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Author", "missing");
}
@Test
- void cyclicRelationBetweenTypesDoNotFail() {
+ void reportWorksWithCyclicRelations() {
String schema = """
type Query {
teamById(id: ID): Team
@@ -340,14 +363,15 @@ class SchemaMappingInspectorTests {
type TeamMember {
name: String
team: Team
+ missing: String
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, TeamController.class);
- assertThatReport(report).isEmpty();
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("TeamMember", "missing");
}
@Test
- void hasMissingFieldEntryWhenMissingPropertyOnTypeProvidedByExtension() {
+ void reportWorksWithPropertyOnTypeExtension() {
String schema = """
type Query {
bookById(id: ID): Book
@@ -362,27 +386,93 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, BookController.class);
- assertThatReport(report).hasSize(1).missesFields("Book", "missing");
+ assertThatReport(report).hasUnmappedFieldCount(1).containsUnmappedFields("Book", "missing");
}
- }
+ @Test
+ void reportHasSkippedUnionType() {
+ String schema = """
+ type Query {
+ fooBar: FooBar
+ }
- @Nested
- class ReportFormatTests {
+ union FooBar = Foo | Bar
- @Test
- void reportsMissingQuery() {
- String schema = """
+ type Foo {
+ name: String
+ }
+
+ type Bar {
+ name: String
+ }
+ """;
+ SchemaMappingInspector.Report report = inspectSchema(schema, UnionController.class);
+ assertThatReport(report).hasUnmappedFieldCount(0).hasSkippedTypeCount(1).containsSkippedTypes("FooBar");
+ }
+
+ @Test
+ void reportHasSkippedTypeForUnknownDataFetcherType() {
+ String schemaContent = """
+ type Query {
+ bookById(id: ID): Book
+ }
+
+ type Book {
+ id: ID
+ name: String
+ }
+ """;
+
+ GraphQLSchema schema = GraphQlSetup.schemaContent(schemaContent).toGraphQlSource().schema();
+ RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring()
+ .type("Query", builder -> builder.dataFetcher("bookById", environment -> null))
+ .build();
+
+ SchemaMappingInspector.Report report = SchemaMappingInspector.inspect(schema, wiring);
+ assertThatReport(report).hasUnmappedFieldCount(0).hasSkippedTypeCount(1).containsSkippedTypes("Book");
+ }
+
+ @Test
+ void reportIsEmptyIfUnknownDataFetcherReturnsSimpleType() {
+ String schemaContent = """
type Query {
greeting: String
}
""";
- SchemaMappingInspector.Report report = inspectSchema(schema, EmptyController.class);
- assertThat(report.getSummary()).isEqualTo("GraphQL schema inspection found missing mappings for: Query[greeting].");
- }
- @Test
- void reportMissingField() {
+ GraphQLSchema schema = GraphQlSetup.schemaContent(schemaContent).toGraphQlSource().schema();
+ RuntimeWiring wiring = RuntimeWiring.newRuntimeWiring()
+ .type("Query", builder -> builder.dataFetcher("greeting", environment -> null))
+ .build();
+
+ SchemaMappingInspector.Report report = SchemaMappingInspector.inspect(schema, wiring);
+ assertThatReport(report).hasUnmappedFieldCount(0).hasSkippedTypeCount(0);
+ }
+
+ @Test
+ void reportHasSkippedTypeForObjectReturnType() {
+ String schemaContent = """
+ type Query {
+ bookObject(id: ID): Book
+ }
+
+ type Book {
+ id: ID
+ name: String
+ }
+ """;
+ SchemaMappingInspector.Report report = inspectSchema(schemaContent, BookController.class);
+ assertThatReport(report).hasUnmappedFieldCount(0).hasSkippedTypeCount(1).containsSkippedTypes("Book");
+ }
+
+ }
+
+
+ @Nested
+ class ReportFormatTests {
+
+ @Test
+ void reportUnmappedField() {
String schema = """
type Query {
allBooks: [Book]
@@ -395,17 +485,48 @@ class SchemaMappingInspectorTests {
}
""";
SchemaMappingInspector.Report report = inspectSchema(schema, BookController.class);
- assertThat(report.getSummary()).isEqualTo("GraphQL schema inspection found missing mappings for: Book[missing].");
+ assertThatReport(report).hasUnmappedFieldCount(1).hasSkippedTypeCount(0);
+ assertThat(report.toString()).isEqualTo("""
+ GraphQL schema inspection:
+ Unmapped fields: {Book=[missing]}
+ Skipped types: []""");
}
}
+ private SchemaMappingInspector.Report 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.refresh();
+
+ AnnotatedControllerConfigurer configurer = new AnnotatedControllerConfigurer();
+ configurer.setApplicationContext(context);
+ configurer.afterPropertiesSet();
+
+ RuntimeWiring.Builder wiringBuilder = RuntimeWiring.newRuntimeWiring();
+ configurer.configure(wiringBuilder);
+ return wiringBuilder.build();
+ }
+
+ static SchemaInspectionReportAssert assertThatReport(SchemaMappingInspector.Report actual) {
+ return new SchemaInspectionReportAssert(actual);
+ }
+
@Controller
static class EmptyController {
}
+
@Controller
static class GreetingController {
@@ -415,6 +536,7 @@ class SchemaMappingInspectorTests {
}
}
+
@Controller
static class BookController {
@@ -443,6 +565,11 @@ class SchemaMappingInspectorTests {
return "custom fetcher";
}
+ @QueryMapping
+ public Object bookObject(@Argument Long id) {
+ return new Book();
+ }
+
@MutationMapping
public Book createBook() {
return new Book();
@@ -454,6 +581,7 @@ class SchemaMappingInspectorTests {
}
}
+
@Controller
static class TeamController {
@QueryMapping
@@ -473,47 +601,29 @@ class SchemaMappingInspectorTests {
}
+
record Team(String name, List members) {
}
+
record TeamMember(String name, Team team) {
}
- SchemaMappingInspector.Report inspectSchema(String schemaContent, Class>... controllers) {
- RuntimeWiringConfigurer wiringConfigurer = createRuntimeWiring(controllers);
- RuntimeWiring.Builder wiringBuilder = RuntimeWiring.newRuntimeWiring();
- wiringConfigurer.configure(wiringBuilder);
- GraphQLSchema schema = GraphQlSetup.schemaContent(schemaContent)
- .runtimeWiring(wiringConfigurer)
- .typeDefinitionConfigurer(new ConnectionTypeDefinitionConfigurer())
- .toGraphQlSource()
- .schema();
+ @Controller
+ static class UnionController {
- return SchemaMappingInspector.inspect(schema, wiringBuilder.build());
- }
-
- private RuntimeWiringConfigurer createRuntimeWiring(Class>... handlerTypes) {
- AnnotationConfigApplicationContext appContext = new AnnotationConfigApplicationContext();
- for (Class> handlerType : handlerTypes) {
- appContext.registerBean(handlerType);
+ @QueryMapping
+ Object fooBar() {
+ return "Hello";
}
- appContext.refresh();
-
- AnnotatedControllerConfigurer configurer = new AnnotatedControllerConfigurer();
- configurer.setApplicationContext(appContext);
- configurer.afterPropertiesSet();
-
- return configurer;
}
- static SchemaInspectionReportAssert assertThatReport(SchemaMappingInspector.Report actual) {
- return new SchemaInspectionReportAssert(actual);
- }
- static class SchemaInspectionReportAssert extends AbstractAssert {
+ private static class SchemaInspectionReportAssert
+ extends AbstractAssert {
public SchemaInspectionReportAssert(SchemaMappingInspector.Report actual) {
super(actual, SchemaInspectionReportAssert.class);
@@ -521,54 +631,47 @@ class SchemaMappingInspectorTests {
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());
+ if (!this.actual.unmappedFields().isEmpty()) {
+ failWithMessage("Report contains missing fields for %s", this.actual.unmappedFields().keySet());
}
}
- public SchemaInspectionReportAssert hasSize(int size) {
+ public SchemaInspectionReportAssert hasUnmappedFieldCount(int expected) {
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));
+ Integer actual = this.actual.unmappedFields().values().stream().map(List::size).reduce(0, Integer::sum);
+ if (actual != expected) {
+ failWithMessage("Expected %s unmapped fields, found %s.", expected, this.actual.unmappedFields());
}
return this;
}
- public SchemaInspectionReportAssert missesOperations(String operationType, String... names) {
+ public SchemaInspectionReportAssert hasSkippedTypeCount(int expected) {
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);
+ if (this.actual.skippedTypes().size() != expected) {
+ failWithMessage("Expected %s skipped types, found %d.", expected, this.actual.skippedTypes());
}
return this;
}
- public SchemaInspectionReportAssert missesFields(String typeName, String... fieldNames) {
+ public SchemaInspectionReportAssert containsUnmappedFields(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);
- }
+ List expected = Arrays.asList(fieldNames);
+ List actual = this.actual.unmappedFields().get(typeName);
+ if (actual == null || !actual.containsAll(expected)) {
+ failWithMessage("Expected unmapped fields for %s: %s, found %s", typeName, expected, actual);
}
- else {
- failWithMessage("No missing field for %s", typeName);
+ return this;
+ }
+
+ public SchemaInspectionReportAssert containsSkippedTypes(String... typeNames) {
+ isNotNull();
+ List expected = Arrays.asList(typeNames);
+ Set actual = this.actual.skippedTypes();
+ if (!actual.containsAll(expected)) {
+ failWithMessage("Expected skipped types: %s, found %s", expected, actual);
}
return this;
}
}
+
}
\ No newline at end of file