diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java index 50fbfa6a7f..1a87f4cc0a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java @@ -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 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()); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java index 074f32452f..ff68ef1203 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java @@ -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 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()); diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java index f695221ab0..79a75b053a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java @@ -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 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())); + }; } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java index cd082e39ac..98be87ddaa 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -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())); + }; } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/schema.graphqls b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/schema.graphqls index ea9b003d91..dc80e899dd 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/schema.graphqls +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/graphql/schema.graphqls @@ -2,4 +2,8 @@ type Query { greeting(name: String! = "Spring"): String! bookById(id: ID): Book books: BookConnection +} + +type Subscription { + booksOnSale(minPages: Int) : Book! } \ No newline at end of file diff --git a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc index 656edbeaad..142ea5550c 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/asciidoc/web/spring-graphql.adoc @@ -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`.