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:
committed by
Rossen Stoyanchev
parent
4d99d2ab31
commit
5aa72f216f
@@ -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`
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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">
|
||||
* @ProjectedPayload
|
||||
* interface BookProjection {
|
||||
* String getName();
|
||||
* }
|
||||
*
|
||||
* @ProjectedPayload
|
||||
* interface BookProjection {
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user