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
This commit is contained in:
Brian Clozel
2021-12-21 08:33:25 +01:00
parent 4ef9b9e3e5
commit 6ef1ed0dd0
5 changed files with 154 additions and 0 deletions

View File

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

View File

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

View File

@@ -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<Object> getJsonConverter(HttpMessageConverters converters) {
return converters.getConverters().stream()
.filter((candidate) -> candidate.canRead(Map.class, MediaType.APPLICATION_JSON)).findFirst()
.map((converter) -> (GenericHttpMessageConverter<Object>) 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;
}
}
}

View File

@@ -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<WebTestClient> consumer) {
this.contextRunner.run((context) -> {
WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient()

View File

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