diff --git a/spring-graphql-docs/src/docs/asciidoc/index.adoc b/spring-graphql-docs/src/docs/asciidoc/index.adoc index 37cfb563..505cb197 100644 --- a/spring-graphql-docs/src/docs/asciidoc/index.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/index.adoc @@ -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 <>. +| `@Arguments` +| For access to all field arguments converted to a higher-level, typed Object. +See <>. + | `@ProjectedPayload` Interface | For access to field arguments through a project interface. See <>. @@ -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` 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 diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Argument.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Argument.java index b68d5a44..3d304bff 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Argument.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Argument.java @@ -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()}. * + *

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}. + * *

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) diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Arguments.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Arguments.java new file mode 100644 index 00000000..daf48e32 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/Arguments.java @@ -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 { + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java index 7ee5c972..b0fa2de8 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java @@ -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 diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolver.java new file mode 100644 index 00000000..e98ff9ec --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolver.java @@ -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); + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java index 7a6794ab..5eeb5bd6 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java @@ -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 arguments = mapper.readValue(jsonPayload, new TypeReference>() {}); - 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; diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentResolverTestSupport.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentResolverTestSupport.java new file mode 100644 index 00000000..b5ee5937 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentResolverTestSupport.java @@ -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_TYPE_REFERENCE = + new TypeReference>() {}; + + + 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 arguments = this.mapper.readValue(argumentsJson, MAP_TYPE_REFERENCE); + return DataFetchingEnvironmentImpl.newDataFetchingEnvironment().arguments(arguments).build(); + } + +} \ No newline at end of file diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolverTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolverTests.java new file mode 100644 index 00000000..58a3dd3c --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentsMethodArgumentResolverTests.java @@ -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; + } + } + +} \ No newline at end of file