Add WebInterceptorExecution

Arguably a better way to encapsulate support for WebInterceptor vs a
base class, more testable, and reduced public API surface.
This commit is contained in:
Rossen Stoyanchev
2020-09-18 22:24:43 +01:00
parent 40a79ab71a
commit 1f1ce72272
11 changed files with 149 additions and 33 deletions

View File

@@ -28,11 +28,13 @@ import org.springframework.web.reactive.function.server.ServerResponse;
* GraphQL handler to expose as a WebFlux.fn endpoint via
* {@link org.springframework.web.reactive.function.server.RouterFunctions}.
*/
public class WebFluxGraphQLHandler extends WebHandlerSupport implements HandlerFunction<ServerResponse> {
public class WebFluxGraphQLHandler implements HandlerFunction<ServerResponse> {
private final WebInterceptorExecution executionChain;
public WebFluxGraphQLHandler(GraphQL graphQL, List<WebInterceptor> interceptors) {
super(graphQL, interceptors);
this.executionChain = new WebInterceptorExecution(graphQL, interceptors);
}
@@ -40,7 +42,7 @@ public class WebFluxGraphQLHandler extends WebHandlerSupport implements HandlerF
return request.bodyToMono(WebInput.MAP_PARAMETERIZED_TYPE_REF)
.flatMap(body -> {
WebInput webInput = new WebInput(request.uri(), request.headers().asHttpHeaders(), body);
return executeQuery(webInput);
return this.executionChain.execute(webInput);
})
.flatMap(output -> ServerResponse.ok().bodyValue(output.toSpecification()));
}

View File

@@ -30,8 +30,8 @@ import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Represents the input to a GraphQL HTTP endpoint including URI, headers, and
* the query, operationName, and variables from the request body.
* Container for input from an HTTP request to a GraphQL endpoint, including
* URI, headers, and other inputs extracted from the body of the request.
*/
public class WebInput {

View File

@@ -18,11 +18,13 @@ package org.springframework.graphql;
import java.util.function.Consumer;
import graphql.ExecutionInput;
import graphql.GraphQL;
import reactor.core.publisher.Mono;
/**
* Allows interception of GraphQL over HTTP requests with possible customization
* of the input and the result of query execution.
* Interceptor for GraphQL over HTTP requests that allows customization of the
* {@link ExecutionInput} and the {@link graphql.ExecutionResult} of
* {@link GraphQL} query execution.
*/
public interface WebInterceptor {

View File

@@ -18,6 +18,7 @@ package org.springframework.graphql;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import graphql.ExecutionInput;
import graphql.ExecutionResult;
@@ -27,39 +28,32 @@ import reactor.core.publisher.Mono;
import org.springframework.util.CollectionUtils;
/**
* Base class for GraphQL over HTTP handlers.
* Supports the use of {@link WebInterceptor}s to customize the
* {@link ExecutionInput} and the {@link ExecutionResult} of {@link GraphQL}
* query execution.
*/
public abstract class WebHandlerSupport {
class WebInterceptorExecution {
private final GraphQL graphQL;
private final List<WebInterceptor> interceptors;
public WebHandlerSupport(GraphQL graphQL, List<WebInterceptor> interceptors) {
WebInterceptorExecution(GraphQL graphQL, List<WebInterceptor> interceptors) {
this.graphQL = graphQL;
this.interceptors = (!CollectionUtils.isEmpty(interceptors) ?
Collections.unmodifiableList(new ArrayList<>(interceptors)) : Collections.emptyList());
}
public GraphQL getGraphQL() {
return this.graphQL;
}
public List<WebInterceptor> getInterceptors() {
return this.interceptors;
}
protected Mono<WebOutput> executeQuery(WebInput webInput) {
public Mono<WebOutput> execute(WebInput webInput) {
return createInputChain(webInput).flatMap(executionInput -> {
Mono<ExecutionResult> resultMono = Mono.fromFuture(getGraphQL().executeAsync(executionInput));
return createOutputChain(resultMono);
CompletableFuture<ExecutionResult> future = this.graphQL.executeAsync(executionInput);
return createOutputChain(Mono.fromFuture(future));
});
}
protected Mono<ExecutionInput> createInputChain(WebInput webInput) {
private Mono<ExecutionInput> createInputChain(WebInput webInput) {
Mono<ExecutionInput> preHandleMono = Mono.just(webInput.toExecutionInput());
for (WebInterceptor interceptor : this.interceptors) {
preHandleMono = preHandleMono.flatMap(input -> interceptor.preHandle(input, webInput));
@@ -67,9 +61,10 @@ public abstract class WebHandlerSupport {
return preHandleMono;
}
protected Mono<WebOutput> createOutputChain(Mono<ExecutionResult> resultMono) {
private Mono<WebOutput> createOutputChain(Mono<ExecutionResult> resultMono) {
Mono<WebOutput> outputMono = resultMono.map(WebOutput::new);
for (WebInterceptor interceptor : this.interceptors) {
for (int i = this.interceptors.size() - 1 ; i >= 0; i--) {
WebInterceptor interceptor = this.interceptors.get(i);
outputMono = outputMono.flatMap(interceptor::postHandle);
}
return outputMono;

View File

@@ -35,11 +35,13 @@ import org.springframework.web.servlet.function.ServerResponse;
* GraphQL handler to expose as a WebMvc.fn endpoint via
* {@link org.springframework.web.servlet.function.RouterFunctions}.
*/
public class WebMvcGraphQLHandler extends WebHandlerSupport implements HandlerFunction<ServerResponse> {
public class WebMvcGraphQLHandler implements HandlerFunction<ServerResponse> {
private final WebInterceptorExecution executionChain;
public WebMvcGraphQLHandler(GraphQL graphQL, List<WebInterceptor> interceptors) {
super(graphQL, interceptors);
this.executionChain = new WebInterceptorExecution(graphQL, interceptors);
}
@@ -51,7 +53,7 @@ public class WebMvcGraphQLHandler extends WebHandlerSupport implements HandlerFu
*/
public ServerResponse handle(ServerRequest request) throws ServletException {
WebInput webInput = new WebInput(request.uri(), request.headers().asHttpHeaders(), readBody(request));
Mono<WebOutput> outputMono = executeQuery(webInput);
Mono<WebOutput> outputMono = this.executionChain.execute(webInput);
return ServerResponse.ok().body(outputMono.map(ExecutionResult::toSpecification));
}

View File

@@ -27,8 +27,8 @@ import org.springframework.lang.Nullable;
/**
* Simple wrapper around a GraphQL {@link ExecutionResult} that allows
* {@link #transform(Consumer) transformation} via a {@link Builder Builder}.
* {@link ExecutionResult} that wraps another in order to provide a convenient
* way to {@link #transform(Consumer) transform} it.
*/
public class WebOutput implements ExecutionResult {

View File

@@ -14,6 +14,7 @@ import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfigurat
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.GraphQLDataFetchers;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;

View File

@@ -11,6 +11,7 @@ import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguratio
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.GraphQLDataFetchers;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
@@ -84,7 +85,7 @@ class WebMvcApplicationContextTests {
@Bean
public RuntimeWiringCustomizer bookDataFetcher() {
return (runtimeWiring) -> runtimeWiring.type(newTypeWiring("Query")
return (builder) -> builder.type(newTypeWiring("Query")
.dataFetcher("bookById", GraphQLDataFetchers.getBookByIdDataFetcher()));
}
}

View File

@@ -1,4 +1,4 @@
package org.springframework.boot.graphql;
package org.springframework.graphql;
public class Book {

View File

@@ -1,4 +1,4 @@
package org.springframework.boot.graphql;
package org.springframework.graphql;
import java.util.Arrays;
import java.util.List;

View File

@@ -0,0 +1,113 @@
/*
* Copyright 2020-2020 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;
import java.io.File;
import java.net.URI;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import graphql.ExecutionInput;
import graphql.GraphQL;
import graphql.schema.GraphQLSchema;
import graphql.schema.idl.RuntimeWiring;
import graphql.schema.idl.SchemaGenerator;
import graphql.schema.idl.SchemaParser;
import graphql.schema.idl.TypeDefinitionRegistry;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.http.HttpHeaders;
import org.springframework.util.ResourceUtils;
import static graphql.schema.idl.TypeRuntimeWiring.newTypeWiring;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
/**
* Unit tests for {@link WebInterceptorExecution}.
*/
public class WebInterceptorExecutionTests {
@Test
void testInterceptorInvocation() throws Exception {
StringBuilder sb = new StringBuilder();
List<WebInterceptor> interceptors = Arrays.asList(
new TestWebInterceptor(sb, 1), new TestWebInterceptor(sb, 2), new TestWebInterceptor(sb, 3));
String query = "{" +
" bookById(id: \\\"book-1\\\"){ " +
" id" +
" name" +
" pageCount" +
" author" +
" }" +
"}";
ObjectMapper mapper = new ObjectMapper();
Map body = mapper.reader().readValue("{\"query\": \"" + query + "\"}", Map.class);
WebInput webInput = new WebInput(URI.create("/graphql"), new HttpHeaders(), body);
WebOutput webOutput = new WebInterceptorExecution(createGraphQL(), interceptors)
.execute(webInput).block();
assertEquals(":pre1:pre2:pre3:post3:post2:post1", sb.toString());
assertTrue(webOutput.isDataPresent());
}
private static GraphQL createGraphQL() throws Exception {
RuntimeWiring runtimeWiring = RuntimeWiring.newRuntimeWiring()
.type(newTypeWiring("Query").dataFetcher("bookById", GraphQLDataFetchers.getBookByIdDataFetcher()))
.build();
File file = ResourceUtils.getFile("classpath:books/schema.graphqls");
TypeDefinitionRegistry registry = new SchemaParser().parse(file);
SchemaGenerator generator = new SchemaGenerator();
GraphQLSchema schema = generator.makeExecutableSchema(registry, runtimeWiring);
return GraphQL.newGraphQL(schema).build();
}
private static class TestWebInterceptor implements WebInterceptor {
private final StringBuilder output;
private final int index;
public TestWebInterceptor(StringBuilder output, int index) {
this.output = output;
this.index = index;
}
@Override
public Mono<ExecutionInput> preHandle(ExecutionInput executionInput, WebInput webInput) {
this.output.append(":pre").append(this.index);
return Mono.delay(Duration.ofMillis(50)).map(aLong -> executionInput);
}
@Override
public Mono<WebOutput> postHandle(WebOutput webOutput) {
this.output.append(":post").append(this.index);
return Mono.delay(Duration.ofMillis(50)).map(aLong -> webOutput);
}
}
}