Refactoring in GraphQL argument initialization

Consolidate GraphQL argument initialization by pushing logic from
ArgumentMethodArgumentResolver down into GraphQlArgumentInitializer,
which now takes the DataFetchingEnvironment, an optional argument name,
and a target type, and does the rest of the work.
This commit is contained in:
Rossen Stoyanchev
2021-12-09 10:26:13 +00:00
parent b22cef00ab
commit 5919178d91
6 changed files with 152 additions and 125 deletions

View File

@@ -24,14 +24,15 @@ import java.util.Map;
import java.util.Optional;
import java.util.Stack;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.beans.SimpleTypeConverter;
import org.springframework.beans.TypeConverter;
import org.springframework.core.CollectionFactory;
import org.springframework.core.MethodParameter;
import org.springframework.core.ResolvableType;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.validation.DataBinder;
@@ -56,27 +57,108 @@ public class GraphQlArgumentInitializer {
/**
* Return the underlying {@link DataBinder}.
* Initialize an Object of the given {@code targetType}, either from a named
* {@link DataFetchingEnvironment#getArgument(String) argument value}, or from all
* {@link DataFetchingEnvironment#getArguments() values} as the source.
* @param environment the environment with the argument values
* @param name optionally, the name of an argument to initialize from,
* or if {@code null}, the full map of arguments is used.
* @param targetType the type of Object to initialize
* @return the initialized Object, or {@code null}
*/
public TypeConverter getTypeConverter() {
return this.typeConverter;
@Nullable
@SuppressWarnings("unchecked")
public Object initializeArgument(
DataFetchingEnvironment environment, @Nullable String name, ResolvableType targetType) {
Object sourceValue = (name != null ? environment.getArgument(name) : environment.getArguments());
if (sourceValue == null) {
return wrapAsOptionalIfNecessary(null, targetType);
}
Class<?> targetClass = targetType.resolve();
Assert.notNull(targetClass, "Could not determine target type from " + targetType);
// From Collection
if (CollectionFactory.isApproximableCollectionType(sourceValue.getClass())) {
Assert.isAssignable(Collection.class, targetClass,
"Argument '" + name + "' is a Collection while method parameter is " + targetClass.getName());
Class<?> elementType = targetType.asCollection().getGeneric(0).resolve();
Assert.notNull(elementType, "Could not determine element type for " + targetType);
return initializeFromCollection((Collection<Object>) sourceValue, elementType);
}
if (targetClass == Optional.class) {
targetClass = targetType.getNested(2).resolve();
Assert.notNull(targetClass, "Could not determine Optional<T> type from " + targetType);
}
// From Map
if (sourceValue instanceof Map) {
Object target = initializeFromMap((Map<String, Object>) sourceValue, targetClass);
return wrapAsOptionalIfNecessary(target, targetType);
}
// From Scalar
if (targetClass.isInstance(sourceValue)) {
return wrapAsOptionalIfNecessary(sourceValue, targetType);
}
Object target = this.typeConverter.convertIfNecessary(sourceValue, targetClass);
if (target == null) {
throw new IllegalStateException("Cannot convert argument value " +
"type [" + sourceValue.getClass().getName() + "] to method parameter " +
"type [" + targetClass.getName() + "].");
}
return wrapAsOptionalIfNecessary(target, targetType);
}
@Nullable
private Object wrapAsOptionalIfNecessary(@Nullable Object value, ResolvableType type) {
return (type.resolve(Object.class).equals(Optional.class) ? Optional.ofNullable(value) : value);
}
/**
* Instantiate a collection of {@code elementType} using the given {@code values}.
* <p>This will instantiate a new Collection of the closest type possible
* from the one provided as an argument.
*
* @param <T> the type of Collection elements
* @param values the collection of values to bind and instantiate
* @param elementClass the type of elements in the given Collection
* @return the instantiated and populated Collection.
* @throws IllegalStateException if there is no suitable constructor.
*/
@SuppressWarnings("unchecked")
private <T> Collection<T> initializeFromCollection(Collection<Object> values, Class<T> elementClass) {
Collection<T> collection = CollectionFactory.createApproximateCollection(values, values.size());
for (Object item : values) {
if (elementClass.isAssignableFrom(item.getClass())) {
collection.add((T) item);
}
else if (item instanceof Map) {
collection.add((T) this.initializeFromMap((Map<String, Object>) item, elementClass));
}
else {
collection.add(this.typeConverter.convertIfNecessary(item, elementClass));
}
}
return collection;
}
/**
* Instantiate an Object of the given target type and bind
* {@link graphql.schema.DataFetchingEnvironment} argument values to it.
* This considers using the default constructor or a primary constructor,
* if available.
*
* @param arguments the data fetching environment arguments
* @param targetType the type of the argument to instantiate
* @param <T> the type of the input argument
* @return the instantiated and populated input argument.
* This considers the default constructor or a primary constructor, if available.
* @throws IllegalStateException if there is no suitable constructor.
*/
@SuppressWarnings("unchecked")
public <T> T initializeFromMap(Map<String, Object> arguments, Class<T> targetType) {
private Object initializeFromMap(Map<String, Object> arguments, Class<?> targetType) {
Object target;
Constructor<?> ctor = BeanUtils.getResolvableConstructor(targetType);
@@ -85,7 +167,7 @@ public class GraphQlArgumentInitializer {
target = BeanUtils.instantiateClass(ctor);
DataBinder dataBinder = new DataBinder(target);
dataBinder.bind(propertyValues);
return (T) target;
return target;
}
// Data class constructor
@@ -96,56 +178,25 @@ public class GraphQlArgumentInitializer {
for (int i = 0; i < paramNames.length; i++) {
String paramName = paramNames[i];
Object value = arguments.get(paramName);
MethodParameter methodParam = new MethodParameter(ctor, i);
if (value == null && methodParam.isOptional()) {
args[i] = (methodParam.getParameterType() == Optional.class ? Optional.empty() : null);
MethodParameter methodParameter = new MethodParameter(ctor, i);
if (value == null && methodParameter.isOptional()) {
args[i] = (methodParameter.getParameterType() == Optional.class ? Optional.empty() : null);
}
else if (value != null && CollectionFactory.isApproximableCollectionType(value.getClass())) {
TypeDescriptor typeDescriptor = new TypeDescriptor(methodParam);
Class<?> elementType = typeDescriptor.getElementTypeDescriptor().getType();
ResolvableType resolvableType = ResolvableType.forMethodParameter(methodParameter);
Class<?> elementType = resolvableType.asCollection().getGeneric(0).resolve();
Assert.notNull(elementType, "Cannot determine element type for " + resolvableType);
args[i] = initializeFromCollection((Collection<Object>) value, elementType);
}
else if (value instanceof Map) {
args[i] = this.initializeFromMap((Map<String, Object>) value, methodParam.getParameterType());
args[i] = this.initializeFromMap((Map<String, Object>) value, methodParameter.getParameterType());
}
else {
args[i] = this.typeConverter.convertIfNecessary(value, paramTypes[i], methodParam);
args[i] = this.typeConverter.convertIfNecessary(value, paramTypes[i], methodParameter);
}
}
return (T) BeanUtils.instantiateClass(ctor, args);
}
/**
* Instantiate a collection of {@code elementType} using the given {@code values}.
* <p>This will instantiate a new Collection of the closest type possible
* from the one provided as an argument.
*
* @param <T> the type of Collection elements
* @param values the collection of values to bind and instantiate
* @param elementType the type of elements in the given Collection
* @return the instantiated and populated Collection.
* @throws IllegalStateException if there is no suitable constructor.
*/
@SuppressWarnings("unchecked")
public <T> Collection<T> initializeFromCollection(Collection<Object> values, Class<T> elementType) {
Assert.state(CollectionFactory.isApproximableCollectionType(values.getClass()),
() -> "Cannot instantiate Collection for type " + values.getClass());
Collection<T> instances = CollectionFactory.createApproximateCollection(values, values.size());
values.forEach(item -> {
T value;
if (elementType.isAssignableFrom(item.getClass())) {
value = (T) item;
}
else if (item instanceof Map) {
value = this.initializeFromMap((Map<String, Object>)item, elementType);
}
else {
value = this.typeConverter.convertIfNecessary(item, elementType);
}
instances.add(value);
});
return instances;
return BeanUtils.instantiateClass(ctor, args);
}
/**

View File

@@ -46,6 +46,7 @@ import org.springframework.core.MethodIntrospector;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.convert.ConversionService;
import org.springframework.graphql.data.GraphQlArgumentInitializer;
import org.springframework.graphql.data.method.HandlerMethod;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite;
@@ -128,7 +129,8 @@ public class AnnotatedControllerConfigurer
this.argumentResolvers.addResolver(new ProjectedPayloadMethodArgumentResolver());
}
this.argumentResolvers.addResolver(new ArgumentMapMethodArgumentResolver());
this.argumentResolvers.addResolver(new ArgumentMethodArgumentResolver(this.conversionService));
GraphQlArgumentInitializer initializer = new GraphQlArgumentInitializer(this.conversionService);
this.argumentResolvers.addResolver(new ArgumentMethodArgumentResolver(initializer));
this.argumentResolvers.addResolver(new ContextValueMethodArgumentResolver());
// Type based

View File

@@ -15,20 +15,13 @@
*/
package org.springframework.graphql.data.method.annotation.support;
import java.util.Collection;
import java.util.Map;
import java.util.Optional;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.core.CollectionFactory;
import org.springframework.core.MethodParameter;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.ResolvableType;
import org.springframework.graphql.data.GraphQlArgumentInitializer;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
@@ -46,8 +39,9 @@ public class ArgumentMethodArgumentResolver implements HandlerMethodArgumentReso
private final GraphQlArgumentInitializer argumentInitializer;
public ArgumentMethodArgumentResolver(@Nullable ConversionService conversionService) {
this.argumentInitializer = new GraphQlArgumentInitializer(conversionService);
public ArgumentMethodArgumentResolver(GraphQlArgumentInitializer initializer) {
Assert.notNull(initializer, "GraphQlArgumentInitializer is required");
this.argumentInitializer = initializer;
}
@@ -57,47 +51,10 @@ public class ArgumentMethodArgumentResolver implements HandlerMethodArgumentReso
}
@Override
@SuppressWarnings("unchecked")
public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) throws Exception {
String name = getArgumentName(parameter);
Object rawValue = environment.getArgument(name);
TypeDescriptor typeDescriptor = new TypeDescriptor(parameter);
if (rawValue == null) {
return wrapAsOptionalIfNecessary(null, typeDescriptor.getType());
}
// From Collection
if (CollectionFactory.isApproximableCollectionType(rawValue.getClass())) {
Assert.isAssignable(Collection.class, typeDescriptor.getType(),
"Argument '" + name + "' is a Collection " +
"while the @Argument method parameter is " + typeDescriptor.getType());
Class<?> elementType = typeDescriptor.getElementTypeDescriptor().getType();
return this.argumentInitializer.initializeFromCollection((Collection<Object>) rawValue, elementType);
}
Class<?> targetType = parameter.nestedIfOptional().getNestedParameterType();
Object target;
// From Map
if (rawValue instanceof Map) {
target = this.argumentInitializer.initializeFromMap((Map<String, Object>) rawValue, targetType);
return wrapAsOptionalIfNecessary(target, typeDescriptor.getType());
}
// From Scalar
if (targetType.isAssignableFrom(rawValue.getClass())) {
return wrapAsOptionalIfNecessary(rawValue, targetType);
}
target = this.argumentInitializer.getTypeConverter().convertIfNecessary(rawValue, targetType);
Assert.state(target != null, () ->
"Cannot convert value type [" + rawValue.getClass() + "] " +
"to argument type [" + targetType.getName() + "].");
return wrapAsOptionalIfNecessary(target, typeDescriptor.getType());
ResolvableType resolvableType = ResolvableType.forMethodParameter(parameter);
return this.argumentInitializer.initializeArgument(environment, name, resolvableType);
}
static String getArgumentName(MethodParameter parameter) {
@@ -115,9 +72,4 @@ public class ArgumentMethodArgumentResolver implements HandlerMethodArgumentReso
"] not specified, and parameter name information not found in class file either.");
}
@Nullable
private Object wrapAsOptionalIfNecessary(@Nullable Object value, Class<?> type) {
return (type.equals(Optional.class) ? Optional.ofNullable(value) : value);
}
}

View File

@@ -30,6 +30,7 @@ import graphql.schema.GraphQLTypeVisitor;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.core.ResolvableType;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.query.FluentQuery;
@@ -103,8 +104,10 @@ public abstract class QueryByExampleDataFetcher<T> {
* @param env contextual info for the GraphQL query
* @return the resulting example
*/
@SuppressWarnings({"ConstantConditions", "unchecked"})
protected Example<T> buildExample(DataFetchingEnvironment env) {
return Example.of(this.argumentInitializer.initializeFromMap(env.getArguments(), this.domainType.getType()));
ResolvableType targetType = ResolvableType.forClass(this.domainType.getType());
return (Example<T>) Example.of(this.argumentInitializer.initializeArgument(env, null, targetType));
}
protected boolean requiresProjection(Class<?> resultType) {

View File

@@ -33,6 +33,8 @@ import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.SynthesizingMethodParameter;
import org.springframework.format.support.DefaultFormattingConversionService;
import org.springframework.graphql.Book;
import org.springframework.graphql.data.GraphQlArgumentInitializer;
import org.springframework.graphql.data.method.HandlerMethodArgumentResolver;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.QueryMapping;
@@ -49,7 +51,8 @@ class ArgumentMethodArgumentResolverTests {
private final ObjectMapper mapper = new ObjectMapper();
ArgumentMethodArgumentResolver resolver = new ArgumentMethodArgumentResolver(new DefaultFormattingConversionService());
private final HandlerMethodArgumentResolver resolver =
new ArgumentMethodArgumentResolver(new GraphQlArgumentInitializer(new DefaultFormattingConversionService()));
@Test
void shouldSupportAnnotatedParameters() {

View File

@@ -26,6 +26,7 @@ import graphql.schema.DataFetchingEnvironment;
import graphql.schema.DataFetchingEnvironmentImpl;
import org.junit.jupiter.api.Test;
import org.springframework.core.ResolvableType;
import org.springframework.graphql.Book;
import org.springframework.graphql.data.GraphQlArgumentInitializer;
@@ -41,13 +42,15 @@ class GraphQlArgumentInitializerTests {
private ObjectMapper mapper = new ObjectMapper();
private GraphQlArgumentInitializer instantiator = new GraphQlArgumentInitializer(null);
private GraphQlArgumentInitializer initializer = new GraphQlArgumentInitializer(null);
@Test
void shouldInstantiateDefaultConstructor() throws Exception {
String payload = "{\"simpleBean\": { \"name\": \"test\"} }";
DataFetchingEnvironment environment = initEnvironment(payload);
SimpleBean result = instantiator.initializeFromMap(environment.getArgument("simpleBean"), SimpleBean.class);
Object result = initializer.initializeArgument(
environment, "simpleBean", ResolvableType.forClass(SimpleBean.class));
assertThat(result).isNotNull().isInstanceOf(SimpleBean.class);
assertThat(result).hasFieldOrPropertyWithValue("name", "test");
@@ -57,7 +60,8 @@ class GraphQlArgumentInitializerTests {
void shouldInstantiatePrimaryConstructor() throws Exception {
String payload = "{\"constructorBean\": { \"name\": \"test\"} }";
DataFetchingEnvironment environment = initEnvironment(payload);
ContructorBean result = instantiator.initializeFromMap(environment.getArgument("constructorBean"), ContructorBean.class);
Object result = initializer.initializeArgument(
environment, "constructorBean", ResolvableType.forClass(ContructorBean.class));
assertThat(result).isNotNull().isInstanceOf(ContructorBean.class);
assertThat(result).hasFieldOrPropertyWithValue("name", "test");
@@ -67,19 +71,21 @@ class GraphQlArgumentInitializerTests {
void shouldFailIfNoPrimaryConstructor() throws Exception {
String payload = "{\"noPrimary\": { \"name\": \"test\"} }";
DataFetchingEnvironment environment = initEnvironment(payload);
assertThatThrownBy(() -> instantiator.initializeFromMap(environment.getArgument("noPrimary"), NoPrimaryConstructor.class))
.isInstanceOf(IllegalStateException.class).hasMessageContaining("No primary or single unique constructor found");
assertThatThrownBy(() -> {
ResolvableType targetType = ResolvableType.forClass(NoPrimaryConstructor.class);
initializer.initializeArgument(environment, "noPrimary", targetType);
}).isInstanceOf(IllegalStateException.class).hasMessageContaining("No primary or single unique constructor found");
}
@Test
void shouldInstantiateNestedBean() throws Exception {
String payload = "{\"book\": { \"name\": \"test name\", \"author\": { \"firstName\": \"Jane\", \"lastName\": \"Spring\"} } }";
DataFetchingEnvironment environment = initEnvironment(payload);
Book result = instantiator.initializeFromMap(environment.getArgument("book"), Book.class);
Object result = initializer.initializeArgument(environment, "book", ResolvableType.forClass(Book.class));
assertThat(result).isNotNull().isInstanceOf(Book.class);
assertThat(result).hasFieldOrPropertyWithValue("name", "test name");
assertThat(result.getAuthor()).isNotNull()
assertThat(((Book) result).getAuthor()).isNotNull()
.hasFieldOrPropertyWithValue("firstName", "Jane")
.hasFieldOrPropertyWithValue("lastName", "Spring");
}
@@ -88,31 +94,35 @@ class GraphQlArgumentInitializerTests {
void shouldInstantiateNestedBeanLists() throws Exception {
String payload = "{\"nestedList\": { \"items\": [ {\"name\": \"first\"}, {\"name\": \"second\"}] } }";
DataFetchingEnvironment environment = initEnvironment(payload);
NestedList result = instantiator.initializeFromMap(environment.getArgument("nestedList"), NestedList.class);
Object result = initializer.initializeArgument(
environment, "nestedList", ResolvableType.forClass(NestedList.class));
assertThat(result).isNotNull().isInstanceOf(NestedList.class);
assertThat(result.getItems()).hasSize(2).extracting("name").containsExactly("first", "second");
assertThat(((NestedList) result).getItems()).hasSize(2).extracting("name").containsExactly("first", "second");
}
@Test
void shouldInstantiatePrimaryConstructorNestedBeanLists() throws Exception {
String payload = "{\"nestedList\": { \"items\": [ {\"name\": \"first\"}, {\"name\": \"second\"}] } }";
DataFetchingEnvironment environment = initEnvironment(payload);
PrimaryConstructorNestedList result = instantiator.initializeFromMap(environment.getArgument("nestedList"), PrimaryConstructorNestedList.class);
Object result = initializer.initializeArgument(
environment, "nestedList", ResolvableType.forClass(PrimaryConstructorNestedList.class));
assertThat(result).isNotNull().isInstanceOf(PrimaryConstructorNestedList.class);
assertThat(result.getItems()).hasSize(2).extracting("name").containsExactly("first", "second");
assertThat(((PrimaryConstructorNestedList) result).getItems())
.hasSize(2).extracting("name").containsExactly("first", "second");
}
@Test
void shouldInstantiateComplexNestedBean() throws Exception {
String payload = "{\"complex\": { \"item\": {\"name\": \"Item name\"}, \"name\": \"Hello\" } }";
DataFetchingEnvironment environment = initEnvironment(payload);
PrimaryConstructorComplexInput result = instantiator.initializeFromMap(environment.getArgument("complex"), PrimaryConstructorComplexInput.class);
Object result = initializer.initializeArgument(
environment, "complex", ResolvableType.forClass(PrimaryConstructorComplexInput.class));
assertThat(result).isNotNull().isInstanceOf(PrimaryConstructorComplexInput.class);
assertThat(result.item.name).isEqualTo("Item name");
assertThat(result.name).isEqualTo("Hello");
assertThat(((PrimaryConstructorComplexInput) result).item.name).isEqualTo("Item name");
assertThat(((PrimaryConstructorComplexInput) result).name).isEqualTo("Hello");
}
private DataFetchingEnvironment initEnvironment(String jsonPayload) throws JsonProcessingException {
@@ -121,6 +131,7 @@ class GraphQlArgumentInitializerTests {
return DataFetchingEnvironmentImpl.newDataFetchingEnvironment().arguments(arguments).build();
}
static class SimpleBean {
String name;
@@ -134,6 +145,7 @@ class GraphQlArgumentInitializerTests {
}
}
static class ContructorBean {
final String name;
@@ -147,6 +159,7 @@ class GraphQlArgumentInitializerTests {
}
}
static class NoPrimaryConstructor {
NoPrimaryConstructor(String name) {
@@ -156,6 +169,7 @@ class GraphQlArgumentInitializerTests {
}
}
static class NestedList {
List<Item> items;
@@ -182,6 +196,7 @@ class GraphQlArgumentInitializerTests {
}
}
static class Item {
String name;
@@ -195,6 +210,7 @@ class GraphQlArgumentInitializerTests {
}
}
static class PrimaryConstructorComplexInput {
final String name;