From acd060c50e18067fc24c08a1bae25e999ffb8d2d Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Fri, 15 Oct 2021 14:33:58 +0100 Subject: [PATCH] Support for default TypeResolver See gh-154 --- .../execution/ClassNameTypeResolver.java | 124 ++++++++ .../DefaultGraphQlSourceBuilder.java | 23 ++ .../graphql/execution/GraphQlSource.java | 20 ++ .../execution/ClassNameTypeResolverTests.java | 279 ++++++++++++++++++ 4 files changed, 446 insertions(+) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/execution/ClassNameTypeResolver.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/execution/ClassNameTypeResolverTests.java diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/ClassNameTypeResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/ClassNameTypeResolver.java new file mode 100644 index 00000000..73c73295 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/ClassNameTypeResolver.java @@ -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, String> classNameExtractor = Class::getSimpleName; + + private final Map, 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. + *

By default, this is {@link Class#getSimpleName()}. + * @param classNameExtractor the function to use + */ + public void setClassNameExtractor(Function, 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, String> entry : this.mappings.entrySet()) { + if (entry.getKey().isAssignableFrom(targetClass)) { + return entry.getValue(); + } + } + return null; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultGraphQlSourceBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultGraphQlSourceBuilder.java index 0ec3030b..2d492754 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultGraphQlSourceBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/DefaultGraphQlSourceBuilder.java @@ -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 runtimeWiringConfigurers = new ArrayList<>(); + @Nullable + private TypeResolver defaultTypeResolver; + private final List exceptionResolvers = new ArrayList<>(); private final List 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 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"); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java b/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java index e853f8d1..29bbcf77 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/execution/GraphQlSource.java @@ -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. + *

A GraphQL {@code TypeResolver} is used to determine the GraphQL Object + * type of values returned from DataFetcher's of GraphQL Interface or + * Union fields. + *

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. diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/ClassNameTypeResolverTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/ClassNameTypeResolverTests.java new file mode 100644 index 00000000..ee2d7f74 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/ClassNameTypeResolverTests.java @@ -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 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> actualAnimals = GraphQlTestUtils.checkErrorsAndGetData(result, "animals"); + + for (int i = 0; i < animalList.size(); i++) { + Map 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> actualSightings = GraphQlTestUtils.checkErrorsAndGetData(result, "sightings"); + + for (int i = 0; i < animalAndPlantList.size(); i++) { + Map 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"); + } + + } + +}