diff --git a/spring-graphql-docs/src/docs/asciidoc/index.adoc b/spring-graphql-docs/src/docs/asciidoc/index.adoc index 1523a079..e3308692 100644 --- a/spring-graphql-docs/src/docs/asciidoc/index.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/index.adoc @@ -770,6 +770,53 @@ given a list of source/parent books objects. ==== +[[controllers-schema-mapping-argument-projections]] +==== Argument Projections + +When accessing individual arguments from a GraphQL request, interface projections can +be useful to access arguments through a well-defined interface. + +Spring Data's `@ProjectedPayload` can be used to annotate projection interfaces that +can be declared as handler method arguments. Payload projection can work on top-level +arguments (`DataFetchingEnvironment.getArguments()`). Alternatively, projections can +be applied on individual arguments by using `@Argument` with a projected payload interface. + +Argument projections are provided by https://docs.spring.io/spring-data/commons/docs/current/reference/html/#projections.interfaces[Spring Data's Interface projections] +when Spring Data is on the class path. + +[source,java,indent=0,subs="verbatim,quotes"] +---- + @Controller + public class BookController { + + @QueryMapping + public Book bookById(BookIdProjection bookId) { + // ... + } + + @MutationMapping + public Book addBook(@Argument BookInputProjection bookInput) { + // ... + } + } + + @ProjectedPayload + interface BookIdProjection { + + Long getId(); + } + + @ProjectedPayload + interface BookInputProjection { + + String getName(); + + @Value("#{target.author + ' ' + target.name}) + String getAuthorAndName(); + } +---- + + [[controllers-schema-mapping-data-loader]] ==== `DataLoader` 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 f056895f..53eb11c6 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 @@ -84,6 +84,9 @@ public class AnnotatedControllerConfigurer */ private static final String SCOPED_TARGET_NAME_PREFIX = "scopedTarget."; + private final static boolean springDataPresent = ClassUtils.isPresent( + "org.springframework.data.projection.SpelAwareProxyProjectionFactory", + AnnotatedControllerConfigurer.class.getClassLoader()); private final static boolean springSecurityPresent = ClassUtils.isPresent( "org.springframework.security.core.context.SecurityContext", AnnotatedControllerConfigurer.class.getClassLoader()); @@ -117,6 +120,9 @@ public class AnnotatedControllerConfigurer @Override public void afterPropertiesSet() { this.argumentResolvers = new HandlerMethodArgumentResolverComposite(); + if (springDataPresent) { + this.argumentResolvers.addResolver(new ProjectedPayloadMethodArgumentResolver(this.conversionService)); + } this.argumentResolvers.addResolver(new ArgumentMapMethodArgumentResolver()); this.argumentResolvers.addResolver(new ArgumentMethodArgumentResolver(this.conversionService)); this.argumentResolvers.addResolver(new DataFetchingEnvironmentMethodArgumentResolver()); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ProjectedPayloadMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ProjectedPayloadMethodArgumentResolver.java new file mode 100644 index 00000000..0e5d8def --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ProjectedPayloadMethodArgumentResolver.java @@ -0,0 +1,110 @@ +/* + * 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 + * + * http://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.beans.BeansException; +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.BeanFactoryAware; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.convert.ConversionService; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.web.ProjectedPayload; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; +import org.springframework.graphql.data.method.annotation.Argument; +import org.springframework.lang.Nullable; + +/** + * Resolver to obtain a {@link ProjectedPayload @ProjectedPayload} + * for {@link DataFetchingEnvironment#getArguments()}. + * + *

Projected payloads consist of the projection interface and accessor methods. + * Projections can be closed or open projections. Closed projections use interface + * getter methods to access underlying properties directly. Open projection methods + * make use of the {@code @Value} annotation to evaluate SpEL expressions against the + * underlying {@code target} object. + * + *

For example: + *

+ * @ProjectedPayload
+ * interface BookProjection {
+ *   String getName();
+ * }
+ *
+ * @ProjectedPayload
+ * interface BookProjection {
+ *   @Value("#{target.author + ' '  + target.name}")
+ *   String getAuthorAndName();
+ * }
+ * 
+ * + * @author Mark Paluch + * @since 1.0.0 + */ +public class ProjectedPayloadMethodArgumentResolver implements HandlerMethodArgumentResolver, + BeanFactoryAware, BeanClassLoaderAware { + + private final SpelAwareProxyProjectionFactory projectionFactory = new SpelAwareProxyProjectionFactory(); + + private final ArgumentMethodArgumentResolver argumentResolver; + + public ProjectedPayloadMethodArgumentResolver(@Nullable ConversionService conversionService) { + this.argumentResolver = new ArgumentMethodArgumentResolver(conversionService){ + @Override + protected Object convert(Object rawValue, Class targetType) { + return project(targetType, rawValue); + } + }; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + Class type = parameter.getParameterType(); + + if (!type.isInterface()) { + return false; + } + + return AnnotatedElementUtils.findMergedAnnotation(type, ProjectedPayload.class) != null; + } + + @Override + public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) throws Exception { + if(parameter.getParameterAnnotation(Argument.class) != null){ + return argumentResolver.resolveArgument(parameter, environment); + } + + return project(parameter.getParameterType(), environment.getArguments()); + } + + protected Object project(Class projectionType, Object source){ + return this.projectionFactory.createProjection(projectionType, source); + } + + @Override + public void setBeanFactory(BeanFactory beanFactory) throws BeansException { + this.projectionFactory.setBeanFactory(beanFactory); + } + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.projectionFactory.setBeanClassLoader(classLoader); + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationTests.java index 74331899..fd3fb66f 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingInvocationTests.java @@ -29,6 +29,7 @@ import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.data.web.ProjectedPayload; import org.springframework.graphql.Author; import org.springframework.graphql.Book; import org.springframework.graphql.BookCriteria; @@ -52,6 +53,7 @@ import static org.assertj.core.api.Assertions.assertThat; * Test GraphQL requests handled through {@code @SchemaMapping} methods. * * @author Rossen Stoyanchev + * @author Mark Paluch */ public class SchemaMappingInvocationTests { @@ -96,6 +98,40 @@ public class SchemaMappingInvocationTests { assertThat(bookList.get(1).getName()).isEqualTo("Animal Farm"); } + @Test + void queryWithProjectedArgument() { + String query = "{ " + + " booksByProjectedArguments(author:\"Orwell\") { " + + " id" + + " name" + + " }" + + "}"; + + Mono resultMono = graphQlService().execute(new RequestInput(query, null, null, null, "1")); + + List bookList = GraphQlResponse.from(resultMono).toList("booksByProjectedArguments", Book.class); + assertThat(bookList).hasSize(2); + assertThat(bookList.get(0).getName()).isEqualTo("Nineteen Eighty-Four"); + assertThat(bookList.get(1).getName()).isEqualTo("Animal Farm"); + } + + @Test + void booksByProjectedCriteria() { + String query = "{ " + + " booksByProjectedCriteria(criteria: {author:\"Orwell\"}) { " + + " id" + + " name" + + " }" + + "}"; + + Mono resultMono = graphQlService().execute(new RequestInput(query, null, null, null, "1")); + + List bookList = GraphQlResponse.from(resultMono).toList("booksByProjectedCriteria", Book.class); + assertThat(bookList).hasSize(2); + assertThat(bookList.get(0).getName()).isEqualTo("Nineteen Eighty-Four"); + assertThat(bookList.get(1).getName()).isEqualTo("Animal Farm"); + } + @Test void queryWithArgumentViaDataFetchingEnvironment() { String query = "{ " + @@ -204,6 +240,16 @@ public class SchemaMappingInvocationTests { return BookSource.findBooksByAuthor(criteria.getAuthor()); } + @QueryMapping + public List booksByProjectedArguments(BookProjection projection) { + return BookSource.findBooksByAuthor(projection.getAuthor()); + } + + @QueryMapping + public List booksByProjectedCriteria(@Argument BookProjection criteria) { + return BookSource.findBooksByAuthor(criteria.getAuthor()); + } + @SchemaMapping public CompletableFuture author(Book book, DataLoader dataLoader) { return dataLoader.load(book.getAuthorId()); @@ -227,4 +273,11 @@ public class SchemaMappingInvocationTests { } } + @ProjectedPayload + interface BookProjection { + + String getAuthor(); + + } + } diff --git a/spring-graphql/src/test/resources/books/schema.graphqls b/spring-graphql/src/test/resources/books/schema.graphqls index d2c34855..8c3c4b72 100644 --- a/spring-graphql/src/test/resources/books/schema.graphqls +++ b/spring-graphql/src/test/resources/books/schema.graphqls @@ -2,6 +2,8 @@ type Query { bookById(id: ID): Book books(id: ID, name: String, author: String): [Book] booksByCriteria(criteria:BookCriteria): [Book] + booksByProjectedArguments(name: String, author: String): [Book] + booksByProjectedCriteria(criteria:BookCriteria): [Book] authorById(id: ID): Author }