Support for @Arguments method parameter

Closes gh-258
This commit is contained in:
rstoyanchev
2022-02-09 17:18:46 +00:00
parent 32930f7e88
commit 6df6ab78c8
8 changed files with 333 additions and 68 deletions

View File

@@ -1009,9 +1009,13 @@ Schema mapping handler methods can have any of the following method arguments:
| Method Argument | Description
| `@Argument`
| For access to field arguments with conversion.
| For access to a named field argument converted to a higher-level, typed Object.
See <<controllers-schema-mapping-argument>>.
| `@Arguments`
| For access to all field arguments converted to a higher-level, typed Object.
See <<controllers-schema-mapping-arguments>>.
| `@ProjectedPayload` Interface
| For access to field arguments through a project interface.
See <<controllers-schema-mapping-projectedpayload-argument>>.
@@ -1056,12 +1060,15 @@ Schema mapping handler methods can return any value, including Reactor `Mono` an
[[controllers-schema-mapping-argument]]
==== `@Argument`
In GraphQL Java, the `DataFetchingEnvironment` provides access to field-specific argument
values. The arguments are available as simple scalar values such as String, or as a `Map`
of values for more complex input, or a `List` of values.
In GraphQL Java, `DataFetchingEnvironment` provides access to a map of field-specific
argument values. The values can be simple scalar values (e.g. String, Long), a `Map` of
values for more complex input, or a `List` of values.
Use `@Argument` to access an argument for the field that maps to the handler method. You
can declare such a method parameter to be of any type.
Use the `@Argument` annotation to inject a named field argument into a handler method.
The method parameter can be a higher-level, typed Object of any type. It is created and
initialized from the named field argument value(s), either matching them to single data
constructor parameters, or using the default constructor and then matching keys onto
Object properties through a `org.springframework.validation.DataBinder`:
[source,java,indent=0,subs="verbatim,quotes"]
----
@@ -1080,20 +1087,32 @@ can declare such a method parameter to be of any type.
}
----
You can explicitly specify the argument name, for example `@Argument("bookInput")`, or if
it not specified, it defaults to the method parameter name, but this requires the
`-parameters` compiler flag with Java 8+ or debugging information from the compiler.
By default, if the method parameter name is available (requires the `-parameters` compiler
flag with Java 8+ or debugging info from the compiler), it is used to look up the argument.
If needed, you can customize the name through the annotation, e.g. `@Argument("bookInput")`.
The `@Argument` annotation does not have a "required" flag, nor the option to specify a
default value. Both of these can be specified at the GraphQL schema level and are enforced
by the GraphQL Engine.
TIP: The `@Argument` annotation does not have a "required" flag, nor the option to
specify a default value. Both of these can be specified at the GraphQL schema level and
are enforced by the GraphQL Engine.
You can use `@Argument` on a `Map<String, Object>` argument, to obtain all argument
values. The name attribute on `@Argument` must not be set.
[[controllers-schema-mapping-arguments]]
==== `@Arguments`
Use the `@Arguments` annotation, if you want to bind the full arguments map onto a single
target Object, in contrast to `@Argument`, which binds a specific, named argument.
For example, `@Argument BookInput bookInput` uses the value of the argument "bookInput"
to initialize `BookInput`, while `@Arguments` uses the full arguments map and in that
case, top-level arguments are bound to `BookInput` properties.
[[controllers-schema-mapping-validation]]
==== `@Argument` validation
==== `@Argument(s)` Validation
If a {spring-framework-ref-docs}/core.html#validation-beanvalidation-overview[Bean Validation]
`Validator` (or typically, a `LocalValidatorFactoryBean`) bean is present in the application context,
@@ -1138,6 +1157,7 @@ Unlike Spring MVC, handler method signatures do not support the injection of `Bi
for reacting to validation errors: those are globally dealt with as exceptions.
====
[[controllers-schema-mapping-projectedpayload-argument]]
==== `@ProjectPayload` Interface

View File

@@ -31,12 +31,19 @@ import org.springframework.core.annotation.AliasFor;
* and a parameter name is not specified, then the map parameter is populated
* via {@link graphql.schema.DataFetchingEnvironment#getArguments()}.
*
* <p>The target method parameter can be a higher-level, typed Object of any
* type. It is created and initialized from the named field argument value(s),
* either matching them to single data constructor parameters, or using the
* default constructor and then matching keys onto Object properties through
* a {@link org.springframework.validation.DataBinder}.
*
* <p>Note that this annotation has neither a "required" flag nor the option to
* specify a default value, both of which can be specified at the GraphQL schema
* level and are enforced by the GraphQL Java engine.
*
* @author Rossen Stoyanchev
* @since 1.0.0
* @see Arguments
*/
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2002-2022 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.data.method.annotation;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Similar to {@link Argument @Argument} but using the full map of argument
* values as the source of values to bind to the target Object.
*
* @author Rossen Stoyanchev
* @since 1.0.0
* @see Argument
*/
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Arguments {
}

View File

@@ -161,6 +161,7 @@ public class AnnotatedControllerConfigurer
this.argumentResolvers.addResolver(new ArgumentMapMethodArgumentResolver());
GraphQlArgumentInitializer initializer = new GraphQlArgumentInitializer(this.conversionService);
this.argumentResolvers.addResolver(new ArgumentMethodArgumentResolver(initializer));
this.argumentResolvers.addResolver(new ArgumentsMethodArgumentResolver(initializer));
this.argumentResolvers.addResolver(new ContextValueMethodArgumentResolver());
// Type based

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2002-2022 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.data.method.annotation.support;
import graphql.schema.DataFetchingEnvironment;
import org.springframework.core.MethodParameter;
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.Arguments;
import org.springframework.util.Assert;
/**
* Resolver for {@link Arguments @Arguments} annotated method parameters,
* obtained via {@link DataFetchingEnvironment#getArgument(String)} and
* converted to the declared type of the method parameter.
*
* @author Rossen Stoyanchev
* @since 1.0.0
*/
public class ArgumentsMethodArgumentResolver implements HandlerMethodArgumentResolver {
private final GraphQlArgumentInitializer argumentInitializer;
public ArgumentsMethodArgumentResolver(GraphQlArgumentInitializer initializer) {
Assert.notNull(initializer, "GraphQlArgumentInitializer is required");
this.argumentInitializer = initializer;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterAnnotation(Arguments.class) != null;
}
@Override
public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) throws Exception {
ResolvableType resolvableType = ResolvableType.forMethodParameter(parameter);
return this.argumentInitializer.initializeArgument(environment, null, resolvableType);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2020-2021 the original author or authors.
* Copyright 2020-2022 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.
@@ -17,20 +17,11 @@
package org.springframework.graphql.data.method.annotation.support;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import graphql.schema.DataFetchingEnvironment;
import graphql.schema.DataFetchingEnvironmentImpl;
import org.junit.jupiter.api.Test;
import org.springframework.core.DefaultParameterNameDiscoverer;
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;
@@ -39,7 +30,6 @@ import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.stereotype.Controller;
import org.springframework.util.ClassUtils;
import static org.assertj.core.api.Assertions.assertThat;
@@ -47,85 +37,69 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link ArgumentMethodArgumentResolver}.
* @author Brian Clozel
*/
class ArgumentMethodArgumentResolverTests {
class ArgumentMethodArgumentResolverTests extends ArgumentResolverTestSupport {
private final ObjectMapper mapper = new ObjectMapper();
private final HandlerMethodArgumentResolver resolver = new ArgumentMethodArgumentResolver(
new GraphQlArgumentInitializer(new DefaultFormattingConversionService()));
private final HandlerMethodArgumentResolver resolver =
new ArgumentMethodArgumentResolver(new GraphQlArgumentInitializer(new DefaultFormattingConversionService()));
@Test
void shouldSupportAnnotatedParameters() {
Method bookById = ClassUtils.getMethod(BookController.class, "bookById", Long.class);
MethodParameter methodParameter = methodParam(bookById, 0);
assertThat(resolver.supportsParameter(methodParameter)).isTrue();
MethodParameter methodParameter = methodParam(BookController.class, "bookById", Long.class);
assertThat(this.resolver.supportsParameter(methodParameter)).isTrue();
}
@Test
void shouldNotSupportParametersWithoutAnnotation() {
Method notSupported = ClassUtils.getMethod(BookController.class, "notSupported", String.class);
MethodParameter methodParameter = methodParam(notSupported, 0);
assertThat(resolver.supportsParameter(methodParameter)).isFalse();
MethodParameter methodParameter = methodParam(BookController.class, "notSupported", String.class);
assertThat(this.resolver.supportsParameter(methodParameter)).isFalse();
}
@Test
void shouldResolveBasicTypeArgument() throws Exception {
Method bookById = ClassUtils.getMethod(BookController.class, "bookById", Long.class);
DataFetchingEnvironment environment = initEnvironment("{\"id\": 42 }");
Object result = resolver.resolveArgument(methodParam(bookById, 0), environment);
Object result = this.resolver.resolveArgument(
methodParam(BookController.class, "bookById", Long.class),
environment("{\"id\": 42 }"));
assertThat(result).isNotNull().isInstanceOf(Long.class).isEqualTo(42L);
}
@Test
void shouldResolveJavaBeanArgument() throws Exception {
Method addBook = ClassUtils.getMethod(BookController.class, "addBook", BookInput.class);
String payload = "{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42} }";
DataFetchingEnvironment environment = initEnvironment(payload);
Object result = resolver.resolveArgument(methodParam(addBook, 0), environment);
Object result = this.resolver.resolveArgument(
methodParam(BookController.class, "addBook", BookInput.class),
environment("{\"bookInput\": { \"name\": \"test name\", \"authorId\": 42} }"));
assertThat(result).isNotNull().isInstanceOf(BookInput.class);
assertThat((BookInput) result).hasFieldOrPropertyWithValue("name", "test name")
assertThat(result).isNotNull().isInstanceOf(BookInput.class)
.hasFieldOrPropertyWithValue("name", "test name")
.hasFieldOrPropertyWithValue("authorId", 42L);
}
@Test
void shouldResolveListOfJavaBeansArgument() throws Exception {
Method addBooks = ClassUtils.getMethod(BookController.class, "addBooks", List.class);
String payload = "{\"books\": [{ \"name\": \"first\", \"authorId\": 42}, { \"name\": \"second\", \"authorId\": 24}] }";
DataFetchingEnvironment environment = initEnvironment(payload);
Object result = resolver.resolveArgument(methodParam(addBooks, 0), environment);
Object result = this.resolver.resolveArgument(
methodParam(BookController.class, "addBooks", List.class),
environment("{\"books\": [" +
"{ \"name\": \"first\", \"authorId\": 42}, " +
"{ \"name\": \"second\", \"authorId\": 24}] }"));
assertThat(result).isNotNull().isInstanceOf(List.class);
assertThat(result).asList().allMatch(item -> item instanceof Book)
assertThat(result).isNotNull()
.isInstanceOf(List.class).asList()
.allMatch(item -> item instanceof Book)
.extracting("name").containsExactly("first", "second");
}
@Test
void shouldResolveArgumentWithConversionService() throws Exception {
Method bookByKeyword = ClassUtils.getMethod(BookController.class, "bookByKeyword", Keyword.class);
String payload = "{\"keyword\": \"test\" }";
DataFetchingEnvironment environment = initEnvironment(payload);
Object result = resolver.resolveArgument(methodParam(bookByKeyword, 0), environment);
Object result = this.resolver.resolveArgument(
methodParam(BookController.class, "bookByKeyword", Keyword.class),
environment("{\"keyword\": \"test\" }"));
assertThat(result).isNotNull().isInstanceOf(Keyword.class);
assertThat((Keyword) result).hasFieldOrPropertyWithValue("term", "test");
}
private MethodParameter methodParam(Method method, int index) {
MethodParameter methodParameter = new SynthesizingMethodParameter(method, index);
methodParameter.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
return methodParameter;
}
private DataFetchingEnvironment initEnvironment(String jsonPayload) throws JsonProcessingException {
Map<String, Object> arguments = mapper.readValue(jsonPayload, new TypeReference<Map<String, Object>>() {});
return DataFetchingEnvironmentImpl.newDataFetchingEnvironment().arguments(arguments).build();
assertThat(result).isNotNull().isInstanceOf(Keyword.class).hasFieldOrPropertyWithValue("term", "test");
}
@SuppressWarnings({"ConstantConditions", "unused"})
@Controller
static class BookController {
@@ -155,6 +129,7 @@ class ArgumentMethodArgumentResolverTests {
}
@SuppressWarnings({"NotNullFieldNotInitialized", "unused"})
static class BookInput {
String name;
@@ -178,6 +153,8 @@ class ArgumentMethodArgumentResolverTests {
}
}
@SuppressWarnings("unused")
static class Keyword {
String term;

View File

@@ -0,0 +1,61 @@
/*
* Copyright 2020-2022 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.data.method.annotation.support;
import java.lang.reflect.Method;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import graphql.schema.DataFetchingEnvironment;
import graphql.schema.DataFetchingEnvironmentImpl;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.core.annotation.SynthesizingMethodParameter;
import org.springframework.util.ClassUtils;
/**
* Base class to test resolving {@link @Argument} and {@link @Arguments}
* annotated method parameters.
*
* @author Rossen Stoyanchev
*/
class ArgumentResolverTestSupport {
private static final TypeReference<Map<String, Object>> MAP_TYPE_REFERENCE =
new TypeReference<Map<String, Object>>() {};
private final ObjectMapper mapper = new ObjectMapper();
protected MethodParameter methodParam(Class<?> controller, String methodName, Class<?>... parameters) {
Method method = ClassUtils.getMethod(controller, methodName, parameters);
MethodParameter methodParam = new SynthesizingMethodParameter(method, 0);
methodParam.initParameterNameDiscovery(new DefaultParameterNameDiscoverer());
return methodParam;
}
protected DataFetchingEnvironment environment(String argumentsJson) throws JsonProcessingException {
Map<String, Object> arguments = this.mapper.readValue(argumentsJson, MAP_TYPE_REFERENCE);
return DataFetchingEnvironmentImpl.newDataFetchingEnvironment().arguments(arguments).build();
}
}

View File

@@ -0,0 +1,105 @@
/*
* Copyright 2020-2022 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.data.method.annotation.support;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
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.Arguments;
import org.springframework.graphql.data.method.annotation.MutationMapping;
import org.springframework.stereotype.Controller;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link ArgumentsMethodArgumentResolver}.
* @author Rossen Stoyanchev
*/
class ArgumentsMethodArgumentResolverTests extends ArgumentResolverTestSupport {
private final HandlerMethodArgumentResolver resolver = new ArgumentsMethodArgumentResolver(
new GraphQlArgumentInitializer(new DefaultFormattingConversionService()));
@Test
void shouldSupportAnnotatedParameters() {
MethodParameter methodParameter = methodParam(BookController.class, "addBook", BookInput.class);
assertThat(resolver.supportsParameter(methodParameter)).isTrue();
}
@Test
void shouldNotSupportParametersWithoutAnnotation() {
MethodParameter methodParameter = methodParam(BookController.class, "notSupported", String.class);
assertThat(resolver.supportsParameter(methodParameter)).isFalse();
}
@Test
void shouldResolveJavaBeanArgument() throws Exception {
Object result = resolver.resolveArgument(
methodParam(BookController.class, "addBook", BookInput.class),
environment("{\"name\":\"test name\", \"authorId\":42}"));
assertThat(result).isNotNull().isInstanceOf(BookInput.class)
.hasFieldOrPropertyWithValue("name", "test name")
.hasFieldOrPropertyWithValue("authorId", 42L);
}
@SuppressWarnings({"ConstantConditions", "unused"})
@Controller
static class BookController {
public void notSupported(String param) {
}
@MutationMapping
public Book addBook(@Arguments BookInput bookInput) {
return null;
}
}
@SuppressWarnings({"NotNullFieldNotInitialized", "unused"})
static class BookInput {
String name;
Long authorId;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public Long getAuthorId() {
return this.authorId;
}
public void setAuthorId(Long authorId) {
this.authorId = authorId;
}
}
}