Support for projected payloads

Controller methods now accept interface projections for individual or all
request arguments when Spring Data is on the class path.

See gh-202
This commit is contained in:
Mark Paluch
2021-11-26 12:54:02 +01:00
committed by Rossen Stoyanchev
parent 4d99d2ab31
commit 5aa72f216f
5 changed files with 218 additions and 0 deletions

View File

@@ -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`

View File

@@ -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());

View File

@@ -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()}.
*
* <p>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.
*
* <p>For example:
* <pre class="code">
* &#064;ProjectedPayload
* interface BookProjection {
* String getName();
* }
*
* &#064;ProjectedPayload
* interface BookProjection {
* &#064;Value("#{target.author + ' ' + target.name}")
* String getAuthorAndName();
* }
* </pre>
*
* @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);
}
}

View File

@@ -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<ExecutionResult> resultMono = graphQlService().execute(new RequestInput(query, null, null, null, "1"));
List<Book> 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<ExecutionResult> resultMono = graphQlService().execute(new RequestInput(query, null, null, null, "1"));
List<Book> 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<Book> booksByProjectedArguments(BookProjection projection) {
return BookSource.findBooksByAuthor(projection.getAuthor());
}
@QueryMapping
public List<Book> booksByProjectedCriteria(@Argument BookProjection criteria) {
return BookSource.findBooksByAuthor(criteria.getAuthor());
}
@SchemaMapping
public CompletableFuture<Author> author(Book book, DataLoader<Long, Author> dataLoader) {
return dataLoader.load(book.getAuthorId());
@@ -227,4 +273,11 @@ public class SchemaMappingInvocationTests {
}
}
@ProjectedPayload
interface BookProjection {
String getAuthor();
}
}

View File

@@ -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
}