Support for default TypeResolver

See gh-154
This commit is contained in:
Rossen Stoyanchev
2021-10-15 14:33:58 +01:00
parent 75184c55e6
commit acd060c50e
4 changed files with 446 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2002-2021 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.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;
import graphql.TypeResolutionEnvironment;
import graphql.schema.GraphQLObjectType;
import graphql.schema.GraphQLSchema;
import graphql.schema.TypeResolver;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
/**
* {@link TypeResolver} that tries to find a GraphQL Object type based on the
* class name of an Object. If necessary, it walks up the base class and
* interface hierarchy to find a match.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class ClassNameTypeResolver implements TypeResolver {
private Function<Class<?>, String> classNameExtractor = Class::getSimpleName;
private final Map<Class<?>, String> mappings = new LinkedHashMap<>();
/**
* Customize how the name of a class, or base class/interface, is determined.
* An application can use this to adapt to a common naming convention, e.g.
* removing "Impl" as a suffix, or "Base" as prefix, and so on.
* <p>By default, this is {@link Class#getSimpleName()}.
* @param classNameExtractor the function to use
*/
public void setClassNameExtractor(Function<Class<?>, String> classNameExtractor) {
Assert.notNull(classNameExtractor, "'classNameExtractor' is required");
this.classNameExtractor = classNameExtractor;
}
/**
* Add a mapping from a Java {@link Class} to a GraphQL Object type. The
* given class can be a base class or an interface, in which case the mapping
* applies to sub-classes too.
* @param clazz the Java class to map
* @param graphQlTypeName the matching GraphQL object type
*/
public void addMapping(Class<?> clazz, String graphQlTypeName) {
this.mappings.put(clazz, graphQlTypeName);
}
@Override
public GraphQLObjectType getType(TypeResolutionEnvironment environment) {
Class<?> clazz = environment.getObject().getClass();
GraphQLObjectType type = getTypeForClass(clazz, environment.getSchema());
Assert.state(type != null, "No GraphQL Object type for class: " + clazz.getName());
return type;
}
@Nullable
private GraphQLObjectType getTypeForClass(Class<?> clazz, GraphQLSchema schema) {
if (clazz.getName().startsWith("java.")) {
return null;
}
String name = getMapping(clazz);
if (name != null) {
GraphQLObjectType objectType = schema.getObjectType(name);
if (objectType == null) {
throw new IllegalStateException(
"Invalid mapping for " + clazz.getName() + ". " +
"No GraphQL Object type with name '" + name + "'.");
}
return objectType;
}
name = this.classNameExtractor.apply(clazz);
if (schema.containsType(name)) {
return schema.getObjectType(name);
}
for (Class<?> interfaceType : clazz.getInterfaces()) {
GraphQLObjectType objectType = getTypeForClass(interfaceType, schema);
if (objectType != null) {
return objectType;
}
}
Class<?> superclass = clazz.getSuperclass();
if (superclass != Object.class && superclass != null) {
return getTypeForClass(superclass, schema);
}
return null;
}
@Nullable
private String getMapping(Class<?> targetClass) {
for (Map.Entry<Class<?>, String> entry : this.mappings.entrySet()) {
if (entry.getKey().isAssignableFrom(targetClass)) {
return entry.getValue();
}
}
return null;
}
}

View File

@@ -29,10 +29,13 @@ import java.util.function.Consumer;
import graphql.GraphQL;
import graphql.execution.instrumentation.ChainedInstrumentation;
import graphql.execution.instrumentation.Instrumentation;
import graphql.language.InterfaceTypeDefinition;
import graphql.language.UnionTypeDefinition;
import graphql.schema.GraphQLCodeRegistry;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLTypeVisitor;
import graphql.schema.SchemaTraverser;
import graphql.schema.TypeResolver;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
@@ -55,6 +58,9 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
private final List<RuntimeWiringConfigurer> runtimeWiringConfigurers = new ArrayList<>();
@Nullable
private TypeResolver defaultTypeResolver;
private final List<DataFetcherExceptionResolver> exceptionResolvers = new ArrayList<>();
private final List<GraphQLTypeVisitor> typeVisitors = new ArrayList<>();
@@ -80,6 +86,12 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
return this;
}
@Override
public GraphQlSource.Builder defaultTypeResolver(TypeResolver typeResolver) {
this.defaultTypeResolver = typeResolver;
return this;
}
@Override
public GraphQlSource.Builder exceptionResolvers(List<DataFetcherExceptionResolver> resolvers) {
this.exceptionResolvers.addAll(resolvers);
@@ -122,9 +134,12 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
this.runtimeWiringConfigurers.forEach(configurer -> configurer.configure(runtimeWiringBuilder));
RuntimeWiring runtimeWiring = runtimeWiringBuilder.build();
registerDefaultTypeResolver(registry, runtimeWiring);
GraphQLSchema schema = (this.schemaFactory != null ?
this.schemaFactory.apply(registry, runtimeWiring) :
new SchemaGenerator().makeExecutableSchema(registry, runtimeWiring));
schema = applyTypeVisitors(schema);
GraphQL.Builder builder = GraphQL.newGraphQL(schema);
@@ -139,6 +154,14 @@ class DefaultGraphQlSourceBuilder implements GraphQlSource.Builder {
return new CachedGraphQlSource(graphQl, schema);
}
private void registerDefaultTypeResolver(TypeDefinitionRegistry registry, RuntimeWiring runtimeWiring) {
TypeResolver typeResolver =
(this.defaultTypeResolver != null ? this.defaultTypeResolver : new ClassNameTypeResolver());
registry.types().values().stream()
.filter(def -> def instanceof UnionTypeDefinition || def instanceof InterfaceTypeDefinition)
.forEach(def -> runtimeWiring.getTypeResolvers().putIfAbsent(def.getName(), typeResolver));
}
private TypeDefinitionRegistry parseSchemaResource(Resource schemaResource) {
Assert.notNull(schemaResource, "'schemaResource' not provided");
Assert.isTrue(schemaResource.exists(), "'schemaResource' does not exist");

View File

@@ -25,6 +25,7 @@ import graphql.GraphQL;
import graphql.execution.instrumentation.Instrumentation;
import graphql.schema.GraphQLSchema;
import graphql.schema.GraphQLTypeVisitor;
import graphql.schema.TypeResolver;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.TypeDefinitionRegistry;
@@ -89,6 +90,25 @@ public interface GraphQlSource {
*/
Builder configureRuntimeWiring(RuntimeWiringConfigurer configurer);
/**
* Configure the default {@link TypeResolver} to use for GraphQL Interface
* and Union types that don't already have such a registration after all
* {@link #configureRuntimeWiring(RuntimeWiringConfigurer) RuntimeWiringConfigurer's}
* have been applied.
* <p>A GraphQL {@code TypeResolver} is used to determine the GraphQL Object
* type of values returned from DataFetcher's of GraphQL Interface or
* Union fields.
* <p>By default this is set to {@link ClassNameTypeResolver}, which
* tries to match the simple class name of the Object value to a GraphQL
* Object type, and it also tries the same for supertypes (base classes
* and interfaces). See the Javadoc of {@code ClassNameTypeResolver} for
* further ways to customize matching a Java class to a GraphQL Object type.
* @param typeResolver the {@code TypeResolver} to use
* @return the current builder
* @see ClassNameTypeResolver
*/
Builder defaultTypeResolver(TypeResolver typeResolver);
/**
* Add {@link DataFetcherExceptionResolver}'s to use for resolving exceptions from
* {@link graphql.schema.DataFetcher}'s.

View File

@@ -0,0 +1,279 @@
/*
* Copyright 2002-2021 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.List;
import java.util.Map;
import graphql.ExecutionResult;
import org.junit.jupiter.api.Test;
import org.springframework.graphql.GraphQlTestUtils;
import org.springframework.graphql.RequestInput;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for interface and union type resolution via {@link ClassNameTypeResolver}.
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class ClassNameTypeResolverTests {
private static final List<Animal> animalList = Arrays.asList(new Dog(), new Penguin());
private static final List<?> animalAndPlantList = Arrays.asList(new GrayWolf(), new GiantRedwood());
private static final String schema = "" +
"type Query {" +
" animals: [Animal!]!," +
" sightings: [Sighting!]!" +
"}" +
"interface Animal {" +
" name: String!" +
"}" +
"type Bird implements Animal {" +
" name: String!" +
" flightless: Boolean!" +
"}" +
"type Mammal implements Animal {" +
" name: String!" +
" herbivore: Boolean!" +
"}" +
"type Plant {" +
" family: String!" +
"}" +
"type Vegetable {" +
" family: String!" +
"}" +
"union Sighting = Bird | Mammal | Plant | Vegetable ";
@Test
void typeResolutionViaSuperHierarchy() {
GraphQlSource graphQlSource =
GraphQlTestUtils.initGraphQlSource(schema, "Query", "animals", env -> animalList).build();
String query = "" +
"query Animals {" +
" animals {" +
" __typename" +
" name" +
" ... on Bird {" +
" flightless" +
" }" +
" ... on Mammal {" +
" herbivore" +
" }" +
" }" +
"}";
ExecutionResult result = new ExecutionGraphQlService(graphQlSource)
.execute(new RequestInput(query, null, null))
.block();
List<Map<String, Object>> actualAnimals = GraphQlTestUtils.checkErrorsAndGetData(result, "animals");
for (int i = 0; i < animalList.size(); i++) {
Map<String, Object> actualAnimal = actualAnimals.get(i);
Animal animal = animalList.get(i);
assertThat(actualAnimal.get("name")).isEqualTo(animal.getName());
if (animal instanceof Bird) {
assertThat(actualAnimal.get("flightless")).isEqualTo(((Bird) animal).isFlightless());
}
else if (animal instanceof Mammal) {
assertThat(actualAnimal.get("herbivore")).isEqualTo(((Mammal) animal).isHerbivore());
}
else {
throw new IllegalStateException();
}
}
}
@Test
void typeResolutionViaMapping() {
ClassNameTypeResolver typeResolver = new ClassNameTypeResolver();
typeResolver.addMapping(Tree.class, "Plant");
GraphQlSource graphQlSource =
GraphQlTestUtils.initGraphQlSource(schema, "Query", "sightings", env -> animalAndPlantList)
.defaultTypeResolver(typeResolver)
.build();
String query = "" +
"query Sightings {" +
" sightings {" +
" __typename" +
" ... on Bird {" +
" name" +
" }" +
" ... on Mammal {" +
" name" +
" }" +
" ... on Plant {" +
" family" +
" }" +
" }" +
"}";
ExecutionResult result = new ExecutionGraphQlService(graphQlSource)
.execute(new RequestInput(query, null, null))
.block();
List<Map<String, Object>> actualSightings = GraphQlTestUtils.checkErrorsAndGetData(result, "sightings");
for (int i = 0; i < animalAndPlantList.size(); i++) {
Map<String, Object> actualSighting = actualSightings.get(i);
Object sighting = animalAndPlantList.get(i);
if (sighting instanceof Animal) {
assertThat(actualSighting.get("name")).isEqualTo(((Animal) sighting).getName());
}
else if (sighting instanceof Tree) {
assertThat(actualSighting.get("family")).isEqualTo(((Tree) sighting).getFamily());
}
else {
throw new IllegalStateException();
}
}
}
interface Animal {
String getName();
}
interface Bird extends Animal {
boolean isFlightless();
}
interface Mammal extends Animal {
boolean isHerbivore();
}
static class BaseAnimal implements Animal {
final String name;
BaseAnimal(String name) {
this.name = name;
}
@Override
public String getName() {
return this.name;
}
}
static class BaseBird extends BaseAnimal implements Bird {
private final boolean flightless;
BaseBird(String name, boolean flightless) {
super(name);
this.flightless = flightless;
}
@Override
public boolean isFlightless() {
return this.flightless;
}
}
static class BaseMammal extends BaseAnimal implements Mammal {
private final boolean isHerbivore;
BaseMammal(String name, boolean isHerbivore) {
super(name);
this.isHerbivore = isHerbivore;
}
@Override
public boolean isHerbivore() {
return this.isHerbivore;
}
}
static class Penguin extends BaseBird {
Penguin() {
super("Penguin", true);
}
}
static class Dog extends BaseMammal {
Dog() {
super("Dog", false);
}
}
static class GrayWolf extends BaseMammal {
GrayWolf() {
super("Gray Wolf", false);
}
}
static class Tree {
private final String family;
Tree(String family) {
this.family = family;
}
public String getFamily() {
return family;
}
}
static class GiantRedwood extends Tree {
GiantRedwood() {
super("Redwood");
}
}
}