Skip to content

Commit 7db96fd

Browse files
committed
Enhance SchemaReport API
See gh-672
1 parent 7dd4ac0 commit 7db96fd

File tree

5 files changed

+309
-148
lines changed

5 files changed

+309
-148
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ protected GraphQLSchema initGraphQlSchema() {
138138

139139
configureGraphQl(builder -> {
140140
GraphQLSchema schema = builder.build().getGraphQLSchema();
141-
SchemaMappingReport report = SchemaMappingInspector.inspect(schema, runtimeWiring);
141+
SchemaReport report = SchemaMappingInspector.inspect(schema, runtimeWiring);
142142
logger.info(report);
143143
});
144144

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

Lines changed: 161 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,13 @@
1616

1717
package org.springframework.graphql.execution;
1818

19+
import java.util.ArrayList;
20+
import java.util.Collections;
1921
import java.util.HashSet;
2022
import java.util.LinkedHashMap;
21-
import java.util.LinkedHashSet;
23+
import java.util.List;
2224
import java.util.Map;
2325
import java.util.Set;
24-
import java.util.function.Supplier;
2526

2627
import graphql.schema.DataFetcher;
2728
import graphql.schema.FieldCoordinates;
@@ -52,14 +53,10 @@
5253

5354
/**
5455
* Declares an {@link #inspect(GraphQLSchema, RuntimeWiring)} method that checks
55-
* if schema fields are covered either by a {@link DataFetcher} registration,
56-
* or match a Java object property. Fields that have neither are reported as
57-
* "unmapped" in the resulting {@link SchemaMappingReport}. The inspection also
58-
* performs a reverse check for {@code DataFetcher} registrations against schema
59-
* fields that don't exist.
56+
* if schema mappings.
6057
*
61-
* <p>The schema field inspection depends on {@code DataFetcher}s to be
62-
* {@link SelfDescribingDataFetcher} to be able to compare schema type and Java
58+
* <p>Schema mapping checks depend on {@code DataFetcher}s to be
59+
* {@link SelfDescribingDataFetcher} in order to compare schema type and Java
6360
* object type structure. If a {@code DataFetcher} does not implement this
6461
* interface, then the Java type remains unknown, and the field type is reported
6562
* as "skipped".
@@ -95,14 +92,10 @@ final class SchemaMappingInspector {
9592

9693
private final ReactiveAdapterRegistry reactiveAdapterRegistry = ReactiveAdapterRegistry.getSharedInstance();
9794

98-
private final MultiValueMap<String, String> unmappedFields = new LinkedMultiValueMap<>();
99-
100-
private final Map<FieldCoordinates, DataFetcher<?>> unmappedDataFetchers = new LinkedHashMap<>();
101-
102-
private final Set<String> skippedTypes = new LinkedHashSet<>();
95+
private final ReportBuilder reportBuilder = new ReportBuilder();
10396

10497
@Nullable
105-
private SchemaMappingReport report;
98+
private SchemaReport report;
10699

107100

108101
private SchemaMappingInspector(GraphQLSchema schema, RuntimeWiring runtimeWiring) {
@@ -114,20 +107,19 @@ private SchemaMappingInspector(GraphQLSchema schema, RuntimeWiring runtimeWiring
114107

115108

116109
/**
117-
* Perform an inspection and create a {@link SchemaMappingReport}.
110+
* Perform an inspection and create a {@link SchemaReport}.
118111
* The inspection is one once only, during the first call to this method.
119112
*/
120-
public SchemaMappingReport getOrCreateReport() {
113+
public SchemaReport getOrCreateReport() {
121114
if (this.report == null) {
122-
checkSchema();
115+
checkSchemaFields();
123116
checkDataFetcherRegistrations();
124-
this.report = new SchemaMappingReport(
125-
this.unmappedFields, this.unmappedDataFetchers, this.skippedTypes);
117+
this.report = this.reportBuilder.build();
126118
}
127119
return this.report;
128120
}
129121

130-
private void checkSchema() {
122+
private void checkSchemaFields() {
131123

132124
checkFieldsContainer(this.schema.getQueryType(), null);
133125

@@ -143,52 +135,50 @@ private void checkSchema() {
143135
/**
144136
* Check the given {@code GraphQLFieldsContainer} against {@code DataFetcher}
145137
* registrations, or Java properties of the given {@code ResolvableType}.
146-
* @param fields the GraphQL interface or object type to check
138+
* @param fieldContainer the GraphQL interface or object type to check
147139
* @param resolvableType the Java type to match against, or {@code null} if
148140
* not applicable such as for Query, Mutation, or Subscription
149141
*/
150142
@SuppressWarnings("rawtypes")
151-
private void checkFieldsContainer(GraphQLFieldsContainer fields, @Nullable ResolvableType resolvableType) {
143+
private void checkFieldsContainer(GraphQLFieldsContainer fieldContainer, @Nullable ResolvableType resolvableType) {
152144

153-
Map<String, DataFetcher> dataFetcherMap = this.runtimeWiring.getDataFetcherForType(fields.getName());
145+
String typeName = fieldContainer.getName();
146+
Map<String, DataFetcher> dataFetcherMap = this.runtimeWiring.getDataFetcherForType(typeName);
154147

155-
for (GraphQLFieldDefinition field : fields.getFieldDefinitions()) {
148+
for (GraphQLFieldDefinition field : fieldContainer.getFieldDefinitions()) {
156149
String fieldName = field.getName();
157-
if (dataFetcherMap.containsKey(fieldName)) {
158-
DataFetcher<?> fetcher = dataFetcherMap.get(fieldName);
159-
if (fetcher instanceof SelfDescribingDataFetcher<?> selfDescribingDataFetcher) {
160-
checkFieldType(
161-
field.getType(), selfDescribingDataFetcher.getReturnType(),
162-
(fields == this.schema.getSubscriptionType()));
163-
}
164-
else if (isNotScalarOrEnumType(field.getType())) {
165-
addSkippedType(field.getType(), () ->
166-
fetcher.getClass().getName() + " does not implement SelfDescribingDataFetcher.");
167-
}
150+
DataFetcher<?> dataFetcher = dataFetcherMap.get(fieldName);
151+
if (dataFetcher != null) {
152+
checkField(fieldContainer, field, dataFetcher);
168153
}
169154
else if (resolvableType == null || !hasProperty(resolvableType, fieldName)) {
170-
this.unmappedFields.add(fields.getName(), fieldName);
155+
this.reportBuilder.unmappedField(FieldCoordinates.coordinates(typeName, fieldName));
171156
}
172157
}
173158
}
174159

175160
/**
176161
* Check the output {@link GraphQLType} of a field against the given DataFetcher return type.
177-
* @param outputType the field type to inspect
178-
* @param resolvableType the expected Java return type
179-
* @param isSubscriptionField whether this is for a subscription field
162+
* @param parent the parent of the field
163+
* @param field the field to inspect
164+
* @param dataFetcher the registered DataFetcher
180165
*/
181-
private void checkFieldType(GraphQLType outputType, ResolvableType resolvableType, boolean isSubscriptionField) {
166+
private void checkField(GraphQLFieldsContainer parent, GraphQLFieldDefinition field, DataFetcher<?> dataFetcher) {
167+
168+
ResolvableType resolvableType = ResolvableType.NONE;
169+
if (dataFetcher instanceof SelfDescribingDataFetcher<?> selfDescribingDataFetcher) {
170+
resolvableType = selfDescribingDataFetcher.getReturnType();
171+
}
182172

183173
// Remove GraphQL type wrappers, and nest within Java generic types
184-
outputType = unwrapIfNonNull(outputType);
174+
GraphQLType outputType = unwrapIfNonNull(field.getType());
185175
if (isPaginatedType(outputType)) {
186176
outputType = getPaginatedType((GraphQLObjectType) outputType);
187177
resolvableType = nestForConnection(resolvableType);
188178
}
189179
else if (outputType instanceof GraphQLList listType) {
190180
outputType = unwrapIfNonNull(listType.getWrappedType());
191-
resolvableType = nestForList(resolvableType, isSubscriptionField);
181+
resolvableType = nestForList(resolvableType, (parent == this.schema.getSubscriptionType()));
192182
}
193183
else {
194184
resolvableType = nestIfReactive(resolvableType);
@@ -202,15 +192,16 @@ else if (outputType instanceof GraphQLList listType) {
202192
// Can we inspect GraphQL type?
203193
if (!(outputType instanceof GraphQLFieldsContainer fieldContainer)) {
204194
if (isNotScalarOrEnumType(outputType)) {
205-
String schemaTypeName = outputType.getClass().getSimpleName();
206-
addSkippedType(outputType, () -> "inspection does not support " + schemaTypeName + ".");
195+
FieldCoordinates coordinates = FieldCoordinates.coordinates(parent.getName(), field.getName());
196+
addSkippedType(outputType, coordinates, "Unsupported schema type");
207197
}
208198
return;
209199
}
210200

211201
// Can we inspect Java type?
212202
if (resolvableType.resolve(Object.class) == Object.class) {
213-
addSkippedType(outputType, () -> "inspection could not determine the Java object return type.");
203+
FieldCoordinates coordinates = FieldCoordinates.coordinates(parent.getName(), field.getName());
204+
addSkippedType(outputType, coordinates, "No Java type information");
214205
return;
215206
}
216207

@@ -236,6 +227,9 @@ private GraphQLType getPaginatedType(GraphQLObjectType type) {
236227
}
237228

238229
private ResolvableType nestForConnection(ResolvableType type) {
230+
if (type == ResolvableType.NONE) {
231+
return type;
232+
}
239233
type = nestIfReactive(type);
240234
if (logger.isDebugEnabled() && type.getGenerics().length != 1) {
241235
logger.debug("Expected Connection type to have a generic parameter: " + type);
@@ -256,6 +250,9 @@ private ResolvableType nestIfReactive(ResolvableType type) {
256250
}
257251

258252
private ResolvableType nestForList(ResolvableType type, boolean subscription) {
253+
if (type == ResolvableType.NONE) {
254+
return type;
255+
}
259256
ReactiveAdapter adapter = this.reactiveAdapterRegistry.getAdapter(type.resolve(Object.class));
260257
if (adapter != null) {
261258
if (logger.isDebugEnabled() && adapter.isNoValue()) {
@@ -266,7 +263,7 @@ private ResolvableType nestForList(ResolvableType type, boolean subscription) {
266263
return type;
267264
}
268265
}
269-
if (logger.isDebugEnabled() && (!type.isArray() && type.getGenerics().length != 1)) {
266+
if (logger.isDebugEnabled() && !type.isArray() && type.getGenerics().length != 1) {
270267
logger.debug("Expected List compatible type: " + type);
271268
}
272269
return type.getNested(2);
@@ -295,21 +292,21 @@ private boolean hasProperty(ResolvableType resolvableType, String fieldName) {
295292
}
296293
}
297294

298-
private void addSkippedType(GraphQLType type, Supplier<String> reason) {
295+
private void addSkippedType(GraphQLType type, FieldCoordinates coordinates, String reason) {
299296
String typeName = typeNameToString(type);
300-
this.skippedTypes.add(typeName);
297+
this.reportBuilder.skippedType(type, coordinates);
301298
if (logger.isDebugEnabled()) {
302-
logger.debug("Skipped '" + typeName + "': " + reason.get());
299+
logger.debug("Skipped '" + typeName + "': " + reason);
303300
}
304301
}
305302

306303
@SuppressWarnings("rawtypes")
307304
private void checkDataFetcherRegistrations() {
308305
this.runtimeWiring.getDataFetchers().forEach((typeName, registrations) ->
309-
registrations.forEach((fieldName, fetcher) -> {
306+
registrations.forEach((fieldName, dataFetcher) -> {
310307
FieldCoordinates coordinates = FieldCoordinates.coordinates(typeName, fieldName);
311308
if (this.schema.getFieldDefinition(coordinates) == null) {
312-
this.unmappedDataFetchers.put(coordinates, fetcher);
309+
this.reportBuilder.unmappedRegistration(coordinates, dataFetcher);
313310
}
314311
}));
315312
}
@@ -321,9 +318,120 @@ private void checkDataFetcherRegistrations() {
321318
* @param runtimeWiring for {@code DataFetcher} registrations
322319
* @return the created report
323320
*/
324-
public static SchemaMappingReport inspect(GraphQLSchema schema, RuntimeWiring runtimeWiring) {
321+
public static SchemaReport inspect(GraphQLSchema schema, RuntimeWiring runtimeWiring) {
325322
return new SchemaMappingInspector(schema, runtimeWiring).getOrCreateReport();
326323
}
327324

328325

326+
/**
327+
* Helps to build a {@link SchemaReport}.
328+
*/
329+
private class ReportBuilder {
330+
331+
private final List<FieldCoordinates> unmappedFields = new ArrayList<>();
332+
333+
private final Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations = new LinkedHashMap<>();
334+
335+
private final List<SchemaReport.SkippedType> skippedTypes = new ArrayList<>();
336+
337+
public void unmappedField(FieldCoordinates coordinates) {
338+
this.unmappedFields.add(coordinates);
339+
}
340+
341+
public void unmappedRegistration(FieldCoordinates coordinates, DataFetcher<?> dataFetcher) {
342+
this.unmappedRegistrations.put(coordinates, dataFetcher);
343+
}
344+
345+
public void skippedType(GraphQLType type, FieldCoordinates coordinates) {
346+
this.skippedTypes.add(new DefaultSkippedType(type, coordinates));
347+
}
348+
349+
public SchemaReport build() {
350+
return new DefaultSchemaReport(this.unmappedFields, this.unmappedRegistrations, this.skippedTypes);
351+
}
352+
353+
}
354+
355+
356+
/**
357+
* Default implementation of {@link SchemaReport}.
358+
*/
359+
private class DefaultSchemaReport implements SchemaReport {
360+
361+
private final List<FieldCoordinates> unmappedFields;
362+
363+
private final Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations;
364+
365+
private final List<SchemaReport.SkippedType> skippedTypes;
366+
367+
public DefaultSchemaReport(
368+
List<FieldCoordinates> unmappedFields, Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations,
369+
List<SkippedType> skippedTypes) {
370+
371+
this.unmappedFields = Collections.unmodifiableList(unmappedFields);
372+
this.unmappedRegistrations = Collections.unmodifiableMap(unmappedRegistrations);
373+
this.skippedTypes = Collections.unmodifiableList(skippedTypes);
374+
}
375+
376+
@Override
377+
public List<FieldCoordinates> unmappedFields() {
378+
return this.unmappedFields;
379+
}
380+
381+
@Override
382+
public Map<FieldCoordinates, DataFetcher<?>> unmappedRegistrations() {
383+
return this.unmappedRegistrations;
384+
}
385+
386+
@Override
387+
public List<SkippedType> skippedTypes() {
388+
return this.skippedTypes;
389+
}
390+
391+
@Override
392+
public GraphQLSchema schema() {
393+
return SchemaMappingInspector.this.schema;
394+
}
395+
396+
@Override
397+
@Nullable
398+
public DataFetcher<?> dataFetcher(FieldCoordinates coordinates) {
399+
return SchemaMappingInspector.this.runtimeWiring
400+
.getDataFetcherForType(coordinates.getTypeName())
401+
.get(coordinates.getFieldName());
402+
}
403+
404+
@Override
405+
public String toString() {
406+
return "GraphQL schema inspection:\n" +
407+
"\tUnmapped fields: " + formatUnmappedFields() + "\n" +
408+
"\tUnmapped registrations: " + this.unmappedRegistrations + "\n" +
409+
"\tSkipped types: " + this.skippedTypes;
410+
}
411+
412+
private String formatUnmappedFields() {
413+
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
414+
this.unmappedFields.forEach(coordinates -> {
415+
List<String> fields = map.computeIfAbsent(coordinates.getTypeName(), s -> new ArrayList<>());
416+
fields.add(coordinates.getFieldName());
417+
});
418+
return map.toString();
419+
}
420+
421+
}
422+
423+
424+
/**
425+
* Default implementation of a {@link SchemaReport.SkippedType}.
426+
*/
427+
private record DefaultSkippedType(
428+
GraphQLType type, FieldCoordinates fieldCoordinates) implements SchemaReport.SkippedType {
429+
430+
@Override
431+
public String toString() {
432+
return typeNameToString(this.type);
433+
}
434+
435+
}
436+
329437
}

0 commit comments

Comments
 (0)