From 998d1881cca4efc8f584237dca38139578bc2bb9 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 9 Mar 2023 17:57:56 +0100 Subject: [PATCH] Add schema inspection support on startup Prior to this commit, a Spring for GraphQL application could be started with a schema and an incomplete set of data fetchers, as the schema would describe: * Queries/Mutations/Subscriptions that are not backed by any `@Controller` method, any Spring Data repository nor any custom `DataFetcher` * type fields that are not backed by any Java Type property nor any registered `DataFetcher` This problem can be noticed at runtime when a request is sent to the API. The response can contain a `null` field where data was expected, or even a GraphQL error because the field was non nullable. This often happens during development time while developers are implementing the schema. This commit adds a new `SchemaInspector` type that visits the GraphQL schema during the startup phase and looks into the `RuntimeWiring` for registered `DataFetcher` instances. Because data fetchers can be simple lambdas and do not require to expose a concrete return type, this also introduces a new `TypedDataFetcher` interface that returns a `ResolvableType`. This type is only declared by the data fetcher implementation, but does not necessarily reflects the concrete type of the returned instances. This inspection is best effort and has known limitations, such as Union types (those will not be inspected). Because of those, the inspection will not fail the application startup. The `SchemaInspector` collects all missing fields into a report and its output is logged at startup at the INFO level. As a first step, the inspector is package private and is only used by the `DefaultSchemaResourceGraphQlSourceBuilder`. The inspection cannot be disabled nor customized. We can expand this feature in future releases as the team collects feedback from the community. Closes gh-386 --- .../graphql/data/TypedDataFetcher.java | 41 ++ .../AnnotatedControllerConfigurer.java | 9 +- .../data/query/QueryByExampleDataFetcher.java | 28 +- .../data/query/QuerydslDataFetcher.java | 29 +- ...ultSchemaResourceGraphQlSourceBuilder.java | 6 + .../graphql/execution/SchemaInspector.java | 270 +++++++++ .../execution/SchemaInspectorTests.java | 556 ++++++++++++++++++ 7 files changed, 930 insertions(+), 9 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/TypedDataFetcher.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaInspector.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaInspectorTests.java 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