Auto-configure SSE support for GraphQL endpoints

This commit auto-configures the newly supported SSE transport for
GraphQL endpoints in both Spring MVC and WebFlux.

Closes gh-39651
This commit is contained in:
Brian Clozel
2024-03-11 14:52:33 +01:00
parent e0ee4817d6
commit 08626d3591
6 changed files with 82 additions and 12 deletions

View File

@@ -48,6 +48,7 @@ import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.WebGraphQlInterceptor;
import org.springframework.graphql.server.webflux.GraphQlHttpHandler;
import org.springframework.graphql.server.webflux.GraphQlRequestPredicates;
import org.springframework.graphql.server.webflux.GraphQlSseHandler;
import org.springframework.graphql.server.webflux.GraphQlWebSocketHandler;
import org.springframework.graphql.server.webflux.GraphiQlHandler;
import org.springframework.graphql.server.webflux.SchemaHandler;
@@ -83,12 +84,6 @@ public class GraphQlWebFluxAutoConfiguration {
private static final Log logger = LogFactory.getLog(GraphQlWebFluxAutoConfiguration.class);
@Bean
@ConditionalOnMissingBean
public GraphQlHttpHandler graphQlHttpHandler(WebGraphQlHandler webGraphQlHandler) {
return new GraphQlHttpHandler(webGraphQlHandler);
}
@Bean
@ConditionalOnMissingBean
public WebGraphQlHandler webGraphQlHandler(ExecutionGraphQlService service,
@@ -96,14 +91,27 @@ public class GraphQlWebFluxAutoConfiguration {
return WebGraphQlHandler.builder(service).interceptors(interceptors.orderedStream().toList()).build();
}
@Bean
@ConditionalOnMissingBean
public GraphQlHttpHandler graphQlHttpHandler(WebGraphQlHandler webGraphQlHandler) {
return new GraphQlHttpHandler(webGraphQlHandler);
}
@Bean
@ConditionalOnMissingBean
public GraphQlSseHandler graphQlSseHandler(WebGraphQlHandler webGraphQlHandler) {
return new GraphQlSseHandler(webGraphQlHandler);
}
@Bean
@Order(0)
public RouterFunction<ServerResponse> graphQlRouterFunction(GraphQlHttpHandler httpHandler,
GraphQlSource graphQlSource, GraphQlProperties properties) {
GraphQlSseHandler sseHandler, GraphQlSource graphQlSource, GraphQlProperties properties) {
String path = properties.getPath();
logger.info(LogMessage.format("GraphQL endpoint HTTP POST %s", path));
RouterFunctions.Builder builder = RouterFunctions.route();
builder.route(GraphQlRequestPredicates.graphQlHttp(path), httpHandler::handleRequest);
builder.route(GraphQlRequestPredicates.graphQlSse(path), sseHandler::handleRequest);
builder = builder.GET(path, this::onlyAllowPost);
if (properties.getGraphiql().isEnabled()) {
GraphiQlHandler graphQlHandler = new GraphiQlHandler(path, properties.getWebsocket().getPath());

View File

@@ -50,6 +50,7 @@ import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.WebGraphQlInterceptor;
import org.springframework.graphql.server.webmvc.GraphQlHttpHandler;
import org.springframework.graphql.server.webmvc.GraphQlRequestPredicates;
import org.springframework.graphql.server.webmvc.GraphQlSseHandler;
import org.springframework.graphql.server.webmvc.GraphQlWebSocketHandler;
import org.springframework.graphql.server.webmvc.GraphiQlHandler;
import org.springframework.graphql.server.webmvc.SchemaHandler;
@@ -94,6 +95,12 @@ public class GraphQlWebMvcAutoConfiguration {
return new GraphQlHttpHandler(webGraphQlHandler);
}
@Bean
@ConditionalOnMissingBean
public GraphQlSseHandler graphQlSseHandler(WebGraphQlHandler webGraphQlHandler) {
return new GraphQlSseHandler(webGraphQlHandler);
}
@Bean
@ConditionalOnMissingBean
public WebGraphQlHandler webGraphQlHandler(ExecutionGraphQlService service,
@@ -104,11 +111,12 @@ public class GraphQlWebMvcAutoConfiguration {
@Bean
@Order(0)
public RouterFunction<ServerResponse> graphQlRouterFunction(GraphQlHttpHandler httpHandler,
GraphQlSource graphQlSource, GraphQlProperties properties) {
GraphQlSseHandler sseHandler, GraphQlSource graphQlSource, GraphQlProperties properties) {
String path = properties.getPath();
logger.info(LogMessage.format("GraphQL endpoint HTTP POST %s", path));
RouterFunctions.Builder builder = RouterFunctions.route();
builder.route(GraphQlRequestPredicates.graphQlHttp(path), httpHandler::handleRequest);
builder.route(GraphQlRequestPredicates.graphQlSse(path), sseHandler::handleRequest);
builder = builder.GET(path, this::onlyAllowPost);
if (properties.getGraphiql().isEnabled()) {
GraphiQlHandler graphiQLHandler = new GraphiQlHandler(path, properties.getWebsocket().getPath());

View File

@@ -44,6 +44,7 @@ import org.springframework.graphql.server.webflux.GraphQlWebSocketHandler;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.EntityExchangeResult;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.RouterFunction;
@@ -93,6 +94,30 @@ class GraphQlWebFluxAutoConfigurationTests {
});
}
@Test
void SseSubscriptionShouldWork() {
testWithWebClient((client) -> {
String query = "{ booksOnSale(minPages: 50){ id name pageCount author } }";
EntityExchangeResult<String> result = client.post()
.uri("/graphql")
.accept(MediaType.TEXT_EVENT_STREAM)
.bodyValue("{ \"query\": \"subscription TestSubscription " + query + "\"}")
.exchange()
.expectStatus()
.isOk()
.expectHeader()
.contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)
.expectBody(String.class)
.returnResult();
assertThat(result.getResponseBody()).contains("event:next",
"data:{\"data\":{\"booksOnSale\":{\"id\":\"book-1\",\"name\":\"GraphQL for beginners\",\"pageCount\":100,\"author\":\"John GraphQL\"}}}",
"event:next",
"data:{\"data\":{\"booksOnSale\":{\"id\":\"book-2\",\"name\":\"Harry Potter and the Philosopher's Stone\",\"pageCount\":223,\"author\":\"Joanne Rowling\"}}}",
"event:complete");
});
}
@Test
void httpGetQueryShouldBeSupported() {
testWithWebClient((client) -> {
@@ -233,8 +258,12 @@ class GraphQlWebFluxAutoConfigurationTests {
@Bean
RuntimeWiringConfigurer bookDataFetcher() {
return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query")
.dataFetcher("bookById", GraphQlTestDataFetchers.getBookByIdDataFetcher()));
return (builder) -> {
builder.type(TypeRuntimeWiring.newTypeWiring("Query")
.dataFetcher("bookById", GraphQlTestDataFetchers.getBookByIdDataFetcher()));
builder.type(TypeRuntimeWiring.newTypeWiring("Subscription")
.dataFetcher("booksOnSale", GraphQlTestDataFetchers.getBooksOnSaleDataFetcher()));
};
}
}

View File

@@ -93,6 +93,22 @@ class GraphQlWebMvcAutoConfigurationTests {
});
}
@Test
void SseSubscriptionShouldWork() {
testWith((mockMvc) -> {
String query = "{ booksOnSale(minPages: 50){ id name pageCount author } }";
mockMvc
.perform(post("/graphql").accept(MediaType.TEXT_EVENT_STREAM)
.content("{\"query\": \"subscription TestSubscription " + query + "\"}"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM))
.andExpect(content().string(Matchers.stringContainsInOrder("event:next",
"data:{\"data\":{\"booksOnSale\":{\"id\":\"book-1\",\"name\":\"GraphQL for beginners\",\"pageCount\":100,\"author\":\"John GraphQL\"}}}",
"event:next",
"data:{\"data\":{\"booksOnSale\":{\"id\":\"book-2\",\"name\":\"Harry Potter and the Philosopher's Stone\",\"pageCount\":223,\"author\":\"Joanne Rowling\"}}}")));
});
}
@Test
void httpGetQueryShouldBeSupported() {
testWith((mockMvc) -> {
@@ -207,8 +223,12 @@ class GraphQlWebMvcAutoConfigurationTests {
@Bean
RuntimeWiringConfigurer bookDataFetcher() {
return (builder) -> builder.type(TypeRuntimeWiring.newTypeWiring("Query")
.dataFetcher("bookById", GraphQlTestDataFetchers.getBookByIdDataFetcher()));
return (builder) -> {
builder.type(TypeRuntimeWiring.newTypeWiring("Query")
.dataFetcher("bookById", GraphQlTestDataFetchers.getBookByIdDataFetcher()));
builder.type(TypeRuntimeWiring.newTypeWiring("Subscription")
.dataFetcher("booksOnSale", GraphQlTestDataFetchers.getBooksOnSaleDataFetcher()));
};
}
}

View File

@@ -2,4 +2,8 @@ type Query {
greeting(name: String! = "Spring"): String!
bookById(id: ID): Book
books: BookConnection
}
type Subscription {
booksOnSale(minPages: Int) : Book!
}

View File

@@ -87,6 +87,7 @@ are detected by Spring Boot and considered as candidates for `DataFetcher` for m
[[web.graphql.transports.http-websocket]]
==== HTTP and WebSocket
The GraphQL HTTP endpoint is at HTTP POST `/graphql` by default.
It also supports the `"text/event-stream"` media type over Server Sent Events for subscriptions only.
The path can be customized with configprop:spring.graphql.path[].
TIP: The HTTP endpoint for both Spring MVC and Spring WebFlux is provided by a `RouterFunction` bean with an `@Order` of `0`.