diff --git a/settings.gradle b/settings.gradle index 697f1b0f..877f75a7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,4 +3,5 @@ include 'spring-graphql-boot-starter-webmvc' include 'spring-graphql-boot-starter-webflux' include 'spring-graphql-webmvc' include 'spring-graphql-webflux' +include 'spring-graphql-common' diff --git a/spring-graphql-common/build.gradle b/spring-graphql-common/build.gradle new file mode 100644 index 00000000..bfa44d94 --- /dev/null +++ b/spring-graphql-common/build.gradle @@ -0,0 +1,15 @@ +description = "GraphQL Java Spring Common" + +apply plugin: 'java-library' + +dependencies { + implementation "org.springframework:spring-web:$springVersion" + implementation 'io.projectreactor:reactor-core:3.3.8.RELEASE' + api "com.graphql-java:graphql-java:$graphqlJavaVersion" + + testImplementation("org.assertj:assertj-core:$assertJVersion") + testImplementation('org.junit.jupiter:junit-jupiter:5.6.2') + testImplementation "org.springframework:spring-test:$springVersion" + testImplementation group: 'com.jayway.jsonpath', name: 'json-path', version: '2.4.0' + testImplementation "org.mockito:mockito-core:2.+" +} diff --git a/spring-graphql-common/src/main/java/org/springframework/graphql/DefaultGraphQLInterceptor.java b/spring-graphql-common/src/main/java/org/springframework/graphql/DefaultGraphQLInterceptor.java new file mode 100644 index 00000000..188d5212 --- /dev/null +++ b/spring-graphql-common/src/main/java/org/springframework/graphql/DefaultGraphQLInterceptor.java @@ -0,0 +1,24 @@ +package org.springframework.graphql; + +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import org.springframework.http.HttpHeaders; +import reactor.core.publisher.Mono; + +public class DefaultGraphQLInterceptor implements GraphQLInterceptor { + + @Override + public Mono preHandle(ExecutionInput input, HttpHeaders headers) { + return Mono.just(input); + } + + @Override + public Mono postHandle(ExecutionResult result, HttpHeaders httpHeaders) { + return Mono.just(result); + } + + @Override + public Mono customizeResponseBody(GraphQLResponseBody graphQLResponseBody, ExecutionResult executionResult, HttpHeaders httpHeader) { + return Mono.just(graphQLResponseBody); + } +} \ No newline at end of file diff --git a/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLHandler.java b/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLHandler.java new file mode 100644 index 00000000..8d5e4c74 --- /dev/null +++ b/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLHandler.java @@ -0,0 +1,55 @@ +package org.springframework.graphql; + +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.GraphQL; +import org.springframework.http.HttpHeaders; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +public class GraphQLHandler { + private GraphQL graphQL; + + private GraphQLInterceptor interceptor; + + public GraphQLHandler(GraphQL graphQL, GraphQLInterceptor interceptor) { + this.graphQL = graphQL; + this.interceptor = interceptor; + } + + public Mono graphqlPOST(GraphQLRequestBody body, HttpHeaders httpHeaders) { + String query = body.getQuery(); + if (query == null) { + query = ""; + } + ExecutionInput input = ExecutionInput.newExecutionInput() + .query(query) + .operationName(body.getOperationName()) + .variables(body.getVariables()) + .build(); + Mono executionInput = interceptor.preHandle(input, httpHeaders); + return executionInput + .flatMap(this::execute) + .flatMap(result -> interceptor.postHandle(result, httpHeaders)) + .flatMap(result -> toResponseBody(result, httpHeaders)); + } + + private Mono toResponseBody(ExecutionResult executionResult, HttpHeaders httpHeaders) { + Map responseBodyRaw = executionResult.toSpecification(); + Object data = responseBodyRaw.get("data"); + List> errors = (List>) responseBodyRaw.get("errors"); + Map extensions = (Map) responseBodyRaw.get("extensions"); + GraphQLResponseBody responseBody = new GraphQLResponseBody(data, + errors, + extensions); + Mono graphQLResponseBodyMono = interceptor.customizeResponseBody(responseBody, executionResult, httpHeaders); + return graphQLResponseBodyMono; + } + + + protected Mono execute(ExecutionInput input) { + return Mono.fromCompletionStage(graphQL.executeAsync(input)); + } +} diff --git a/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLInterceptor.java b/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLInterceptor.java new file mode 100644 index 00000000..3b111e93 --- /dev/null +++ b/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLInterceptor.java @@ -0,0 +1,16 @@ +package org.springframework.graphql; + +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import org.springframework.http.HttpHeaders; +import reactor.core.publisher.Mono; + +public interface GraphQLInterceptor { + + Mono preHandle(ExecutionInput input, HttpHeaders headers); + + Mono postHandle(ExecutionResult result, HttpHeaders httpHeaders); + + Mono customizeResponseBody(GraphQLResponseBody graphQLResponseBody, ExecutionResult executionResult, HttpHeaders httpHeader); + +} \ No newline at end of file diff --git a/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/GraphQLInvocationData.java b/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLRequestBody.java similarity index 50% rename from spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/GraphQLInvocationData.java rename to spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLRequestBody.java index 76e80a8d..45dfbd11 100644 --- a/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/GraphQLInvocationData.java +++ b/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLRequestBody.java @@ -1,20 +1,16 @@ -package org.springframework.graphql.reactive; +package org.springframework.graphql; -import graphql.Assert; - -import java.util.Collections; import java.util.Map; -public class GraphQLInvocationData { - +public class GraphQLRequestBody { private final String query; private final String operationName; private final Map variables; - public GraphQLInvocationData(String query, String operationName, Map variables) { - this.query = Assert.assertNotNull(query, () -> "query must be provided"); + public GraphQLRequestBody(String query, String operationName, Map variables) { + this.query = query; this.operationName = operationName; - this.variables = variables != null ? variables : Collections.emptyMap(); + this.variables = variables; } public String getQuery() { diff --git a/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLResponseBody.java b/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLResponseBody.java new file mode 100644 index 00000000..c84fab74 --- /dev/null +++ b/spring-graphql-common/src/main/java/org/springframework/graphql/GraphQLResponseBody.java @@ -0,0 +1,29 @@ +package org.springframework.graphql; + +import java.util.List; +import java.util.Map; + +public class GraphQLResponseBody { + + private final Object data; + private final List> errors; + private final Map extensions; + + public GraphQLResponseBody(Object data, List> errors, Map extensions) { + this.data = data; + this.errors = errors; + this.extensions = extensions; + } + + public Object getData() { + return data; + } + + public List> getErrors() { + return errors; + } + + public Map getExtensions() { + return extensions; + } +} diff --git a/spring-graphql-webflux/build.gradle b/spring-graphql-webflux/build.gradle index 2e554b59..007b675e 100644 --- a/spring-graphql-webflux/build.gradle +++ b/spring-graphql-webflux/build.gradle @@ -7,6 +7,7 @@ dependencies { implementation "org.springframework:spring-context:$springVersion" implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion" api "com.graphql-java:graphql-java:$graphqlJavaVersion" + implementation project(':spring-graphql-common') testImplementation("org.assertj:assertj-core:$assertJVersion") testImplementation('org.junit.jupiter:junit-jupiter:5.6.2') diff --git a/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLController.java b/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLController.java index 68f8caba..950cd787 100644 --- a/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLController.java +++ b/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLController.java @@ -1,15 +1,22 @@ 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.reactive.GraphQLInvocationData; +import org.springframework.graphql.DefaultGraphQLInterceptor; +import org.springframework.graphql.GraphQLHandler; +import org.springframework.graphql.GraphQLInterceptor; +import org.springframework.graphql.GraphQLRequestBody; 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; @@ -18,7 +25,18 @@ import static org.springframework.web.reactive.function.server.RouterFunctions.r public class GraphQLController { @Autowired - GraphQLRequestHandler graphQLRequestHandler; + 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 routerFunction() { @@ -30,15 +48,31 @@ public class GraphQLController { } private Mono graphqlPOST(ServerRequest serverRequest) { - Mono bodyMono = serverRequest.bodyToMono(GraphQLRequestBody.class); + Mono bodyMono = serverRequest.bodyToMono(GraphQLReactiveRequestBody.class); return bodyMono.flatMap(body -> { String query = body.getQuery(); if (query == null) { query = ""; } - GraphQLInvocationData invocationData = new GraphQLInvocationData(query, body.getOperationName(), body.getVariables()); - Mono resultBodyMono = graphQLRequestHandler.invoke(invocationData, serverRequest.headers()); - return resultBodyMono.flatMap(resultBody -> ServerResponse.ok().bodyValue(resultBody)); + Map variables = body.getVariables(); + if (variables == null) { + variables = Collections.emptyMap(); + } + GraphQLRequestBody graphQLRequestBody = new GraphQLRequestBody(query, body.getOperationName(), variables); + return graphQLHandler.graphqlPOST(graphQLRequestBody, serverRequest.headers().asHttpHeaders()); + }).flatMap(graphQLResponseBody -> { + //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 responseBodyRaw = new LinkedHashMap<>(); + responseBodyRaw.put("data", graphQLResponseBody.getData()); + if (graphQLResponseBody.getErrors() != null) { + responseBodyRaw.put("errors", graphQLResponseBody.getErrors()); + } + if (graphQLResponseBody.getExtensions() != null) { + responseBodyRaw.put("extensions", graphQLResponseBody.getExtensions()); + } + return ServerResponse.ok().bodyValue(responseBodyRaw); }); } diff --git a/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLRequestBody.java b/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLReactiveRequestBody.java similarity index 94% rename from spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLRequestBody.java rename to spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLReactiveRequestBody.java index a07b2f95..f95661e2 100644 --- a/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLRequestBody.java +++ b/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLReactiveRequestBody.java @@ -2,7 +2,7 @@ package org.springframework.graphql.reactive.components; import java.util.Map; -public class GraphQLRequestBody { +public class GraphQLReactiveRequestBody { private String query; private String operationName; private Map variables; diff --git a/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLRequestHandler.java b/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLRequestHandler.java deleted file mode 100644 index 915ae7a2..00000000 --- a/spring-graphql-webflux/src/main/java/org/springframework/graphql/reactive/components/GraphQLRequestHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.springframework.graphql.reactive.components; - -import graphql.ExecutionInput; -import graphql.ExecutionResult; -import graphql.GraphQL; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.graphql.reactive.GraphQLInvocationData; -import org.springframework.stereotype.Component; -import org.springframework.util.Assert; -import org.springframework.web.reactive.function.server.ServerRequest; -import reactor.core.publisher.Mono; - -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -@Component -public class GraphQLRequestHandler { - - @Autowired - private GraphQL graphQL; - - public Mono invoke(GraphQLInvocationData invocationData, - ServerRequest.Headers headers) { - Assert.notNull(graphQL, "graphQL is not set"); - ExecutionInput executionInput = ExecutionInput.newExecutionInput() - .query(invocationData.getQuery()) - .operationName(invocationData.getOperationName()) - .variables(invocationData.getVariables()) - .build(); - customizeExecutionInput(executionInput, headers); - CompletableFuture customizedExecutionInput = customizeExecutionInput(executionInput, headers); - CompletableFuture executionResultCompletableFuture = customizedExecutionInput.thenCompose(graphQL::executeAsync); - return handleExecutionResult(executionResultCompletableFuture); - } - - protected CompletableFuture customizeExecutionInput(ExecutionInput executionInput, - ServerRequest.Headers headers) { - return CompletableFuture.completedFuture(executionInput); - } - - protected Mono handleExecutionResult(CompletableFuture executionResultCF) { - return Mono.fromCompletionStage(executionResultCF).map(ExecutionResult::toSpecification); - } -}