Add GraphQL web support

This commit removes the controller components and instead moves the web
support to specific auto-configurations.
This commit also adds tests for both web stacks.
This commit is contained in:
Brian Clozel
2020-09-16 16:34:00 +02:00
parent 9aff167527
commit 2693742b63
13 changed files with 305 additions and 254 deletions

View File

@@ -0,0 +1,36 @@
package org.springframework.boot.graphql.reactive;
import graphql.GraphQL;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.graphql.GraphQLAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.reactive.GraphQLHandler;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
@Configuration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnClass(GraphQL.class)
@ConditionalOnBean(GraphQL.Builder.class)
@AutoConfigureAfter(GraphQLAutoConfiguration.class)
public class GraphQLWebFluxAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public GraphQLHandler graphQLHandler(GraphQL.Builder graphQLBuilder) {
return new GraphQLHandler(graphQLBuilder);
}
@Bean
public RouterFunction<ServerResponse> graphQLQueryEndpoint(GraphQLHandler handler) {
return RouterFunctions.route().POST("/graphql", handler::handle).build();
}
}

View File

@@ -1,6 +1,6 @@
@NonNullApi
@NonNullFields
package org.springframework.graphql.servlet.components;
package org.springframework.boot.graphql.reactive;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -0,0 +1,41 @@
package org.springframework.boot.graphql.servlet;
import graphql.GraphQL;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.graphql.GraphQLAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.servlet.GraphQLHandler;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.RouterFunctions;
import org.springframework.web.servlet.function.ServerResponse;
import static org.springframework.web.servlet.function.RequestPredicates.accept;
@Configuration
@ConditionalOnWebApplication
@ConditionalOnClass(GraphQL.class)
@ConditionalOnBean(GraphQL.Builder.class)
@AutoConfigureAfter(GraphQLAutoConfiguration.class)
public class GraphQLWebAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public GraphQLHandler graphQLHandler(GraphQL.Builder graphQLBuilder) {
return new GraphQLHandler(graphQLBuilder);
}
@Bean
public RouterFunction<ServerResponse> graphQLQueryEndpoint(GraphQLHandler handler) {
return RouterFunctions.route()
.POST("/graphql", accept(MediaType.APPLICATION_JSON), handler::handle)
.build();
}
}

View File

@@ -1,6 +1,6 @@
@NonNullApi
@NonNullFields
package org.springframework.graphql.reactive.components;
package org.springframework.boot.graphql.servlet;
import org.springframework.lang.NonNullApi;
import org.springframework.lang.NonNullFields;

View File

@@ -1,86 +0,0 @@
package org.springframework.graphql.reactive.components;
import graphql.GraphQL;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.DefaultGraphQLInterceptor;
import org.springframework.graphql.GraphQLHandler;
import org.springframework.graphql.GraphQLHttpRequest;
import org.springframework.graphql.GraphQLInterceptor;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import javax.annotation.PostConstruct;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.springframework.web.reactive.function.server.RouterFunctions.route;
@Configuration
public class GraphQLController {
@Autowired
GraphQL graphQL;
GraphQLHandler graphQLHandler;
@Autowired(required = false)
GraphQLInterceptor graphQLInterceptor;
@PostConstruct
public void init() {
GraphQLInterceptor interceptor = graphQLInterceptor == null ? new DefaultGraphQLInterceptor() : graphQLInterceptor;
this.graphQLHandler = new GraphQLHandler(graphQL, interceptor);
}
@Bean
public RouterFunction<ServerResponse> routerFunction() {
RouterFunction<ServerResponse> route = route()
.POST("/graphql", this::graphqlPOST)
.build();
return route;
}
private Mono<ServerResponse> graphqlPOST(ServerRequest serverRequest) {
Mono<GraphQLReactiveRequestBody> bodyMono = serverRequest.bodyToMono(GraphQLReactiveRequestBody.class);
return bodyMono.flatMap(body -> {
String query = body.getQuery();
if (query == null) {
query = "";
}
Map<String, Object> variables = body.getVariables();
if (variables == null) {
variables = Collections.emptyMap();
}
GraphQLHttpRequest graphQLHttpRequest = new GraphQLHttpRequest(
query,
body.getOperationName(),
variables,
serverRequest.headers().asHttpHeaders(),
serverRequest.queryParams());
return graphQLHandler.graphqlPOST(graphQLHttpRequest);
}).flatMap(graphQLHttpResponse -> {
//TODO: this should be handled better:
// we don't want to serialize `null` values for `errors` and `extensions`
// this is why we convert it to a Map here
Map<String, Object> responseBodyRaw = new LinkedHashMap<>();
responseBodyRaw.put("data", graphQLHttpResponse.getData());
if (graphQLHttpResponse.getErrors() != null) {
responseBodyRaw.put("errors", graphQLHttpResponse.getErrors());
}
if (graphQLHttpResponse.getExtensions() != null) {
responseBodyRaw.put("extensions", graphQLHttpResponse.getExtensions());
}
return ServerResponse.ok().headers(httpHeaders -> {
httpHeaders.addAll(graphQLHttpResponse.getHttpHeaders());
}).bodyValue(responseBodyRaw);
});
}
}

View File

@@ -1,33 +0,0 @@
package org.springframework.graphql.reactive.components;
import java.util.Map;
public class GraphQLReactiveRequestBody {
private String query;
private String operationName;
private Map<String, Object> variables;
public String getQuery() {
return query;
}
public void setQuery(String query) {
this.query = query;
}
public String getOperationName() {
return operationName;
}
public void setOperationName(String operationName) {
this.operationName = operationName;
}
public Map<String, Object> getVariables() {
return variables;
}
public void setVariables(Map<String, Object> variables) {
this.variables = variables;
}
}

View File

@@ -1,100 +0,0 @@
package org.springframework.graphql.servlet.components;
import graphql.GraphQL;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.DefaultGraphQLInterceptor;
import org.springframework.graphql.GraphQLHandler;
import org.springframework.graphql.GraphQLHttpRequest;
import org.springframework.graphql.GraphQLInterceptor;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.function.RequestPredicates;
import org.springframework.web.servlet.function.RouterFunction;
import org.springframework.web.servlet.function.ServerRequest;
import org.springframework.web.servlet.function.ServerResponse;
import reactor.core.publisher.Mono;
import javax.annotation.PostConstruct;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import static org.springframework.web.servlet.function.RouterFunctions.route;
@Configuration
public class GraphQLController {
@Autowired
GraphQL graphQL;
GraphQLHandler graphQLHandler;
@Autowired(required = false)
GraphQLInterceptor graphQLInterceptor;
@PostConstruct
public void init() {
GraphQLInterceptor interceptor = graphQLInterceptor == null ? new DefaultGraphQLInterceptor() : graphQLInterceptor;
this.graphQLHandler = new GraphQLHandler(graphQL, interceptor);
}
@Bean
public RouterFunction<ServerResponse> routerFunction() {
RouterFunction<ServerResponse> route = route()
.POST("/graphql", RequestPredicates.contentType(MediaType.APPLICATION_JSON)
.or(RequestPredicates.contentType(MediaType.APPLICATION_JSON_UTF8)), this::graphqlPOST)
.build();
return route;
}
private ServerResponse graphqlPOST(ServerRequest serverRequest) {
GraphQLServletRequestBody body = null;
try {
body = serverRequest.body(GraphQLServletRequestBody.class);
} catch (ServletException | IOException e) {
e.printStackTrace();
}
String query = body.getQuery();
if (query == null) {
query = "";
}
Map<String, Object> variables = body.getVariables();
if (variables == null) {
variables = Collections.emptyMap();
}
GraphQLHttpRequest graphQLHttpRequest = new GraphQLHttpRequest(
query,
body.getOperationName(),
variables,
serverRequest.headers().asHttpHeaders(),
serverRequest.params());
Mono<Map<String, Object>> responseRawMono = graphQLHandler.graphqlPOST(graphQLHttpRequest)
.map(graphQLHttpResponse -> {
//TODO: this should be handled better:
// we don't want to serialize `null` values for `errors` and `extensions`
// this is why we convert it to a Map here
Map<String, Object> responseBodyRaw = new LinkedHashMap<>();
responseBodyRaw.put("data", graphQLHttpResponse.getData());
if (graphQLHttpResponse.getErrors() != null) {
responseBodyRaw.put("errors", graphQLHttpResponse.getErrors());
}
if (graphQLHttpResponse.getExtensions() != null) {
responseBodyRaw.put("extensions", graphQLHttpResponse.getExtensions());
}
return responseBodyRaw;
});
//TODO: how to add http headers for the response here? It is part of responseRawMono but
// ServerResponse don't accept a CompletableFuture or Mono as headers.
return ServerResponse.ok().body(responseRawMono);
}
}

View File

@@ -1,33 +0,0 @@
package org.springframework.graphql.servlet.components;
import java.util.Map;
public class GraphQLServletRequestBody {
private String query;
private String operationName;
private Map<String, Object> variables;
public String getQuery() {
return query;
}
public void setQuery(String query) {
this.query = query;
}
public String getOperationName() {
return operationName;
}
public void setOperationName(String operationName) {
this.operationName = operationName;
}
public Map<String, Object> getVariables() {
return variables;
}
public void setVariables(Map<String, Object> variables) {
this.variables = variables;
}
}

View File

@@ -0,0 +1,54 @@
package org.springframework.boot.graphql;
public class Book {
String id;
String name;
int pageCount;
String author;
public Book() {
}
public Book(String id, String name, int pageCount, String author) {
this.id = id;
this.name = name;
this.pageCount = pageCount;
this.author = author;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getPageCount() {
return pageCount;
}
public void setPageCount(int pageCount) {
this.pageCount = pageCount;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
}

View File

@@ -0,0 +1,27 @@
package org.springframework.boot.graphql;
import java.util.Arrays;
import java.util.List;
import graphql.schema.DataFetcher;
public class GraphQLDataFetchers {
private static List<Book> books = Arrays.asList(
new Book("book-1", "GraphQL for beginners", 100, "John GraphQL"),
new Book("book-2", "Harry Potter and the Philosopher's Stone", 223, "Joanne Rowling"),
new Book("book-3", "Moby Dick", 635, "Moby Dick"),
new Book("book-3", "Moby Dick", 635, "Moby Dick"));
public static DataFetcher getBookByIdDataFetcher() {
return dataFetchingEnvironment -> {
String bookId = dataFetchingEnvironment.getArgument("id");
return books
.stream()
.filter(book -> book.getId().equals(bookId))
.findFirst()
.orElse(null);
};
}
}

View File

@@ -0,0 +1,70 @@
package org.springframework.boot.graphql.reactive;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
import org.springframework.boot.graphql.GraphQLAutoConfiguration;
import org.springframework.boot.graphql.GraphQLDataFetchers;
import org.springframework.boot.graphql.RuntimeWiringCustomizer;
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import static graphql.schema.idl.TypeRuntimeWiring.newTypeWiring;
class GraphQLWebFluxEndpointTests {
@Test
void endpointHandlesGraphQLQueries() throws Exception {
new ReactiveWebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, CodecsAutoConfiguration.class,
WebFluxAutoConfiguration.class, HttpHandlerAutoConfiguration.class,
GraphQLAutoConfiguration.class, GraphQLWebFluxAutoConfiguration.class))
.withUserConfiguration(DataFetchersConfiguration.class)
.withPropertyValues("spring.main.web-application-type=reactive", "spring.graphql.schema:classpath:books/schema.graphqls").run((context) -> {
WebTestClient client = createWebTestClient(context);
String query = "{" +
" bookById(id: \\\"book-1\\\"){ " +
" id" +
" name" +
" pageCount" +
" author" +
" }" +
"}";
String body = "{" +
" \"query\": \"" + query + "\"" +
"}";
client.post().uri("/graphql").contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON)
.bodyValue(body).exchange().expectStatus().isOk()
.expectBody()
.jsonPath("data.bookById.name").isEqualTo("GraphQL for beginners");
});
}
private WebTestClient createWebTestClient(ApplicationContext context) {
return WebTestClient.bindToApplicationContext(context).configureClient().baseUrl("https://spring.example.org")
.build();
}
@Configuration(proxyBeanMethods = false)
static class DataFetchersConfiguration {
@Bean
public RuntimeWiringCustomizer bookDataFetcher() {
return (runtimeWiring) -> runtimeWiring.type(newTypeWiring("Query")
.dataFetcher("bookById", GraphQLDataFetchers.getBookByIdDataFetcher()));
}
}
}

View File

@@ -0,0 +1,65 @@
package org.springframework.boot.graphql.servlet;
import org.junit.jupiter.api.Test;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.graphql.GraphQLAutoConfiguration;
import org.springframework.boot.graphql.GraphQLDataFetchers;
import org.springframework.boot.graphql.RuntimeWiringCustomizer;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static graphql.schema.idl.TypeRuntimeWiring.newTypeWiring;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
class GraphQLWebEndpointTests {
@Test
void endpointHandlesGraphQLQueries() throws Exception {
new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
WebMvcAutoConfiguration.class, DispatcherServletAutoConfiguration.class,
GraphQLAutoConfiguration.class, GraphQLWebAutoConfiguration.class))
.withUserConfiguration(DataFetchersConfiguration.class)
.withPropertyValues("spring.main.web-application-type=servlet", "spring.graphql.schema:classpath:books/schema.graphqls").run((context) -> {
MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
String query = "{" +
" bookById(id: \\\"book-1\\\"){ " +
" id" +
" name" +
" pageCount" +
" author" +
" }" +
"}";
String body = "{" +
" \"query\": \"" + query + "\"" +
"}";
mockMvc.perform(post("/graphql").content(body).contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("data.bookById.name").value("GraphQL for beginners"));
});
}
@Configuration(proxyBeanMethods = false)
static class DataFetchersConfiguration {
@Bean
public RuntimeWiringCustomizer bookDataFetcher() {
return (runtimeWiring) -> runtimeWiring.type(newTypeWiring("Query")
.dataFetcher("bookById", GraphQLDataFetchers.getBookByIdDataFetcher()));
}
}
}

View File

@@ -0,0 +1,10 @@
type Query {
bookById(id: ID): Book
}
type Book {
id: ID
name: String
pageCount: Int
author: String
}