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