Skip to content

Commit 1f7063c

Browse files
committed
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
1 parent 3dcbd8c commit 1f7063c

File tree

19 files changed

+969
-73
lines changed

19 files changed

+969
-73
lines changed

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ description = "Spring for GraphQL"
22

33
ext {
44
moduleProjects = [project(":spring-graphql"), project(":spring-graphql-test")]
5-
springFrameworkVersion = "6.1.3"
5+
springFrameworkVersion = "6.1.4-SNAPSHOT"
66
graphQlJavaVersion = "21.3"
77
springBootVersion = "3.2.2"
88
}

platform/build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dependencies {
1818
api(platform("io.rsocket:rsocket-bom:1.1.4"))
1919
api(platform("org.jetbrains.kotlin:kotlin-bom:${kotlinVersion}"))
2020
api(platform("org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3"))
21-
api(platform("org.junit:junit-bom:5.10.1"))
21+
api(platform("org.junit:junit-bom:5.10.2"))
2222
api(platform("org.mockito:mockito-bom:5.8.0"))
2323
api(platform("org.testcontainers:testcontainers-bom:1.19.4"))
2424
api(platform("org.apache.logging.log4j:log4j-bom:2.22.1"))
@@ -39,6 +39,8 @@ dependencies {
3939
api("org.assertj:assertj-core:3.24.2")
4040
api("com.jayway.jsonpath:json-path:2.8.0")
4141
api("org.skyscreamer:jsonassert:1.5.1")
42+
api("org.awaitility:awaitility:4.2.0")
43+
api("com.squareup.okhttp3:mockwebserver:4.12.0")
4244

4345
api("com.h2database:h2:2.1.214")
4446
api("org.hibernate:hibernate-core:6.4.2.Final")

spring-graphql-docs/modules/ROOT/pages/client.adoc

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -467,10 +467,12 @@ You can use the `GraphQlClient` xref:client.adoc#client.graphqlclient.builder[Bu
467467
[[client.subscriptions]]
468468
== Subscription Requests
469469

470-
`GraphQlClient` can execute subscriptions over transports that support it. Only
471-
the WebSocket and RSocket transports support GraphQL subscriptions, so you'll need to
472-
create a xref:client.adoc#client.websocketgraphqlclient[WebSocketGraphQlClient] or
473-
xref:client.adoc#client.rsocketgraphqlclient[RSocketGraphQlClient].
470+
Subscription requests require a client transport that is capable of streaming data.
471+
You will need to create a `GraphQlClient` that support this:
472+
473+
- xref:client.adoc#client.httpgraphqlclient[HttpGraphQlClient] with Server-Sent Events
474+
- xref:client.adoc#client.websocketgraphqlclient[WebSocketGraphQlClient] with WebSocket
475+
- xref:client.adoc#client.rsocketgraphqlclient[RSocketGraphQlClient] with RSocket
474476

475477

476478

spring-graphql-docs/modules/ROOT/pages/transports.adoc

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@ it contains, for the actual config.
3232
The 1.0.x branch of this repository contains a Spring MVC
3333
{github-10x-branch}/samples/webmvc-http[HTTP sample] application.
3434

35+
36+
[[server.transports.sse]]
37+
== Server-Sent Events
38+
39+
`GraphQlSseHandler` is very similar to the HTTP handler listed above, but this time handling GraphQL requests over HTTP
40+
using the Server-Sent Events protocol. With this transport, clients must send HTTP POST requests to the endpoint with
41+
`"application/json"` as content type and GraphQL request details included as JSON in the request body; the only
42+
difference with the vanilla HTTP variant is that the client must send `"text/event-stream"` as the `"Accept"` request
43+
header. The response will be sent as one or more Server-Sent Event(s).
44+
45+
This is also defined in the proposed
46+
https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md[GraphQL over HTTP] specification.
47+
Spring for GraphQL only implements the "Distinct connections mode", so applications must consider scalability concerns
48+
and whether adopting HTTP/2 as the underlying transport would help.
49+
50+
The main use case for `GraphQlSseHandler` is an alternative to the
51+
xref:transports.adoc#server.transports.websocket[WebSocket transport], receiving a stream of items as a response to a
52+
subscription operation. Other types of operations, like queries and mutations, are not supported here and should be
53+
using the plain JSON over HTTP transport variant.
54+
55+
3556
[[server.transports.http.fileupload]]
3657
=== File Upload
3758

spring-graphql-test/build.gradle

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ dependencies {
3232
testImplementation 'io.projectreactor.netty:reactor-netty'
3333
testImplementation 'io.rsocket:rsocket-transport-local'
3434
testImplementation 'io.micrometer:context-propagation'
35-
testImplementation 'com.squareup.okhttp3:mockwebserver:3.14.9'
3635
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
3736

3837
testRuntimeOnly 'org.apache.logging.log4j:log4j-core'

spring-graphql/build.gradle

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies {
3737
testImplementation 'org.junit.jupiter:junit-jupiter'
3838
testImplementation 'org.assertj:assertj-core'
3939
testImplementation 'org.mockito:mockito-core'
40+
testImplementation 'org.awaitility:awaitility'
4041
testImplementation 'io.projectreactor:reactor-test'
4142
testImplementation 'org.springframework:spring-core-test'
4243
testImplementation 'org.springframework:spring-messaging'
@@ -63,20 +64,22 @@ dependencies {
6364
testImplementation 'com.querydsl:querydsl-core'
6465
testImplementation 'com.querydsl:querydsl-collections'
6566
testImplementation 'jakarta.servlet:jakarta.servlet-api'
66-
testImplementation 'com.squareup.okhttp3:mockwebserver:3.14.9'
67+
testImplementation 'com.squareup.okhttp3:mockwebserver'
6768
testImplementation 'io.rsocket:rsocket-transport-local'
6869
testImplementation 'jakarta.persistence:jakarta.persistence-api'
6970
testImplementation 'jakarta.validation:jakarta.validation-api'
7071
testImplementation 'com.jayway.jsonpath:json-path'
7172
testImplementation 'com.fasterxml.jackson.core:jackson-databind'
72-
testImplementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310")
73+
testImplementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
7374
testImplementation 'org.apache.tomcat.embed:tomcat-embed-el:10.0.21'
7475
testImplementation 'com.apollographql.federation:federation-graphql-java-support'
7576

7677
testRuntimeOnly 'org.apache.logging.log4j:log4j-core'
7778
testRuntimeOnly 'org.apache.logging.log4j:log4j-slf4j-impl'
7879

7980
testFixturesApi 'org.springframework:spring-webflux'
81+
testFixturesApi 'org.junit.jupiter:junit-jupiter-engine'
82+
testFixturesApi 'com.squareup.okhttp3:mockwebserver'
8083
testFixturesApi 'com.fasterxml.jackson.core:jackson-databind'
8184
}
8285

spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2023 the original author or authors.
2+
* Copyright 2002-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@
2626
import org.springframework.graphql.GraphQlResponse;
2727
import org.springframework.http.HttpHeaders;
2828
import org.springframework.http.MediaType;
29+
import org.springframework.http.codec.ServerSentEvent;
2930
import org.springframework.util.Assert;
3031
import org.springframework.web.reactive.function.client.WebClient;
3132

@@ -37,13 +38,17 @@
3738
* see {@link WebSocketGraphQlTransport} and {@link RSocketGraphQlTransport}.
3839
*
3940
* @author Rossen Stoyanchev
41+
* @author Brian Clozel
4042
* @since 1.0.0
4143
*/
4244
final class HttpGraphQlTransport implements GraphQlTransport {
4345

4446
private static final ParameterizedTypeReference<Map<String, Object>> MAP_TYPE =
4547
new ParameterizedTypeReference<Map<String, Object>>() {};
4648

49+
private static final ParameterizedTypeReference<ServerSentEvent<Map<String, Object>>> SSE_TYPE =
50+
new ParameterizedTypeReference<ServerSentEvent<Map<String, Object>>>() {};
51+
4752
// To be removed in favor of Framework's MediaType.APPLICATION_GRAPHQL_RESPONSE
4853
private static final MediaType APPLICATION_GRAPHQL_RESPONSE =
4954
new MediaType("application", "graphql-response+json");
@@ -87,7 +92,19 @@ public Mono<GraphQlResponse> execute(GraphQlRequest request) {
8792

8893
@Override
8994
public Flux<GraphQlResponse> executeSubscription(GraphQlRequest request) {
90-
throw new UnsupportedOperationException("Subscriptions not supported over HTTP");
95+
return this.webClient.post()
96+
.contentType(this.contentType)
97+
.accept(MediaType.TEXT_EVENT_STREAM)
98+
.bodyValue(request.toMap())
99+
.attributes(attributes -> {
100+
if (request instanceof ClientGraphQlRequest clientRequest) {
101+
attributes.putAll(clientRequest.getAttributes());
102+
}
103+
})
104+
.retrieve()
105+
.bodyToFlux(SSE_TYPE)
106+
.takeWhile(event -> "next".equals(event.event()))
107+
.map(event -> new ResponseMapGraphQlResponse(event.data()));
91108
}
92109

93110
}

spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,11 @@
1818

1919
import java.util.Arrays;
2020
import java.util.List;
21-
import java.util.Map;
2221

2322
import org.apache.commons.logging.Log;
2423
import org.apache.commons.logging.LogFactory;
2524
import reactor.core.publisher.Mono;
2625

27-
import org.springframework.core.ParameterizedTypeReference;
2826
import org.springframework.graphql.server.WebGraphQlHandler;
2927
import org.springframework.graphql.server.WebGraphQlRequest;
3028
import org.springframework.graphql.server.support.SerializableGraphQlRequest;
@@ -44,16 +42,9 @@ public class GraphQlHttpHandler {
4442

4543
private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class);
4644

47-
// To be removed in favor of Framework's MediaType.APPLICATION_GRAPHQL_RESPONSE
48-
private static final MediaType APPLICATION_GRAPHQL_RESPONSE =
49-
new MediaType("application", "graphql-response+json");
50-
51-
private static final ParameterizedTypeReference<Map<String, Object>> MAP_PARAMETERIZED_TYPE_REF =
52-
new ParameterizedTypeReference<Map<String, Object>>() {};
53-
5445
@SuppressWarnings("removal")
5546
private static final List<MediaType> SUPPORTED_MEDIA_TYPES =
56-
Arrays.asList(APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL);
47+
Arrays.asList(MediaType.APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, MediaType.APPLICATION_GRAPHQL);
5748

5849
private final WebGraphQlHandler graphQlHandler;
5950

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.server.webflux;
18+
19+
20+
import java.util.Collections;
21+
import java.util.Map;
22+
23+
import graphql.ErrorType;
24+
import graphql.ExecutionResult;
25+
import graphql.GraphQLError;
26+
import org.apache.commons.logging.Log;
27+
import org.apache.commons.logging.LogFactory;
28+
import org.reactivestreams.Publisher;
29+
import reactor.core.publisher.Flux;
30+
import reactor.core.publisher.Mono;
31+
32+
import org.springframework.graphql.execution.SubscriptionPublisherException;
33+
import org.springframework.graphql.server.WebGraphQlHandler;
34+
import org.springframework.graphql.server.WebGraphQlRequest;
35+
import org.springframework.graphql.server.support.SerializableGraphQlRequest;
36+
import org.springframework.http.MediaType;
37+
import org.springframework.http.codec.ServerSentEvent;
38+
import org.springframework.util.Assert;
39+
import org.springframework.util.CollectionUtils;
40+
import org.springframework.web.reactive.function.BodyInserters;
41+
import org.springframework.web.reactive.function.server.ServerRequest;
42+
import org.springframework.web.reactive.function.server.ServerResponse;
43+
44+
/**
45+
* GraphQL handler that supports the
46+
* <a href="https://github.com/graphql/graphql-over-http/blob/main/rfcs/GraphQLOverSSE.md">GraphQL
47+
* Server-Sent Events Protocol</a> and to be exposed as a WebFlux.fn endpoint via
48+
* {@link org.springframework.web.reactive.function.server.RouterFunctions}.
49+
*
50+
* @author Brian Clozel
51+
* @since 1.3.0
52+
*/
53+
public class GraphQlSseHandler {
54+
55+
private static final Log logger = LogFactory.getLog(GraphQlSseHandler.class);
56+
57+
private static final Mono<ServerSentEvent<Map<String, Object>>> COMPLETE_EVENT = Mono.just(ServerSentEvent.<Map<String, Object>>builder().event("complete").build());
58+
59+
private final WebGraphQlHandler graphQlHandler;
60+
61+
62+
public GraphQlSseHandler(WebGraphQlHandler graphQlHandler) {
63+
Assert.notNull(graphQlHandler, "WebGraphQlHandler is required");
64+
this.graphQlHandler = graphQlHandler;
65+
}
66+
67+
/**
68+
* Handle GraphQL requests over HTTP using the Server-Sent Events protocol.
69+
*
70+
* @param serverRequest the incoming HTTP request
71+
* @return the HTTP response
72+
*/
73+
@SuppressWarnings("unchecked")
74+
public Mono<ServerResponse> handleRequest(ServerRequest serverRequest) {
75+
Flux<ServerSentEvent<Map<String, Object>>> data = serverRequest.bodyToMono(SerializableGraphQlRequest.class)
76+
.flatMap(body -> {
77+
WebGraphQlRequest graphQlRequest = new WebGraphQlRequest(
78+
serverRequest.uri(), serverRequest.headers().asHttpHeaders(),
79+
serverRequest.cookies(), serverRequest.attributes(), body,
80+
serverRequest.exchange().getRequest().getId(),
81+
serverRequest.exchange().getLocaleContext().getLocale());
82+
if (logger.isDebugEnabled()) {
83+
logger.debug("Executing: " + graphQlRequest);
84+
}
85+
return this.graphQlHandler.handleRequest(graphQlRequest);
86+
})
87+
.flatMapMany(response -> {
88+
if (logger.isDebugEnabled()) {
89+
logger.debug("Execution result ready"
90+
+ (!CollectionUtils.isEmpty(response.getErrors()) ? " with errors: " + response.getErrors() : "")
91+
+ ".");
92+
}
93+
if (response.getData() instanceof Publisher) {
94+
// Subscription
95+
return Flux.from((Publisher<ExecutionResult>) response.getData()).map(ExecutionResult::toSpecification);
96+
}
97+
if (logger.isDebugEnabled()) {
98+
logger.debug("Only subscriptions are supported, DataFetcher must return a Publisher type");
99+
}
100+
// Single response (query or mutation) are not supported
101+
String errorMessage = "SSE transport only supports Subscription operations";
102+
GraphQLError unsupportedOperationError = GraphQLError.newError().errorType(ErrorType.OperationNotSupported)
103+
.message(errorMessage).build();
104+
return Flux.error(new SubscriptionPublisherException(Collections.singletonList(unsupportedOperationError),
105+
new IllegalArgumentException(errorMessage)));
106+
})
107+
.onErrorResume(SubscriptionPublisherException.class, exc -> {
108+
ExecutionResult errorResult = ExecutionResult.newExecutionResult().errors(exc.getErrors()).build();
109+
return Flux.just(errorResult.toSpecification());
110+
})
111+
.map(event -> ServerSentEvent.builder(event).event("next").build());
112+
113+
Flux<ServerSentEvent<Map<String, Object>>> body = data.concatWith(COMPLETE_EVENT);
114+
return ServerResponse.ok().contentType(MediaType.TEXT_EVENT_STREAM).body(BodyInserters.fromServerSentEvents(body))
115+
.onErrorResume(Throwable.class, exc -> ServerResponse.badRequest().build());
116+
}
117+
118+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2020-2024 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.server.webmvc;
18+
19+
import java.io.IOException;
20+
21+
import jakarta.servlet.ServletException;
22+
import jakarta.servlet.http.Cookie;
23+
import org.apache.commons.logging.Log;
24+
import org.apache.commons.logging.LogFactory;
25+
26+
import org.springframework.graphql.GraphQlRequest;
27+
import org.springframework.graphql.server.WebGraphQlHandler;
28+
import org.springframework.graphql.server.support.SerializableGraphQlRequest;
29+
import org.springframework.http.HttpCookie;
30+
import org.springframework.util.AlternativeJdkIdGenerator;
31+
import org.springframework.util.Assert;
32+
import org.springframework.util.IdGenerator;
33+
import org.springframework.util.LinkedMultiValueMap;
34+
import org.springframework.util.MultiValueMap;
35+
import org.springframework.web.server.ServerWebInputException;
36+
import org.springframework.web.servlet.function.ServerRequest;
37+
38+
/**
39+
* Abstract class for GraphQL Handler implementations using the HTTP transport.
40+
*
41+
* @author Brian Clozel
42+
* @since 1.3.0
43+
*/
44+
abstract class AbstractGraphQlHttpHandler {
45+
46+
protected final Log logger = LogFactory.getLog(getClass());
47+
48+
protected final IdGenerator idGenerator = new AlternativeJdkIdGenerator();
49+
50+
protected final WebGraphQlHandler graphQlHandler;
51+
52+
53+
AbstractGraphQlHttpHandler(WebGraphQlHandler graphQlHandler) {
54+
Assert.notNull(graphQlHandler, "WebGraphQlHandler is required");
55+
this.graphQlHandler = graphQlHandler;
56+
}
57+
58+
protected static MultiValueMap<String, HttpCookie> initCookies(ServerRequest serverRequest) {
59+
MultiValueMap<String, Cookie> source = serverRequest.cookies();
60+
MultiValueMap<String, HttpCookie> target = new LinkedMultiValueMap<>(source.size());
61+
source.values().forEach(cookieList -> cookieList.forEach(cookie -> {
62+
HttpCookie httpCookie = new HttpCookie(cookie.getName(), cookie.getValue());
63+
target.add(cookie.getName(), httpCookie);
64+
}));
65+
return target;
66+
}
67+
68+
protected static GraphQlRequest readBody(ServerRequest request) throws ServletException {
69+
try {
70+
return request.body(SerializableGraphQlRequest.class);
71+
}
72+
catch (IOException ex) {
73+
throw new ServerWebInputException("I/O error while reading request body", null, ex);
74+
}
75+
}
76+
77+
}

0 commit comments

Comments
 (0)