From 6ef1ed0dd0c25fcf3e90778c175d9dc99e85f5de Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 21 Dec 2021 08:33:25 +0100 Subject: [PATCH] Auto-configure GraphQL WebSocket endpoint This commit auto-configures a GraphQL WebSocket endpoint for both Spring MVC and Spring WebFlux. This is only enabled if the required libraries are on the classpath and if the `"spring.graphql.websocket.path"` property is defined. See gh-29140 --- .../graphql/GraphQlProperties.java | 38 ++++++++++++++ .../GraphQlWebFluxAutoConfiguration.java | 34 +++++++++++++ .../GraphQlWebMvcAutoConfiguration.java | 50 +++++++++++++++++++ .../GraphQlWebFluxAutoConfigurationTests.java | 16 ++++++ .../GraphQlWebMvcAutoConfigurationTests.java | 16 ++++++ 5 files changed, 154 insertions(+) diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java index c9d7e8bdfa..3df070e745 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java @@ -16,6 +16,7 @@ package org.springframework.boot.autoconfigure.graphql; +import java.time.Duration; import java.util.Arrays; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -38,6 +39,8 @@ public class GraphQlProperties { private final Schema schema = new Schema(); + private final Websocket websocket = new Websocket(); + public Graphiql getGraphiql() { return this.graphiql; } @@ -54,6 +57,10 @@ public class GraphQlProperties { return this.schema; } + public Websocket getWebsocket() { + return this.websocket; + } + public static class Schema { /** @@ -143,4 +150,35 @@ public class GraphQlProperties { } + public static class Websocket { + + /** + * Path of the GraphQL WebSocket subscription endpoint. + */ + private String path; + + /** + * Time within which the initial {@code CONNECTION_INIT} type message must be + * received. + */ + private Duration connectionInitTimeout = Duration.ofSeconds(60); + + public String getPath() { + return this.path; + } + + public void setPath(String path) { + this.path = path; + } + + public Duration getConnectionInitTimeout() { + return this.connectionInitTimeout; + } + + public void setConnectionInitTimeout(Duration connectionInitTimeout) { + this.connectionInitTimeout = connectionInitTimeout; + } + + } + } 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 3cba5822b1..b7f8136d7f 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 @@ -29,6 +29,7 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties; @@ -42,17 +43,22 @@ import org.springframework.graphql.execution.GraphQlSource; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; import org.springframework.graphql.web.webflux.GraphQlHttpHandler; +import org.springframework.graphql.web.webflux.GraphQlWebSocketHandler; import org.springframework.graphql.web.webflux.GraphiQlHandler; import org.springframework.graphql.web.webflux.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.HandlerMapping; import org.springframework.web.reactive.config.CorsRegistry; import org.springframework.web.reactive.config.WebFluxConfigurer; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.RouterFunctions; import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping; +import org.springframework.web.reactive.socket.server.support.WebSocketUpgradeHandlerPredicate; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; import static org.springframework.web.reactive.function.server.RequestPredicates.contentType; @@ -139,4 +145,32 @@ public class GraphQlWebFluxAutoConfiguration { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(prefix = "spring.graphql.websocket", name = "path") + public static class WebSocketConfiguration { + + @Bean + @ConditionalOnMissingBean + public GraphQlWebSocketHandler graphQlWebSocketHandler(WebGraphQlHandler webGraphQlHandler, + GraphQlProperties properties, ServerCodecConfigurer configurer) { + return new GraphQlWebSocketHandler(webGraphQlHandler, configurer, + properties.getWebsocket().getConnectionInitTimeout()); + } + + @Bean + public HandlerMapping graphQlWebSocketEndpoint(GraphQlWebSocketHandler graphQlWebSocketHandler, + GraphQlProperties properties) { + String path = properties.getWebsocket().getPath(); + if (logger.isInfoEnabled()) { + logger.info("GraphQL endpoint WebSocket " + path); + } + SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(); + mapping.setHandlerPredicate(new WebSocketUpgradeHandlerPredicate()); + mapping.setUrlMap(Collections.singletonMap(path, graphQlWebSocketHandler)); + mapping.setOrder(-2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean) + return mapping; + } + + } + } 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 2c7ef2b9fe..fd5a2210fe 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 @@ -17,8 +17,11 @@ package org.springframework.boot.autoconfigure.graphql.servlet; import java.util.Collections; +import java.util.Map; import java.util.stream.Collectors; +import javax.websocket.server.ServerContainer; + import graphql.GraphQL; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -29,10 +32,12 @@ import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration; import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties; import org.springframework.boot.autoconfigure.graphql.GraphQlProperties; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -43,18 +48,25 @@ import org.springframework.graphql.execution.ThreadLocalAccessor; import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; import org.springframework.graphql.web.webmvc.GraphQlHttpHandler; +import org.springframework.graphql.web.webmvc.GraphQlWebSocketHandler; import org.springframework.graphql.web.webmvc.GraphiQlHandler; import org.springframework.graphql.web.webmvc.SchemaHandler; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.http.converter.GenericHttpMessageConverter; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.function.RequestPredicates; import org.springframework.web.servlet.function.RouterFunction; import org.springframework.web.servlet.function.RouterFunctions; import org.springframework.web.servlet.function.ServerResponse; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; +import org.springframework.web.socket.server.support.WebSocketHandlerMapping; +import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler; /** * {@link EnableAutoConfiguration Auto-configuration} for enabling Spring GraphQL over @@ -140,4 +152,42 @@ public class GraphQlWebMvcAutoConfiguration { } + @Configuration(proxyBeanMethods = false) + @ConditionalOnClass({ ServerContainer.class, WebSocketHandler.class }) + @ConditionalOnProperty(prefix = "spring.graphql.websocket", name = "path") + public static class WebSocketConfiguration { + + @Bean + @ConditionalOnMissingBean + public GraphQlWebSocketHandler graphQlWebSocketHandler(WebGraphQlHandler webGraphQlHandler, + GraphQlProperties properties, HttpMessageConverters converters) { + + return new GraphQlWebSocketHandler(webGraphQlHandler, getJsonConverter(converters), + properties.getWebsocket().getConnectionInitTimeout()); + } + + @SuppressWarnings("unchecked") + private static GenericHttpMessageConverter getJsonConverter(HttpMessageConverters converters) { + return converters.getConverters().stream() + .filter((candidate) -> candidate.canRead(Map.class, MediaType.APPLICATION_JSON)).findFirst() + .map((converter) -> (GenericHttpMessageConverter) converter) + .orElseThrow(() -> new IllegalStateException("No JSON converter")); + } + + @Bean + public HandlerMapping graphQlWebSocketMapping(GraphQlWebSocketHandler handler, GraphQlProperties properties) { + String path = properties.getWebsocket().getPath(); + if (logger.isInfoEnabled()) { + logger.info("GraphQL endpoint WebSocket " + path); + } + WebSocketHandlerMapping mapping = new WebSocketHandlerMapping(); + mapping.setWebSocketUpgradeMatch(true); + mapping.setUrlMap(Collections.singletonMap(path, + new WebSocketHttpRequestHandler(handler, new DefaultHandshakeHandler()))); + mapping.setOrder(2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean) + return mapping; + } + + } + } 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 d1a4f9433e..64aadfe805 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 @@ -33,12 +33,16 @@ import org.springframework.boot.test.context.runner.ReactiveWebApplicationContex import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; +import org.springframework.graphql.web.webflux.GraphQlHttpHandler; +import org.springframework.graphql.web.webflux.GraphQlWebSocketHandler; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; /** @@ -60,6 +64,12 @@ class GraphQlWebFluxAutoConfigurationTests { "spring.graphql.cors.allowed-origins=https://example.com", "spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true"); + @Test + void shouldContributeDefaultBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GraphQlHttpHandler.class) + .hasSingleBean(WebGraphQlHandler.class).doesNotHaveBean(GraphQlWebSocketHandler.class)); + } + @Test void simpleQueryShouldWork() { testWithWebClient((client) -> { @@ -130,6 +140,12 @@ class GraphQlWebFluxAutoConfigurationTests { }); } + @Test + void shouldConfigureWebSocketBeans() { + this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws") + .run((context) -> assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class)); + } + private void testWithWebClient(Consumer consumer) { this.contextRunner.run((context) -> { WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient() 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 838662c67e..3e1f57a16a 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 @@ -31,13 +31,17 @@ import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.graphql.execution.RuntimeWiringConfigurer; +import org.springframework.graphql.web.WebGraphQlHandler; import org.springframework.graphql.web.WebInterceptor; +import org.springframework.graphql.web.webmvc.GraphQlHttpHandler; +import org.springframework.graphql.web.webmvc.GraphQlWebSocketHandler; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -65,6 +69,12 @@ class GraphQlWebMvcAutoConfigurationTests { "spring.graphql.cors.allowed-origins=https://example.com", "spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true"); + @Test + void shouldContributeDefaultBeans() { + this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GraphQlHttpHandler.class) + .hasSingleBean(WebGraphQlHandler.class).doesNotHaveBean(GraphQlWebSocketHandler.class)); + } + @Test void simpleQueryShouldWork() { testWith((mockMvc) -> { @@ -137,6 +147,12 @@ class GraphQlWebMvcAutoConfigurationTests { }); } + @Test + void shouldConfigureWebSocketBeans() { + this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws") + .run((context) -> assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class)); + } + private void testWith(MockMvcConsumer mockMvcConsumer) { this.contextRunner.run((context) -> { MediaType mediaType = MediaType.APPLICATION_JSON;