Skip to content

Commit 267b514

Browse files
committed
Schema inspection support for unmapped arguments
Closes: gh-740
1 parent dcaa151 commit 267b514

File tree

6 files changed

+159
-55
lines changed

6 files changed

+159
-55
lines changed

spring-graphql-docs/modules/ROOT/pages/request-execution.adoc

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -229,24 +229,25 @@ matching Java object property, will always be `null`.
229229
GraphQL Java does not perform checks to ensure every schema field is covered, and that
230230
can result in gaps that might not be discovered depending on test coverage. At runtime
231231
you may get a "silent" `null`, or an error if the field is not nullable. As a lower level
232-
library, GraphQL Java simply does not know enough about `DataFetcher` implementations and
233-
their return types, and therefore can't compare schema type structure against Java object
234-
structure.
232+
library, GraphQL Java simply does not have enough information about `DataFetcher`
233+
implementations to know their return types or what arguments they depend on, and as a result
234+
cannot perform such verifications.
235235

236236
Spring for GraphQL defines the `SelfDescribingDataFetcher` interface to allow a
237-
`DataFetcher` to expose return type information. All Spring `DataFetcher` implementations
237+
`DataFetcher` to expose information about itself. All Spring `DataFetcher` implementations
238238
implement this interface. That includes those for xref:controllers.adoc[Annotated Controllers], and those for
239-
xref:data.adoc#data.querydsl[Querydsl] and xref:data.adoc#data.querybyexample[Query by Example] Spring Data repositories. For annotated
240-
controllers, the return type is derived from the declared return type on a
241-
`@SchemaMapping` method.
239+
xref:data.adoc#data.querydsl[Querydsl] and
240+
xref:data.adoc#data.querybyexample[Query by Example] Spring Data repositories.
241+
For annotated controllers, the return type is derived from the declared return type on
242+
a `@SchemaMapping` method, while arguments are dervied from `@Argument` method parameters.
242243

243-
On startup, Spring for GraphQL can inspect schema fields, `DataFetcher` registrations,
244-
and the properties of Java objects returned from `DataFetcher` implementations to check
245-
if all schema fields are covered either by an explicitly registered `DataFetcher`, or
246-
a matching Java object property. The inspection also performs a reverse check looking for
247-
`DataFetcher` registrations against schema fields that don't exist.
244+
Spring for GraphQL can perform an inspection on startup to ensure the following:
248245

249-
To enable inspection of schema mappings:
246+
- Schema fields have a `DataFetcher` registration or a corresponding Java property.
247+
- `DataFetcher` registrations refer to a schema field that does exist.
248+
- `DataFetcher` refers to schema arguments that exist.
249+
250+
You can enable the inspection and take an appropriate action as follows:
250251

251252
[source,java,indent=0,subs="verbatim,quotes"]
252253
----
@@ -264,14 +265,16 @@ Below is an example report:
264265
GraphQL schema inspection:
265266
Unmapped fields: {Book=[title], Author[firstName, lastName]} // <1>
266267
Unmapped registrations: {Book.reviews=BookController#reviews[1 args]} <2>
267-
Skipped types: [BookOrAuthor] // <3>
268+
Unmapped arguments: {BookController#bookSearch[1 args]=[myAuthor]} // <3>
269+
Skipped types: [BookOrAuthor] // <4>
268270
----
269271

270-
<1> List of schema fields and their source types that are not mapped
271-
<2> List of `DataFetcher` registrations on fields that don't exist
272-
<3> List of schema types that are skipped, as explained next
272+
<1> Coordinates of schema fields that are not covered
273+
<2> ``DataFetcher`` registered mapped to fields that don't exist
274+
<3> `DataFetcher` arguments that don't exist
275+
<4> Schema types that have been skipped (explained next)
273276

274-
There are limits to what schema field inspection can do, in particular when there is
277+
There are limits to what schema inspection can do, in particular when there is
275278
insufficient Java type information. This is the case if an annotated controller method is
276279
declared to return `java.lang.Object`, or if the return type has an unspecified generic
277280
parameter such as `List<?>`, or if the `DataFetcher` does not implement

spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import java.util.concurrent.Executor;
3030
import java.util.function.BiConsumer;
3131
import java.util.function.Consumer;
32+
import java.util.function.Predicate;
33+
import java.util.stream.Collectors;
3234

3335
import graphql.execution.DataFetcherResult;
3436
import graphql.schema.DataFetcher;
@@ -45,15 +47,19 @@
4547
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
4648
import org.springframework.context.ApplicationContext;
4749
import org.springframework.context.expression.BeanFactoryResolver;
50+
import org.springframework.core.DefaultParameterNameDiscoverer;
4851
import org.springframework.core.KotlinDetector;
4952
import org.springframework.core.MethodParameter;
53+
import org.springframework.core.ParameterNameDiscoverer;
5054
import org.springframework.core.ResolvableType;
5155
import org.springframework.core.annotation.AnnotatedElementUtils;
5256
import org.springframework.data.domain.ScrollPosition;
57+
import org.springframework.graphql.data.ArgumentValue;
5358
import org.springframework.graphql.data.GraphQlArgumentBinder;
5459
import org.springframework.graphql.data.method.HandlerMethod;
5560
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
5661
import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite;
62+
import org.springframework.graphql.data.method.annotation.Argument;
5763
import org.springframework.graphql.data.method.annotation.BatchMapping;
5864
import org.springframework.graphql.data.method.annotation.SchemaMapping;
5965
import org.springframework.graphql.data.pagination.CursorStrategy;
@@ -380,6 +386,8 @@ public void configure(GraphQLCodeRegistry.Builder codeRegistryBuilder) {
380386
*/
381387
static class SchemaMappingDataFetcher implements SelfDescribingDataFetcher<Object> {
382388

389+
private static final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
390+
383391
private final DataFetcherMappingInfo mappingInfo;
384392

385393
private final HandlerMethodArgumentResolverComposite argumentResolvers;
@@ -424,6 +432,20 @@ public ResolvableType getReturnType() {
424432
return ResolvableType.forMethodReturnType(this.mappingInfo.getHandlerMethod().getMethod());
425433
}
426434

435+
@Override
436+
public Map<String, ResolvableType> getArguments() {
437+
438+
Predicate<MethodParameter> argumentPredicate = p ->
439+
(p.getParameterAnnotation(Argument.class) != null || p.getParameterType() == ArgumentValue.class);
440+
441+
return Arrays.stream(this.mappingInfo.getHandlerMethod().getMethodParameters())
442+
.filter(argumentPredicate)
443+
.peek(p -> p.initParameterNameDiscovery(parameterNameDiscoverer))
444+
.collect(Collectors.toMap(
445+
ArgumentMethodArgumentResolver::getArgumentName,
446+
ResolvableType::forMethodParameter));
447+
}
448+
427449
/**
428450
* Return the {@link HandlerMethod} used to fetch data.
429451
*/

spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaMappingInspector.java

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
import org.springframework.core.ResolvableType;
5050
import org.springframework.lang.Nullable;
5151
import org.springframework.util.Assert;
52+
import org.springframework.util.CollectionUtils;
5253
import org.springframework.util.LinkedMultiValueMap;
5354
import org.springframework.util.MultiValueMap;
5455

@@ -135,13 +136,13 @@ private void checkSchemaFields() {
135136
}
136137

137138
/**
138-
* Check the given {@code GraphQLFieldsContainer} against {@code DataFetcher}
139-
* registrations, or Java properties of the given {@code ResolvableType}.
140-
* @param fieldContainer the GraphQL interface or object type to check
141-
* @param resolvableType the Java type to match against, or {@code null} if
142-
* not applicable such as for Query, Mutation, or Subscription
139+
* Check fields of the given {@code GraphQLFieldsContainer} to make sure there
140+
* is either a {@code DataFetcher} registration, or a corresponding property
141+
* in the given Java type, which may be {@code null} for the top-level types
142+
* Query, Mutation, and Subscription.
143143
*/
144-
private void checkFieldsContainer(GraphQLFieldsContainer fieldContainer, @Nullable ResolvableType resolvableType) {
144+
private void checkFieldsContainer(
145+
GraphQLFieldsContainer fieldContainer, @Nullable ResolvableType resolvableType) {
145146

146147
String typeName = fieldContainer.getName();
147148
Map<String, DataFetcher> dataFetcherMap = this.dataFetchers.getOrDefault(typeName, Collections.emptyMap());
@@ -159,16 +160,21 @@ else if (resolvableType == null || !hasProperty(resolvableType, fieldName)) {
159160
}
160161

161162
/**
162-
* Check the output {@link GraphQLType} of a field against the given DataFetcher return type.
163-
* @param parent the parent of the field
164-
* @param field the field to inspect
165-
* @param dataFetcher the registered DataFetcher
163+
* Perform the following:
164+
* <ul>
165+
* <li>Resolve the field type and the {@code DataFetcher} return type, and recurse
166+
* with {@link #checkFieldsContainer} if there is sufficient type information.
167+
* <li>Resolve the arguments the {@code DataFetcher} depends on and check they
168+
* are defined in the schema.
169+
* </ul>
166170
*/
167-
private void checkField(GraphQLFieldsContainer parent, GraphQLFieldDefinition field, DataFetcher<?> dataFetcher) {
171+
private void checkField(
172+
GraphQLFieldsContainer parent, GraphQLFieldDefinition field, DataFetcher<?> dataFetcher) {
168173

169174
ResolvableType resolvableType = ResolvableType.NONE;
170-
if (dataFetcher instanceof SelfDescribingDataFetcher<?> selfDescribingDataFetcher) {
171-
resolvableType = selfDescribingDataFetcher.getReturnType();
175+
if (dataFetcher instanceof SelfDescribingDataFetcher<?> selfDescribing) {
176+
resolvableType = selfDescribing.getReturnType();
177+
checkFieldArguments(field, selfDescribing);
172178
}
173179

174180
// Remove GraphQL type wrappers, and nest within Java generic types
@@ -210,6 +216,17 @@ else if (outputType instanceof GraphQLList listType) {
210216
checkFieldsContainer(fieldContainer, resolvableType);
211217
}
212218

219+
private void checkFieldArguments(GraphQLFieldDefinition field, SelfDescribingDataFetcher<?> dataFetcher) {
220+
221+
List<String> arguments = dataFetcher.getArguments().keySet().stream()
222+
.filter(name -> field.getArgument(name) == null)
223+
.toList();
224+
225+
if (!arguments.isEmpty()) {
226+
this.reportBuilder.unmappedArgument(dataFetcher, arguments);
227+
}
228+
}
229+
213230
private GraphQLType unwrapIfNonNull(GraphQLType type) {
214231
return (type instanceof GraphQLNonNull graphQLNonNull ? graphQLNonNull.getWrappedType() : type);
215232
}
@@ -347,6 +364,8 @@ private class ReportBuilder {
347364

348365
private final Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations = new LinkedHashMap<>();
349366

367+
private final MultiValueMap<DataFetcher<?>, String> unmappedArguments = new LinkedMultiValueMap<>();
368+
350369
private final List<SchemaReport.SkippedType> skippedTypes = new ArrayList<>();
351370

352371
public void unmappedField(FieldCoordinates coordinates) {
@@ -357,12 +376,17 @@ public void unmappedRegistration(FieldCoordinates coordinates, DataFetcher<?> da
357376
this.unmappedRegistrations.put(coordinates, dataFetcher);
358377
}
359378

379+
public void unmappedArgument(DataFetcher<?> dataFetcher, List<String> arguments) {
380+
this.unmappedArguments.put(dataFetcher, arguments);
381+
}
382+
360383
public void skippedType(GraphQLType type, FieldCoordinates coordinates) {
361384
this.skippedTypes.add(new DefaultSkippedType(type, coordinates));
362385
}
363386

364387
public SchemaReport build() {
365-
return new DefaultSchemaReport(this.unmappedFields, this.unmappedRegistrations, this.skippedTypes);
388+
return new DefaultSchemaReport(
389+
this.unmappedFields, this.unmappedRegistrations, this.unmappedArguments, this.skippedTypes);
366390
}
367391

368392
}
@@ -377,14 +401,17 @@ private class DefaultSchemaReport implements SchemaReport {
377401

378402
private final Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations;
379403

404+
private final MultiValueMap<DataFetcher<?>, String> unmappedArguments;
405+
380406
private final List<SchemaReport.SkippedType> skippedTypes;
381407

382408
public DefaultSchemaReport(
383409
List<FieldCoordinates> unmappedFields, Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations,
384-
List<SkippedType> skippedTypes) {
410+
MultiValueMap<DataFetcher<?>, String> unmappedArguments, List<SkippedType> skippedTypes) {
385411

386412
this.unmappedFields = Collections.unmodifiableList(unmappedFields);
387413
this.unmappedRegistrations = Collections.unmodifiableMap(unmappedRegistrations);
414+
this.unmappedArguments = CollectionUtils.unmodifiableMultiValueMap(unmappedArguments);
388415
this.skippedTypes = Collections.unmodifiableList(skippedTypes);
389416
}
390417

@@ -398,6 +425,11 @@ public Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations() {
398425
return this.unmappedRegistrations;
399426
}
400427

428+
@Override
429+
public MultiValueMap<DataFetcher<?>, String> unmappedArguments() {
430+
return this.unmappedArguments;
431+
}
432+
401433
@Override
402434
public List<SkippedType> skippedTypes() {
403435
return this.skippedTypes;
@@ -421,6 +453,7 @@ public String toString() {
421453
return "GraphQL schema inspection:\n" +
422454
"\tUnmapped fields: " + formatUnmappedFields() + "\n" +
423455
"\tUnmapped registrations: " + this.unmappedRegistrations + "\n" +
456+
"\tUnmapped arguments: " + this.unmappedArguments + "\n" +
424457
"\tSkipped types: " + this.skippedTypes;
425458
}
426459

spring-graphql/src/main/java/org/springframework/graphql/execution/SchemaReport.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2023 the original author or authors.
2+
* Copyright 2020-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -27,6 +27,7 @@
2727
import graphql.schema.GraphQLType;
2828

2929
import org.springframework.lang.Nullable;
30+
import org.springframework.util.MultiValueMap;
3031

3132
/**
3233
* Report produced as a result of inspecting schema mappings.
@@ -61,6 +62,13 @@ public interface SchemaReport {
6162
*/
6263
Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations();
6364

65+
/**
66+
* Return a map with {@link DataFetcher}s and the names of arguments they
67+
* depend on that don't exist.
68+
* @since 1.3
69+
*/
70+
MultiValueMap<DataFetcher<?>, String> unmappedArguments();
71+
6472
/**
6573
* Return types skipped during the inspection, either because the schema type
6674
* is not supported, e.g. union, or because there is insufficient Java type

spring-graphql/src/main/java/org/springframework/graphql/execution/SelfDescribingDataFetcher.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2020-2023 the original author or authors.
2+
* Copyright 2020-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -16,6 +16,9 @@
1616

1717
package org.springframework.graphql.execution;
1818

19+
import java.util.Collections;
20+
import java.util.Map;
21+
1922
import graphql.schema.DataFetcher;
2023

2124
import org.springframework.core.ResolvableType;
@@ -46,4 +49,13 @@ public interface SelfDescribingDataFetcher<T> extends DataFetcher<T> {
4649
*/
4750
ResolvableType getReturnType();
4851

52+
/**
53+
* Return a map with arguments that this {@link DataFetcher} looks up
54+
* along with the Java types they are mapped to.
55+
* @since 1.3
56+
*/
57+
default Map<String, ResolvableType> getArguments() {
58+
return Collections.emptyMap();
59+
}
60+
4961
}

0 commit comments

Comments
 (0)