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:
Brian Clozel
2024-02-15 21:09:27 +01:00
parent 3dcbd8c2e9
commit 1f7063c9b4
19 changed files with 969 additions and 73 deletions

View File

@@ -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"
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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'
}

View File

@@ -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()));
}
}

View File

@@ -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;

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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)) {

View File

@@ -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();
}
}
}

View File

@@ -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();
});
}
}

View File

@@ -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"));
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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 {