From 1f7063c9b439cb2e5ea635106b004c629cadef91 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 15 Feb 2024 21:09:27 +0100 Subject: [PATCH] Add Server-Sent Events transport Prior to this commit, the WebFlux and WebMVC infrastructure would only support subscriptions over the WebSocket and RSocket transports. This commit adds the `GraphQlSseHandler` implementations for both web frameworks. This handler will send GraphQL responses as as stream of Server-Sent Events, over an HTTP response with the "text/event-stream" content type. This implementation only supports the "Distinct connections mode" and will reject all operations other than Subscriptions. This commit also enhances the `HttpGraphQlTransport` client transport to support subscriptions over this new protocol. Closes gh-309 --- build.gradle | 2 +- platform/build.gradle | 4 +- .../modules/ROOT/pages/client.adoc | 10 +- .../modules/ROOT/pages/transports.adoc | 21 +++ spring-graphql-test/build.gradle | 1 - spring-graphql/build.gradle | 7 +- .../graphql/client/HttpGraphQlTransport.java | 21 ++- .../server/webflux/GraphQlHttpHandler.java | 11 +- .../server/webflux/GraphQlSseHandler.java | 118 ++++++++++++ .../webmvc/AbstractGraphQlHttpHandler.java | 77 ++++++++ .../server/webmvc/GraphQlHttpHandler.java | 53 +----- .../server/webmvc/GraphQlSseHandler.java | 159 ++++++++++++++++ .../client/GraphQlClientTestSupport.java | 18 +- .../graphql/client/GraphQlClientTests.java | 17 ++ .../HttpGraphQlTransportIntegrationTests.java | 97 ++++++++++ .../webflux/GraphQlSseHandlerTests.java | 173 ++++++++++++++++++ .../server/webmvc/GraphQlSseHandlerTests.java | 169 +++++++++++++++++ .../graphql/MockWebServerExtension.java | 61 ++++++ .../MockExecutionGraphQlService.java | 23 +++ 19 files changed, 969 insertions(+), 73 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlSseHandler.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/AbstractGraphQlHttpHandler.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlSseHandler.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlTransportIntegrationTests.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlSseHandlerTests.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlSseHandlerTests.java create mode 100644 spring-graphql/src/testFixtures/java/org/springframework/graphql/MockWebServerExtension.java diff --git a/build.gradle b/build.gradle index cc850c0d..a26340f9 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ description = "Spring for GraphQL" ext { moduleProjects = [project(":spring-graphql"), project(":spring-graphql-test")] - springFrameworkVersion = "6.1.3" + springFrameworkVersion = "6.1.4-SNAPSHOT" graphQlJavaVersion = "21.3" springBootVersion = "3.2.2" } diff --git a/platform/build.gradle b/platform/build.gradle index 538f8a0c..8ce6db6e 100644 --- a/platform/build.gradle +++ b/platform/build.gradle @@ -18,7 +18,7 @@ dependencies { api(platform("io.rsocket:rsocket-bom:1.1.4")) api(platform("org.jetbrains.kotlin:kotlin-bom:${kotlinVersion}")) api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3")) - api(platform("org.junit:junit-bom:5.10.1")) + api(platform("org.junit:junit-bom:5.10.2")) api(platform("org.mockito:mockito-bom:5.8.0")) api(platform("org.testcontainers:testcontainers-bom:1.19.4")) api(platform("org.apache.logging.log4j:log4j-bom:2.22.1")) @@ -39,6 +39,8 @@ dependencies { api("org.assertj:assertj-core:3.24.2") api("com.jayway.jsonpath:json-path:2.8.0") api("org.skyscreamer:jsonassert:1.5.1") + api("org.awaitility:awaitility:4.2.0") + api("com.squareup.okhttp3:mockwebserver:4.12.0") api("com.h2database:h2:2.1.214") api("org.hibernate:hibernate-core:6.4.2.Final") diff --git a/spring-graphql-docs/modules/ROOT/pages/client.adoc b/spring-graphql-docs/modules/ROOT/pages/client.adoc index 0abf7a5f..af2b09ca 100644 --- a/spring-graphql-docs/modules/ROOT/pages/client.adoc +++ b/spring-graphql-docs/modules/ROOT/pages/client.adoc @@ -467,10 +467,12 @@ You can use the `GraphQlClient` xref:client.adoc#client.graphqlclient.builder[Bu [[client.subscriptions]] == Subscription Requests -`GraphQlClient` can execute subscriptions over transports that support it. Only -the WebSocket and RSocket transports support GraphQL subscriptions, so you'll need to -create a xref:client.adoc#client.websocketgraphqlclient[WebSocketGraphQlClient] or -xref:client.adoc#client.rsocketgraphqlclient[RSocketGraphQlClient]. +Subscription requests require a client transport that is capable of streaming data. +You will need to create a `GraphQlClient` that support this: + +- xref:client.adoc#client.httpgraphqlclient[HttpGraphQlClient] with Server-Sent Events +- xref:client.adoc#client.websocketgraphqlclient[WebSocketGraphQlClient] with WebSocket +- xref:client.adoc#client.rsocketgraphqlclient[RSocketGraphQlClient] with RSocket diff --git a/spring-graphql-docs/modules/ROOT/pages/transports.adoc b/spring-graphql-docs/modules/ROOT/pages/transports.adoc index a79f7782..ffb12a59 100644 --- a/spring-graphql-docs/modules/ROOT/pages/transports.adoc +++ b/spring-graphql-docs/modules/ROOT/pages/transports.adoc @@ -32,6 +32,27 @@ it contains, for the actual config. The 1.0.x branch of this repository contains a Spring MVC {github-10x-branch}/samples/webmvc-http[HTTP sample] application. + +[[server.transports.sse]] +== Server-Sent Events + +`GraphQlSseHandler` is very similar to the HTTP handler listed above, but this time handling GraphQL requests over HTTP +using the Server-Sent Events protocol. With this transport, clients must send HTTP POST requests to the endpoint with +`"application/json"` as content type and GraphQL request details included as JSON in the request body; the only +difference with the vanilla HTTP variant is that the client must send `"text/event-stream"` as the `"Accept"` request +header. The response will be sent as one or more Server-Sent Event(s). + +This is also defined in the proposed +https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md[GraphQL over HTTP] specification. +Spring for GraphQL only implements the "Distinct connections mode", so applications must consider scalability concerns +and whether adopting HTTP/2 as the underlying transport would help. + +The main use case for `GraphQlSseHandler` is an alternative to the +xref:transports.adoc#server.transports.websocket[WebSocket transport], receiving a stream of items as a response to a +subscription operation. Other types of operations, like queries and mutations, are not supported here and should be +using the plain JSON over HTTP transport variant. + + [[server.transports.http.fileupload]] === File Upload diff --git a/spring-graphql-test/build.gradle b/spring-graphql-test/build.gradle index 7c02613a..3f000703 100644 --- a/spring-graphql-test/build.gradle +++ b/spring-graphql-test/build.gradle @@ -32,7 +32,6 @@ dependencies { testImplementation 'io.projectreactor.netty:reactor-netty' testImplementation 'io.rsocket:rsocket-transport-local' testImplementation 'io.micrometer:context-propagation' - testImplementation 'com.squareup.okhttp3:mockwebserver:3.14.9' testImplementation 'com.fasterxml.jackson.core:jackson-databind' testRuntimeOnly 'org.apache.logging.log4j:log4j-core' diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index 6c92b4a5..9c4cc891 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -37,6 +37,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.assertj:assertj-core' testImplementation 'org.mockito:mockito-core' + testImplementation 'org.awaitility:awaitility' testImplementation 'io.projectreactor:reactor-test' testImplementation 'org.springframework:spring-core-test' testImplementation 'org.springframework:spring-messaging' @@ -63,13 +64,13 @@ dependencies { testImplementation 'com.querydsl:querydsl-core' testImplementation 'com.querydsl:querydsl-collections' testImplementation 'jakarta.servlet:jakarta.servlet-api' - testImplementation 'com.squareup.okhttp3:mockwebserver:3.14.9' + testImplementation 'com.squareup.okhttp3:mockwebserver' testImplementation 'io.rsocket:rsocket-transport-local' testImplementation 'jakarta.persistence:jakarta.persistence-api' testImplementation 'jakarta.validation:jakarta.validation-api' testImplementation 'com.jayway.jsonpath:json-path' testImplementation 'com.fasterxml.jackson.core:jackson-databind' - testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + testImplementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' testImplementation 'org.apache.tomcat.embed:tomcat-embed-el:10.0.21' testImplementation 'com.apollographql.federation:federation-graphql-java-support' @@ -77,6 +78,8 @@ dependencies { testRuntimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl' testFixturesApi 'org.springframework:spring-webflux' + testFixturesApi 'org.junit.jupiter:junit-jupiter-engine' + testFixturesApi 'com.squareup.okhttp3:mockwebserver' testFixturesApi 'com.fasterxml.jackson.core:jackson-databind' } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java index 70af28a9..2f11343a 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -26,6 +26,7 @@ import org.springframework.graphql.GraphQlRequest; import org.springframework.graphql.GraphQlResponse; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; import org.springframework.util.Assert; import org.springframework.web.reactive.function.client.WebClient; @@ -37,6 +38,7 @@ import org.springframework.web.reactive.function.client.WebClient; * see {@link WebSocketGraphQlTransport} and {@link RSocketGraphQlTransport}. * * @author Rossen Stoyanchev + * @author Brian Clozel * @since 1.0.0 */ final class HttpGraphQlTransport implements GraphQlTransport { @@ -44,6 +46,9 @@ final class HttpGraphQlTransport implements GraphQlTransport { private static final ParameterizedTypeReference> MAP_TYPE = new ParameterizedTypeReference>() {}; + private static final ParameterizedTypeReference>> SSE_TYPE = + new ParameterizedTypeReference>>() {}; + // To be removed in favor of Framework's MediaType.APPLICATION_GRAPHQL_RESPONSE private static final MediaType APPLICATION_GRAPHQL_RESPONSE = new MediaType("application", "graphql-response+json"); @@ -87,7 +92,19 @@ final class HttpGraphQlTransport implements GraphQlTransport { @Override public Flux executeSubscription(GraphQlRequest request) { - throw new UnsupportedOperationException("Subscriptions not supported over HTTP"); + return this.webClient.post() + .contentType(this.contentType) + .accept(MediaType.TEXT_EVENT_STREAM) + .bodyValue(request.toMap()) + .attributes(attributes -> { + if (request instanceof ClientGraphQlRequest clientRequest) { + attributes.putAll(clientRequest.getAttributes()); + } + }) + .retrieve() + .bodyToFlux(SSE_TYPE) + .takeWhile(event -> "next".equals(event.event())) + .map(event -> new ResponseMapGraphQlResponse(event.data())); } } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java index deda89f9..36bb5ad8 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java @@ -18,13 +18,11 @@ package org.springframework.graphql.server.webflux; import java.util.Arrays; import java.util.List; -import java.util.Map; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import reactor.core.publisher.Mono; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; import org.springframework.graphql.server.support.SerializableGraphQlRequest; @@ -44,16 +42,9 @@ public class GraphQlHttpHandler { private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class); - // To be removed in favor of Framework's MediaType.APPLICATION_GRAPHQL_RESPONSE - private static final MediaType APPLICATION_GRAPHQL_RESPONSE = - new MediaType("application", "graphql-response+json"); - - private static final ParameterizedTypeReference> MAP_PARAMETERIZED_TYPE_REF = - new ParameterizedTypeReference>() {}; - @SuppressWarnings("removal") private static final List SUPPORTED_MEDIA_TYPES = - Arrays.asList(APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL); + Arrays.asList(MediaType.APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL); private final WebGraphQlHandler graphQlHandler; diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlSseHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlSseHandler.java new file mode 100644 index 00000000..a71275ef --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlSseHandler.java @@ -0,0 +1,118 @@ +/* + * Copyright 2020-2024 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.server.webflux; + + +import java.util.Collections; +import java.util.Map; + +import graphql.ErrorType; +import graphql.ExecutionResult; +import graphql.GraphQLError; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.execution.SubscriptionPublisherException; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.support.SerializableGraphQlRequest; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; + +/** + * GraphQL handler that supports the + * GraphQL + * Server-Sent Events Protocol and to be exposed as a WebFlux.fn endpoint via + * {@link org.springframework.web.reactive.function.server.RouterFunctions}. + * + * @author Brian Clozel + * @since 1.3.0 + */ +public class GraphQlSseHandler { + + private static final Log logger = LogFactory.getLog(GraphQlSseHandler.class); + + private static final Mono>> COMPLETE_EVENT = Mono.just(ServerSentEvent.>builder().event("complete").build()); + + private final WebGraphQlHandler graphQlHandler; + + + public GraphQlSseHandler(WebGraphQlHandler graphQlHandler) { + Assert.notNull(graphQlHandler, "WebGraphQlHandler is required"); + this.graphQlHandler = graphQlHandler; + } + + /** + * Handle GraphQL requests over HTTP using the Server-Sent Events protocol. + * + * @param serverRequest the incoming HTTP request + * @return the HTTP response + */ + @SuppressWarnings("unchecked") + public Mono handleRequest(ServerRequest serverRequest) { + Flux>> data = serverRequest.bodyToMono(SerializableGraphQlRequest.class) + .flatMap(body -> { + WebGraphQlRequest graphQlRequest = new WebGraphQlRequest( + serverRequest.uri(), serverRequest.headers().asHttpHeaders(), + serverRequest.cookies(), serverRequest.attributes(), body, + serverRequest.exchange().getRequest().getId(), + serverRequest.exchange().getLocaleContext().getLocale()); + if (logger.isDebugEnabled()) { + logger.debug("Executing: " + graphQlRequest); + } + return this.graphQlHandler.handleRequest(graphQlRequest); + }) + .flatMapMany(response -> { + if (logger.isDebugEnabled()) { + logger.debug("Execution result ready" + + (!CollectionUtils.isEmpty(response.getErrors()) ? " with errors: " + response.getErrors() : "") + + "."); + } + if (response.getData() instanceof Publisher) { + // Subscription + return Flux.from((Publisher) response.getData()).map(ExecutionResult::toSpecification); + } + if (logger.isDebugEnabled()) { + logger.debug("Only subscriptions are supported, DataFetcher must return a Publisher type"); + } + // Single response (query or mutation) are not supported + String errorMessage = "SSE transport only supports Subscription operations"; + GraphQLError unsupportedOperationError = GraphQLError.newError().errorType(ErrorType.OperationNotSupported) + .message(errorMessage).build(); + return Flux.error(new SubscriptionPublisherException(Collections.singletonList(unsupportedOperationError), + new IllegalArgumentException(errorMessage))); + }) + .onErrorResume(SubscriptionPublisherException.class, exc -> { + ExecutionResult errorResult = ExecutionResult.newExecutionResult().errors(exc.getErrors()).build(); + return Flux.just(errorResult.toSpecification()); + }) + .map(event -> ServerSentEvent.builder(event).event("next").build()); + + Flux>> body = data.concatWith(COMPLETE_EVENT); + return ServerResponse.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(BodyInserters.fromServerSentEvents(body)) + .onErrorResume(Throwable.class, exc -> ServerResponse.badRequest().build()); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/AbstractGraphQlHttpHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/AbstractGraphQlHttpHandler.java new file mode 100644 index 00000000..4d0335fe --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/AbstractGraphQlHttpHandler.java @@ -0,0 +1,77 @@ +/* + * Copyright 2020-2024 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.server.webmvc; + +import java.io.IOException; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.graphql.GraphQlRequest; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.support.SerializableGraphQlRequest; +import org.springframework.http.HttpCookie; +import org.springframework.util.AlternativeJdkIdGenerator; +import org.springframework.util.Assert; +import org.springframework.util.IdGenerator; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.servlet.function.ServerRequest; + +/** + * Abstract class for GraphQL Handler implementations using the HTTP transport. + * + * @author Brian Clozel + * @since 1.3.0 + */ +abstract class AbstractGraphQlHttpHandler { + + protected final Log logger = LogFactory.getLog(getClass()); + + protected final IdGenerator idGenerator = new AlternativeJdkIdGenerator(); + + protected final WebGraphQlHandler graphQlHandler; + + + AbstractGraphQlHttpHandler(WebGraphQlHandler graphQlHandler) { + Assert.notNull(graphQlHandler, "WebGraphQlHandler is required"); + this.graphQlHandler = graphQlHandler; + } + + protected static MultiValueMap initCookies(ServerRequest serverRequest) { + MultiValueMap source = serverRequest.cookies(); + MultiValueMap target = new LinkedMultiValueMap<>(source.size()); + source.values().forEach(cookieList -> cookieList.forEach(cookie -> { + HttpCookie httpCookie = new HttpCookie(cookie.getName(), cookie.getValue()); + target.add(cookie.getName(), httpCookie); + })); + return target; + } + + protected static GraphQlRequest readBody(ServerRequest request) throws ServletException { + try { + return request.body(SerializableGraphQlRequest.class); + } + catch (IOException ex) { + throw new ServerWebInputException("I/O error while reading request body", null, ex); + } + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java index d87b87a9..54c796b5 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java @@ -16,33 +16,18 @@ package org.springframework.graphql.server.webmvc; -import java.io.IOException; import java.util.Arrays; import java.util.List; -import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.springframework.context.i18n.LocaleContextHolder; -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.graphql.GraphQlRequest; import org.springframework.graphql.server.WebGraphQlHandler; import org.springframework.graphql.server.WebGraphQlRequest; -import org.springframework.graphql.server.support.SerializableGraphQlRequest; -import org.springframework.http.HttpCookie; import org.springframework.http.MediaType; -import org.springframework.util.AlternativeJdkIdGenerator; -import org.springframework.util.Assert; -import org.springframework.util.IdGenerator; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; import org.springframework.web.HttpMediaTypeNotSupportedException; -import org.springframework.web.server.ServerWebInputException; import org.springframework.web.servlet.function.ServerRequest; import org.springframework.web.servlet.function.ServerResponse; @@ -54,32 +39,19 @@ import org.springframework.web.servlet.function.ServerResponse; * @author Brian Clozel * @since 1.0.0 */ -public class GraphQlHttpHandler { - - private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class); - - private static final ParameterizedTypeReference> MAP_PARAMETERIZED_TYPE_REF = - new ParameterizedTypeReference<>() {}; - - // To be removed in favor of Framework's MediaType.APPLICATION_GRAPHQL_RESPONSE - private static final MediaType APPLICATION_GRAPHQL_RESPONSE = - new MediaType("application", "graphql-response+json"); +public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler{ @SuppressWarnings("removal") private static final List SUPPORTED_MEDIA_TYPES = - Arrays.asList(APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL); + Arrays.asList(MediaType.APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL); - private final IdGenerator idGenerator = new AlternativeJdkIdGenerator(); - - private final WebGraphQlHandler graphQlHandler; /** * Create a new instance. * @param graphQlHandler common handler for GraphQL over HTTP requests */ public GraphQlHttpHandler(WebGraphQlHandler graphQlHandler) { - Assert.notNull(graphQlHandler, "WebGraphQlHandler is required"); - this.graphQlHandler = graphQlHandler; + super(graphQlHandler); } /** @@ -127,25 +99,6 @@ public class GraphQlHttpHandler { return ServerResponse.async(future); } - private static MultiValueMap initCookies(ServerRequest serverRequest) { - MultiValueMap source = serverRequest.cookies(); - MultiValueMap target = new LinkedMultiValueMap<>(source.size()); - source.values().forEach(cookieList -> cookieList.forEach(cookie -> { - HttpCookie httpCookie = new HttpCookie(cookie.getName(), cookie.getValue()); - target.add(cookie.getName(), httpCookie); - })); - return target; - } - - private static GraphQlRequest readBody(ServerRequest request) throws ServletException { - try { - return request.body(SerializableGraphQlRequest.class); - } - catch (IOException ex) { - throw new ServerWebInputException("I/O error while reading request body", null, ex); - } - } - private static MediaType selectResponseMediaType(ServerRequest serverRequest) { for (MediaType accepted : serverRequest.headers().accept()) { if (SUPPORTED_MEDIA_TYPES.contains(accepted)) { diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlSseHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlSseHandler.java new file mode 100644 index 00000000..4f51bab5 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlSseHandler.java @@ -0,0 +1,159 @@ +/* + * Copyright 2020-2024 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.server.webmvc; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + +import graphql.ErrorType; +import graphql.ExecutionResult; +import graphql.GraphQLError; +import jakarta.servlet.ServletException; +import org.reactivestreams.Publisher; +import reactor.core.publisher.BaseSubscriber; +import reactor.core.publisher.Flux; + +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.graphql.execution.SubscriptionPublisherException; +import org.springframework.graphql.server.WebGraphQlHandler; +import org.springframework.graphql.server.WebGraphQlRequest; +import org.springframework.graphql.server.WebGraphQlResponse; +import org.springframework.util.AlternativeJdkIdGenerator; +import org.springframework.util.CollectionUtils; +import org.springframework.util.IdGenerator; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; + +/** + * GraphQL handler that supports the + * GraphQL + * Server-Sent Events Protocol and to be exposed as a WebMvc functional endpoint via + * {@link org.springframework.web.servlet.function.RouterFunctions}. + * + * @author Brian Clozel + * @since 1.3.0 + */ +public class GraphQlSseHandler extends AbstractGraphQlHttpHandler { + + private final IdGenerator idGenerator = new AlternativeJdkIdGenerator(); + + + public GraphQlSseHandler(WebGraphQlHandler graphQlHandler) { + super(graphQlHandler); + } + + /** + * Handle GraphQL requests over HTTP using the Server-Sent Events protocol. + * + * @param serverRequest the incoming HTTP request + * @return the HTTP response + * @throws ServletException may be raised when reading the request body, e.g. + * {@link HttpMediaTypeNotSupportedException}. + */ + public ServerResponse handleRequest(ServerRequest serverRequest) throws ServletException { + + WebGraphQlRequest graphQlRequest = new WebGraphQlRequest( + serverRequest.uri(), serverRequest.headers().asHttpHeaders(), initCookies(serverRequest), + serverRequest.attributes(), readBody(serverRequest), this.idGenerator.generateId().toString(), + LocaleContextHolder.getLocale()); + + if (logger.isDebugEnabled()) { + logger.debug("Executing: " + graphQlRequest); + } + return ServerResponse.sse(sseBuilder -> { + this.graphQlHandler.handleRequest(graphQlRequest) + .flatMapMany(this::handleResponse) + .subscribe(new SendMessageSubscriber(graphQlRequest.getId(), sseBuilder)); + }); + } + + + @SuppressWarnings("unchecked") + private Publisher> handleResponse(WebGraphQlResponse response) { + if (logger.isDebugEnabled()) { + logger.debug("Execution result ready" + + (!CollectionUtils.isEmpty(response.getErrors()) ? " with errors: " + response.getErrors() : "") + + "."); + } + if (response.getData() instanceof Publisher) { + // Subscription + return Flux.from((Publisher) response.getData()).map(ExecutionResult::toSpecification); + } + if (logger.isDebugEnabled()) { + logger.debug("Only subscriptions are supported, DataFetcher must return a Publisher type"); + } + // Single response (query or mutation) are not supported + String errorMessage = "SSE transport only supports Subscription operations"; + GraphQLError unsupportedOperationError = GraphQLError.newError().errorType(ErrorType.OperationNotSupported) + .message(errorMessage).build(); + return Flux.error(new SubscriptionPublisherException(Collections.singletonList(unsupportedOperationError), + new IllegalArgumentException(errorMessage))); + } + + + private static class SendMessageSubscriber extends BaseSubscriber> { + + final String id; + + final ServerResponse.SseBuilder sseBuilder; + + public SendMessageSubscriber(String id, ServerResponse.SseBuilder sseBuilder) { + this.id = id; + this.sseBuilder = sseBuilder; + } + + @Override + protected void hookOnNext(Map value) { + writeNext(value); + } + + @Override + protected void hookOnError(Throwable throwable) { + if (throwable instanceof SubscriptionPublisherException subscriptionException) { + ExecutionResult errorResult = ExecutionResult.newExecutionResult().errors(subscriptionException.getErrors()).build(); + writeNext(errorResult.toSpecification()); + } + else { + this.sseBuilder.error(throwable); + } + this.hookOnComplete(); + } + + private void writeNext(Map value) { + try { + this.sseBuilder.event("next"); + this.sseBuilder.data(value); + } catch (IOException exception) { + this.onError(exception); + } + } + + @Override + protected void hookOnComplete() { + try { + this.sseBuilder.event("complete").send(); + } catch (IOException exc) { + throw new RuntimeException(exc); + } + this.sseBuilder.complete(); + } + + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTestSupport.java b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTestSupport.java index 9c017363..c1ba89e9 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTestSupport.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTestSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 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. @@ -18,6 +18,7 @@ package org.springframework.graphql.client; import java.time.Duration; +import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -26,6 +27,7 @@ import org.springframework.graphql.GraphQlRequest; import org.springframework.graphql.GraphQlResponse; import org.springframework.graphql.execution.MockExecutionGraphQlService; import org.springframework.graphql.support.DefaultExecutionGraphQlRequest; +import org.springframework.util.Assert; /** * Base class for {@link GraphQlClient} tests. @@ -83,7 +85,19 @@ public class GraphQlClientTestSupport { @Override public Flux executeSubscription(GraphQlRequest request) { - return Flux.error(new UnsupportedOperationException()); + return graphQlService.execute( + new DefaultExecutionGraphQlRequest( + request.getDocument(), + request.getOperationName(), + request.getVariables(), + request.getExtensions(), + "1", + null)) + .flatMapMany(response -> { + Assert.notNull(response.getData(), "Response Data should not be null"); + Assert.state(response.getData() instanceof Publisher, "Response Data should be a publisher"); + return response.getData(); + }); } } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java index 2ffe80ad..b47b0c26 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/GraphQlClientTests.java @@ -230,4 +230,21 @@ public class GraphQlClientTests extends GraphQlClientTestSupport { .path(ResultPath.parse(errorPath).toList()).build(); } + @Test + void executeSubscription() { + String document = "subscriptionRequest"; + getGraphQlService().setDataAsJsonStream(document, + "{\"friend\": {\"name\":\"Luke Skywalker\"}}", + "{\"friend\": {\"name\":\"Han Solo\"}}", + "{\"friend\": {\"name\":\"Leia Organa\"}}"); + + List movieCharacters = graphQlClient().document(document) + .executeSubscription() + .map(response -> response.field("friend").toEntity(MovieCharacter.class)) + .collectList().block(TIMEOUT); + + assertThat(movieCharacters).contains(MovieCharacter.create("Luke Skywalker"), MovieCharacter.create("Han Solo"), + MovieCharacter.create("Leia Organa")); + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlTransportIntegrationTests.java b/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlTransportIntegrationTests.java new file mode 100644 index 00000000..37eddff4 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/client/HttpGraphQlTransportIntegrationTests.java @@ -0,0 +1,97 @@ +/* + * Copyright 2020-2024 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.client; + + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import org.springframework.graphql.Book; +import org.springframework.graphql.MockWebServerExtension; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link HttpGraphQlTransport}. + * @author Brian Clozel + */ +@ExtendWith(MockWebServerExtension.class) +class HttpGraphQlTransportIntegrationTests { + + @Test + void shouldStreamSubscriptionResultsOverSse(MockWebServer server) { + WebClient webClient = WebClient.create(server.url("/graphql").toString()); + HttpGraphQlClient graphQlClient = HttpGraphQlClient.create(webClient); + Flux responses = graphQlClient + .document("subscription TestSubscription { bookSearch(author:\"Orwell\") { id name } ") + .executeSubscription(); + + server.enqueue(new MockResponse().addHeader("Content-Type", MediaType.TEXT_EVENT_STREAM_VALUE) + .setBody(""" + event:next + data:{"data":{"bookSearch":{"id":"1","name":"Nineteen Eighty-Four"}}} + + event:next + data:{"data":{"bookSearch":{"id":"5","name":"Animal Farm"}}} + + event:complete + + """)); + + StepVerifier.create(responses) + .assertNext(item -> assertThat(item.field("bookSearch").toEntity(Book.class).getName()).isEqualTo("Nineteen Eighty-Four")) + .assertNext(item -> assertThat(item.field("bookSearch").toEntity(Book.class).getName()).isEqualTo("Animal Farm")) + .verifyComplete(); + } + + @Test + void shouldStreamSubscriptionErrorsOverSse(MockWebServer server) { + WebClient webClient = WebClient.create(server.url("/graphql").toString()); + HttpGraphQlClient graphQlClient = HttpGraphQlClient.create(webClient); + Flux responses = graphQlClient + .document("subscription TestSubscription { bookSearch(author:\"Orwell\") { id name } ") + .executeSubscription(); + + server.enqueue(new MockResponse().addHeader("Content-Type", MediaType.TEXT_EVENT_STREAM_VALUE) + .setBody(""" + event:next + data:{"data":{"bookSearch":{"id":"1","name":"Nineteen Eighty-Four"}}} + + event:next + data:{"errors":[{"message":"Subscription error","locations":[],"extensions":{"classification":"INTERNAL_ERROR"}}]} + + event:complete + + """)); + StepVerifier.create(responses) + .assertNext(item -> assertThat(item.field("bookSearch").toEntity(Book.class).getName()).isEqualTo("Nineteen Eighty-Four")) + .assertNext(item -> { + assertThat(item.getErrors()).hasSize(1); + assertThat(item.getErrors().get(0).getErrorType().toString()).isEqualTo("INTERNAL_ERROR"); + assertThat(item.getErrors().get(0).getMessage()).isEqualTo("Subscription error"); + }) + .verifyComplete(); + } + + +} \ No newline at end of file diff --git a/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlSseHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlSseHandlerTests.java new file mode 100644 index 00000000..2d2f9792 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlSseHandlerTests.java @@ -0,0 +1,173 @@ +/* + * Copyright 2020-2024 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.server.webflux; + + +import java.util.Collections; +import java.util.List; + +import graphql.schema.DataFetcher; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.BookSource; +import org.springframework.graphql.GraphQlRequest; +import org.springframework.graphql.GraphQlSetup; +import org.springframework.graphql.server.support.SerializableGraphQlRequest; +import org.springframework.http.MediaType; +import org.springframework.http.codec.HttpMessageWriter; +import org.springframework.http.codec.ServerSentEventHttpMessageWriter; +import org.springframework.http.codec.json.Jackson2JsonEncoder; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.http.server.reactive.MockServerHttpResponse; +import org.springframework.mock.web.reactive.function.server.MockServerRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.reactive.result.view.ViewResolver; +import org.springframework.web.server.ServerWebExchange; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GraphQlSseHandler}. + * + * @author Brian Clozel + */ +class GraphQlSseHandlerTests { + + private static final List> MESSAGE_WRITERS = Collections.singletonList(new ServerSentEventHttpMessageWriter(new Jackson2JsonEncoder())); + + private static final DataFetcher BOOK_SEARCH = environment -> { + String author = environment.getArgument("author"); + return Flux.fromIterable(BookSource.books()) + .filter((book) -> book.getAuthor().getFullName().contains(author)); + }; + + private final MockServerHttpRequest httpRequest = MockServerHttpRequest.post("/graphql") + .contentType(MediaType.APPLICATION_JSON).accept(MediaType.TEXT_EVENT_STREAM).build(); + + + + @Test + void shouldRejectQueryOperations() { + SerializableGraphQlRequest request = initRequest("{ bookById(id: 42) {name} }"); + GraphQlSseHandler sseHandler = createSseHandler(BOOK_SEARCH); + MockServerHttpResponse httpResponse = handleRequest(this.httpRequest, sseHandler, request); + + assertThat(httpResponse.getHeaders().getContentType().isCompatibleWith(MediaType.TEXT_EVENT_STREAM)).isTrue(); + assertThat(httpResponse.getBodyAsString().block()).isEqualTo( + """ + event:next + data:{"errors":[{"message":"SSE transport only supports Subscription operations","locations":[],"extensions":{"classification":"OperationNotSupported"}}]} + + event:complete + + """); + } + + @Test + void shouldWriteMultipleEventsForSubscription() { + SerializableGraphQlRequest request = initRequest("subscription TestSubscription { bookSearch(author:\"Orwell\") { id name } }"); + GraphQlSseHandler sseHandler = createSseHandler(BOOK_SEARCH); + MockServerHttpResponse httpResponse = handleRequest(this.httpRequest, sseHandler, request); + + assertThat(httpResponse.getHeaders().getContentType().isCompatibleWith(MediaType.TEXT_EVENT_STREAM)).isTrue(); + assertThat(httpResponse.getBodyAsString().block()).isEqualTo( + """ + event:next + data:{"data":{"bookSearch":{"id":"1","name":"Nineteen Eighty-Four"}}} + + event:next + data:{"data":{"bookSearch":{"id":"5","name":"Animal Farm"}}} + + event:complete + + """); + } + + @Test + void shouldWriteEventsAndTerminalError() { + SerializableGraphQlRequest request = initRequest("subscription TestSubscription { bookSearch(author:\"Orwell\") { id name } }"); + DataFetcher errorDataFetcher = env -> Flux.just(BookSource.getBook(1L)) + .concatWith(Flux.error(new IllegalStateException("test error"))); + GraphQlSseHandler sseHandler = createSseHandler(errorDataFetcher); + MockServerHttpResponse httpResponse = handleRequest(this.httpRequest, sseHandler, request); + + assertThat(httpResponse.getHeaders().getContentType().isCompatibleWith(MediaType.TEXT_EVENT_STREAM)).isTrue(); + assertThat(httpResponse.getBodyAsString().block()).isEqualTo( + """ + event:next + data:{"data":{"bookSearch":{"id":"1","name":"Nineteen Eighty-Four"}}} + + event:next + data:{"errors":[{"message":"Subscription error","locations":[],"extensions":{"classification":"INTERNAL_ERROR"}}]} + + event:complete + + """); + } + + private GraphQlSseHandler createSseHandler(DataFetcher subscriptionDataFetcher) { + return new GraphQlSseHandler(GraphQlSetup.schemaResource(BookSource.schema) + .queryFetcher("bookById", (env) -> BookSource.getBookWithoutAuthor(1L)) + .subscriptionFetcher("bookSearch", subscriptionDataFetcher) + .toWebGraphQlHandler()); + } + + private static SerializableGraphQlRequest initRequest(String document) { + SerializableGraphQlRequest request = new SerializableGraphQlRequest(); + request.setQuery(document); + return request; + } + + private MockServerHttpResponse handleRequest( + MockServerHttpRequest httpRequest, GraphQlSseHandler handler, GraphQlRequest body) { + + MockServerWebExchange exchange = MockServerWebExchange.from(httpRequest); + + MockServerRequest serverRequest = MockServerRequest.builder() + .exchange(exchange) + .uri(((ServerWebExchange) exchange).getRequest().getURI()) + .method(((ServerWebExchange) exchange).getRequest().getMethod()) + .headers(((ServerWebExchange) exchange).getRequest().getHeaders()) + .body(Mono.just(body)); + + handler.handleRequest(serverRequest) + .flatMap(response -> response.writeTo(exchange, new DefaultContext())) + .block(); + + return exchange.getResponse(); + } + + + private static class DefaultContext implements ServerResponse.Context { + + @Override + public List> messageWriters() { + return MESSAGE_WRITERS; + } + + @Override + public List viewResolvers() { + return Collections.emptyList(); + } + + } + + +} \ No newline at end of file diff --git a/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlSseHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlSseHandlerTests.java new file mode 100644 index 00000000..d219aede --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlSseHandlerTests.java @@ -0,0 +1,169 @@ +/* + * Copyright 2020-2024 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.server.webmvc; + + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.List; + +import graphql.schema.DataFetcher; +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import org.springframework.graphql.BookSource; +import org.springframework.graphql.GraphQlSetup; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.servlet.function.AsyncServerResponse; +import org.springframework.web.servlet.function.ServerRequest; +import org.springframework.web.servlet.function.ServerResponse; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Tests for {@link GraphQlSseHandler}. + * + * @author Brian Clozel + */ +class GraphQlSseHandlerTests { + + private static final List> MESSAGE_READERS = + Collections.singletonList(new MappingJackson2HttpMessageConverter()); + + private static final DataFetcher BOOK_SEARCH = environment -> { + String author = environment.getArgument("author"); + return Flux.fromIterable(BookSource.books()) + .filter((book) -> book.getAuthor().getFullName().contains(author)); + }; + + @Test + void shouldRejectQueryOperations() throws Exception { + GraphQlSseHandler sseHandler = createSseHandler(BOOK_SEARCH); + MockHttpServletRequest request = createServletRequest("{ \"query\": \"{ bookById(id: 42) {name} }\"}"); + MockHttpServletResponse response = handleRequest(request, sseHandler); + + assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE); + assertThat(response.getContentAsString()).isEqualTo( + """ + event:next + data:{"errors":[{"message":"SSE transport only supports Subscription operations","locations":[],"extensions":{"classification":"OperationNotSupported"}}]} + + event:complete + + """); + } + + @Test + void shouldWriteMultipleEventsForSubscription() throws Exception { + GraphQlSseHandler sseHandler = createSseHandler(BOOK_SEARCH); + MockHttpServletRequest request = createServletRequest(""" + { + "query": "subscription TestSubscription { bookSearch(author:\\\"Orwell\\\") { id name } }" + } + """); + MockHttpServletResponse response = handleRequest(request, sseHandler); + + assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE); + assertThat(response.getContentAsString()).isEqualTo( + """ + event:next + data:{"data":{"bookSearch":{"id":"1","name":"Nineteen Eighty-Four"}}} + + event:next + data:{"data":{"bookSearch":{"id":"5","name":"Animal Farm"}}} + + event:complete + + """); + } + + @Test + void shouldWriteEventsAndTerminalError() throws Exception { + DataFetcher errorDataFetcher = env -> Flux.just(BookSource.getBook(1L)) + .concatWith(Flux.error(new IllegalStateException("test error"))); + GraphQlSseHandler sseHandler = createSseHandler(errorDataFetcher); + MockHttpServletRequest request = createServletRequest(""" + { + "query": "subscription TestSubscription { bookSearch(author:\\\"Orwell\\\") { id name } }" + } + """); + MockHttpServletResponse response = handleRequest(request, sseHandler); + + assertThat(response.getContentType()).isEqualTo(MediaType.TEXT_EVENT_STREAM_VALUE); + assertThat(response.getContentAsString()).isEqualTo( + """ + event:next + data:{"data":{"bookSearch":{"id":"1","name":"Nineteen Eighty-Four"}}} + + event:next + data:{"errors":[{"message":"Subscription error","locations":[],"extensions":{"classification":"INTERNAL_ERROR"}}]} + + event:complete + + """); + } + + private GraphQlSseHandler createSseHandler(DataFetcher subscriptionDataFetcher) { + return new GraphQlSseHandler(GraphQlSetup.schemaResource(BookSource.schema) + .queryFetcher("bookById", (env) -> BookSource.getBookWithoutAuthor(1L)) + .subscriptionFetcher("bookSearch", subscriptionDataFetcher) + .toWebGraphQlHandler()); + } + + private MockHttpServletRequest createServletRequest(String query) { + MockHttpServletRequest servletRequest = new MockHttpServletRequest("POST", "/"); + servletRequest.setContentType(MediaType.APPLICATION_JSON_VALUE); + servletRequest.setContent(query.getBytes(StandardCharsets.UTF_8)); + servletRequest.addHeader("Accept", MediaType.TEXT_EVENT_STREAM_VALUE); + servletRequest.setAsyncSupported(true); + return servletRequest; + } + + private MockHttpServletResponse handleRequest( + MockHttpServletRequest servletRequest, GraphQlSseHandler handler) throws ServletException, IOException { + + ServerRequest request = ServerRequest.create(servletRequest, MESSAGE_READERS); + ServerResponse response = handler.handleRequest(request); + if (response instanceof AsyncServerResponse asyncResponse) { + asyncResponse.block(); + } + + MockHttpServletResponse servletResponse = new MockHttpServletResponse(); + response.writeTo(servletRequest, servletResponse, new DefaultContext()); + await().atMost(Duration.ofMillis(500)).until(() -> servletResponse.getContentAsString().contains("complete")); + return servletResponse; + } + + + private static class DefaultContext implements ServerResponse.Context { + + @Override + public List> messageConverters() { + return MESSAGE_READERS; + } + + } + +} \ No newline at end of file diff --git a/spring-graphql/src/testFixtures/java/org/springframework/graphql/MockWebServerExtension.java b/spring-graphql/src/testFixtures/java/org/springframework/graphql/MockWebServerExtension.java new file mode 100644 index 00000000..4e92e15e --- /dev/null +++ b/spring-graphql/src/testFixtures/java/org/springframework/graphql/MockWebServerExtension.java @@ -0,0 +1,61 @@ +/* + * Copyright 2020-2024 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 okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; + +/** + * JUnit 5 Extension that creates, starts and stops a {@link MockWebServer} instance for each test + * and injects it as a method parameter in test methods. + * + *

Test classes should be annotated with {@code ExtendWith(MockWebServerExtension.class)} + * to use this extension. + * @author Brian Clozel + */ +public class MockWebServerExtension implements BeforeEachCallback, AfterEachCallback, ParameterResolver { + + private MockWebServer mockWebServer; + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return parameterContext.getParameter().getType() + .equals(MockWebServer.class); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return this.mockWebServer; + } + + @Override + public void beforeEach(ExtensionContext extensionContext) throws Exception { + this.mockWebServer = new MockWebServer(); + this.mockWebServer.start(); + } + + @Override + public void afterEach(ExtensionContext extensionContext) throws Exception { + this.mockWebServer.shutdown(); + } + +} diff --git a/spring-graphql/src/testFixtures/java/org/springframework/graphql/execution/MockExecutionGraphQlService.java b/spring-graphql/src/testFixtures/java/org/springframework/graphql/execution/MockExecutionGraphQlService.java index e8e11ee9..34572e9d 100644 --- a/spring-graphql/src/testFixtures/java/org/springframework/graphql/execution/MockExecutionGraphQlService.java +++ b/spring-graphql/src/testFixtures/java/org/springframework/graphql/execution/MockExecutionGraphQlService.java @@ -18,8 +18,10 @@ package org.springframework.graphql.execution; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.function.Consumer; +import java.util.stream.Stream; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -28,6 +30,7 @@ import graphql.ExecutionResult; import graphql.ExecutionResultImpl; import graphql.GraphQLError; import graphql.GraphqlErrorBuilder; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import org.springframework.graphql.ExecutionGraphQlRequest; @@ -82,6 +85,13 @@ public class MockExecutionGraphQlService implements ExecutionGraphQlService { setResponse(document, decode(dataJson)); } + /** + * Set a "data"-stream response for the given document. + */ + public void setDataAsJsonStream(String document, String... dataJson) { + setResponseStream(document, Arrays.stream(dataJson).map(this::decode)); + } + /** * Set an "errors" response for the given document. */ @@ -128,6 +138,19 @@ public class MockExecutionGraphQlService implements ExecutionGraphQlService { this.responses.put(document, new DefaultExecutionGraphQlResponse(input, result)); } + /** + * Set a response stream for the given document. + */ + @SuppressWarnings("unused") + public void setResponseStream(String document, Stream> dataStream) { + ExecutionInput input = ExecutionInput.newExecutionInput().query(document).build(); + List resultList = dataStream + .map(data -> ExecutionResult.newExecutionResult().data(data).build()) + .map(result -> new DefaultExecutionGraphQlResponse(input, result)).toList(); + this.responses.put(document, new DefaultExecutionGraphQlResponse(input, + ExecutionResult.newExecutionResult().data(Flux.fromIterable(resultList)).build())); + } + @SuppressWarnings("unchecked") private Map decode(String json) { try {