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
This commit is contained in:
Brian Clozel
2023-03-09 17:57:56 +01:00
parent 0f9581d99c
commit 998d1881cc
7 changed files with 930 additions and 9 deletions

View File

@@ -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}.
* <p>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<T> extends DataFetcher<T> {
/**
* The type declared by this {@link DataFetcher}.
* <p>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();
}

View File

@@ -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<Object> {
static class SchemaMappingDataFetcher implements TypedDataFetcher<Object> {
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());
}
}

View File

@@ -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<T> {
}
private static class SingleEntityFetcher<T, R> extends QueryByExampleDataFetcher<T> implements DataFetcher<R> {
private static class SingleEntityFetcher<T, R> extends QueryByExampleDataFetcher<T> implements TypedDataFetcher<R> {
private final QueryByExampleExecutor<T> executor;
@@ -480,10 +481,14 @@ public abstract class QueryByExampleDataFetcher<T> {
}).orElse(null);
}
@Override
public ResolvableType getDeclaredType() {
return ResolvableType.forClass(this.resultType);
}
}
private static class ManyEntityFetcher<T, R> extends QueryByExampleDataFetcher<T> implements DataFetcher<Iterable<R>> {
private static class ManyEntityFetcher<T, R> extends QueryByExampleDataFetcher<T> implements TypedDataFetcher<Iterable<R>> {
private final QueryByExampleExecutor<T> executor;
@@ -522,10 +527,15 @@ public abstract class QueryByExampleDataFetcher<T> {
});
}
@Override
public ResolvableType getDeclaredType() {
return ResolvableType.forClassWithGenerics(Iterable.class, this.resultType);
}
}
private static class ReactiveSingleEntityFetcher<T, R> extends QueryByExampleDataFetcher<T> implements DataFetcher<Mono<R>> {
private static class ReactiveSingleEntityFetcher<T, R> extends QueryByExampleDataFetcher<T> implements TypedDataFetcher<Mono<R>> {
private final ReactiveQueryByExampleExecutor<T> executor;
@@ -564,10 +574,15 @@ public abstract class QueryByExampleDataFetcher<T> {
});
}
@Override
public ResolvableType getDeclaredType() {
return ResolvableType.forClassWithGenerics(Mono.class, this.resultType);
}
}
private static class ReactiveManyEntityFetcher<T, R> extends QueryByExampleDataFetcher<T> implements DataFetcher<Flux<R>> {
private static class ReactiveManyEntityFetcher<T, R> extends QueryByExampleDataFetcher<T> implements TypedDataFetcher<Flux<R>> {
private final ReactiveQueryByExampleExecutor<T> executor;
@@ -606,6 +621,11 @@ public abstract class QueryByExampleDataFetcher<T> {
});
}
@Override
public ResolvableType getDeclaredType() {
return ResolvableType.forClassWithGenerics(Flux.class, this.resultType);
}
}
}

View File

@@ -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<T> {
}
private static class SingleEntityFetcher<T, R> extends QuerydslDataFetcher<T> implements DataFetcher<R> {
private static class SingleEntityFetcher<T, R> extends QuerydslDataFetcher<T> implements TypedDataFetcher<R> {
private final QuerydslPredicateExecutor<T> executor;
@@ -581,10 +583,14 @@ public abstract class QuerydslDataFetcher<T> {
}).orElse(null);
}
@Override
public ResolvableType getDeclaredType() {
return ResolvableType.forClass(this.resultType);
}
}
private static class ManyEntityFetcher<T, R> extends QuerydslDataFetcher<T> implements DataFetcher<Iterable<R>> {
private static class ManyEntityFetcher<T, R> extends QuerydslDataFetcher<T> implements TypedDataFetcher<Iterable<R>> {
private final QuerydslPredicateExecutor<T> executor;
@@ -625,10 +631,15 @@ public abstract class QuerydslDataFetcher<T> {
});
}
@Override
public ResolvableType getDeclaredType() {
return ResolvableType.forClassWithGenerics(Iterable.class, this.resultType);
}
}
private static class ReactiveSingleEntityFetcher<T, R> extends QuerydslDataFetcher<T> implements DataFetcher<Mono<R>> {
private static class ReactiveSingleEntityFetcher<T, R> extends QuerydslDataFetcher<T> implements TypedDataFetcher<Mono<R>> {
private final ReactiveQuerydslPredicateExecutor<T> executor;
@@ -670,10 +681,15 @@ public abstract class QuerydslDataFetcher<T> {
});
}
@Override
public ResolvableType getDeclaredType() {
return ResolvableType.forClassWithGenerics(Mono.class, this.resultType);
}
}
private static class ReactiveManyEntityFetcher<T, R> extends QuerydslDataFetcher<T> implements DataFetcher<Flux<R>> {
private static class ReactiveManyEntityFetcher<T, R> extends QuerydslDataFetcher<T> implements TypedDataFetcher<Flux<R>> {
private final ReactiveQuerydslPredicateExecutor<T> executor;
@@ -715,6 +731,11 @@ public abstract class QuerydslDataFetcher<T> {
});
}
@Override
public ResolvableType getDeclaredType() {
return ResolvableType.forClassWithGenerics(Flux.class, this.resultType);
}
}
}

View File

@@ -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));

View File

@@ -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.
* <p>This inspection utility will report to developers:
* <ul>
* <li>{@code Query}, {@code Mutation} and {@code Subscription} fields that have no corresponding {@link DataFetcher} registered
* <li>Fields in other schema types that have no property on the relevant Java type, or no DataFetcher registered
* </ul>
* <p>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<String> seenTypes = new HashSet<>();
SchemaInspection(TypeDefinitionRegistry typeDefinitionRegistry, RuntimeWiring runtimeWiring) {
this.typeDefinitionRegistry = typeDefinitionRegistry;
this.runtimeWiring = runtimeWiring;
}
@SuppressWarnings("rawtypes")
void inspectOperation(String operationName, ReportBuilder report) {
Map<String, DataFetcher> 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<String, DataFetcher> 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<String, DataFetcher> 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<ObjectTypeExtensionDefinition> extensionsConsumer) {
List<ObjectTypeExtensionDefinition> 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<String, String> missingOperations, MultiValueMap<String, String> 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<String, String> missingOperations = new LinkedMultiValueMap<>();
private final MultiValueMap<String, String> 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);
}
}
}

View File

@@ -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<Book> 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<Book> 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<TeamMember> members(Team team) {
return List.of();
}
@SchemaMapping
public Team team(TeamMember teamMember) {
return null;
}
}
record Team(String name, List<TeamMember> 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<SchemaInspectionReportAssert, SchemaInspector.Report> {
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<String> expectedOperations = Arrays.asList(names);
List<String> 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<String> expectedFields = Arrays.asList(fieldNames);
List<String> 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;
}
}
}