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
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, Object>> MAP_TYPE =
|
||||
new ParameterizedTypeReference<Map<String, Object>>() {};
|
||||
|
||||
private static final ParameterizedTypeReference<ServerSentEvent<Map<String, Object>>> SSE_TYPE =
|
||||
new ParameterizedTypeReference<ServerSentEvent<Map<String, Object>>>() {};
|
||||
|
||||
// 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<GraphQlResponse> 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()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<String, Object>> MAP_PARAMETERIZED_TYPE_REF =
|
||||
new ParameterizedTypeReference<Map<String, Object>>() {};
|
||||
|
||||
@SuppressWarnings("removal")
|
||||
private static final List<MediaType> 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;
|
||||
|
||||
|
||||
@@ -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
|
||||
* <a href="https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md">GraphQL
|
||||
* Server-Sent Events Protocol</a> 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<ServerSentEvent<Map<String, Object>>> COMPLETE_EVENT = Mono.just(ServerSentEvent.<Map<String, Object>>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<ServerResponse> handleRequest(ServerRequest serverRequest) {
|
||||
Flux<ServerSentEvent<Map<String, Object>>> 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<ExecutionResult>) 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<ServerSentEvent<Map<String, Object>>> body = data.concatWith(COMPLETE_EVENT);
|
||||
return ServerResponse.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(BodyInserters.fromServerSentEvents(body))
|
||||
.onErrorResume(Throwable.class, exc -> ServerResponse.badRequest().build());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String, HttpCookie> initCookies(ServerRequest serverRequest) {
|
||||
MultiValueMap<String, Cookie> source = serverRequest.cookies();
|
||||
MultiValueMap<String, HttpCookie> 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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<String, Object>> 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<MediaType> 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<String, HttpCookie> initCookies(ServerRequest serverRequest) {
|
||||
MultiValueMap<String, Cookie> source = serverRequest.cookies();
|
||||
MultiValueMap<String, HttpCookie> 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)) {
|
||||
|
||||
@@ -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
|
||||
* <a href="https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md">GraphQL
|
||||
* Server-Sent Events Protocol</a> 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<Map<String, Object>> 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<ExecutionResult>) 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<Map<String, Object>> {
|
||||
|
||||
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<String, Object> 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<String, Object> 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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<GraphQlResponse> 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();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<MovieCharacter> 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"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<ClientGraphQlResponse> 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<ClientGraphQlResponse> 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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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<HttpMessageWriter<?>> 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<HttpMessageWriter<?>> messageWriters() {
|
||||
return MESSAGE_WRITERS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ViewResolver> viewResolvers() {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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<HttpMessageConverter<?>> 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<HttpMessageConverter<?>> messageConverters() {
|
||||
return MESSAGE_READERS;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<Map<String, Object>> dataStream) {
|
||||
ExecutionInput input = ExecutionInput.newExecutionInput().query(document).build();
|
||||
List<DefaultExecutionGraphQlResponse> 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<String, Object> decode(String json) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user