Skip to content

Commit 6ef1ed0

Browse files
committed
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
1 parent 4ef9b9e commit 6ef1ed0

File tree

5 files changed

+154
-0
lines changed

5 files changed

+154
-0
lines changed

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/GraphQlProperties.java

+38
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package org.springframework.boot.autoconfigure.graphql;
1818

19+
import java.time.Duration;
1920
import java.util.Arrays;
2021

2122
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -38,6 +39,8 @@ public class GraphQlProperties {
3839

3940
private final Schema schema = new Schema();
4041

42+
private final Websocket websocket = new Websocket();
43+
4144
public Graphiql getGraphiql() {
4245
return this.graphiql;
4346
}
@@ -54,6 +57,10 @@ public Schema getSchema() {
5457
return this.schema;
5558
}
5659

60+
public Websocket getWebsocket() {
61+
return this.websocket;
62+
}
63+
5764
public static class Schema {
5865

5966
/**
@@ -143,4 +150,35 @@ public void setEnabled(boolean enabled) {
143150

144151
}
145152

153+
public static class Websocket {
154+
155+
/**
156+
* Path of the GraphQL WebSocket subscription endpoint.
157+
*/
158+
private String path;
159+
160+
/**
161+
* Time within which the initial {@code CONNECTION_INIT} type message must be
162+
* received.
163+
*/
164+
private Duration connectionInitTimeout = Duration.ofSeconds(60);
165+
166+
public String getPath() {
167+
return this.path;
168+
}
169+
170+
public void setPath(String path) {
171+
this.path = path;
172+
}
173+
174+
public Duration getConnectionInitTimeout() {
175+
return this.connectionInitTimeout;
176+
}
177+
178+
public void setConnectionInitTimeout(Duration connectionInitTimeout) {
179+
this.connectionInitTimeout = connectionInitTimeout;
180+
}
181+
182+
}
183+
146184
}

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfiguration.java

+34
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
3030
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
3131
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
32+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
3233
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
3334
import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
3435
import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties;
@@ -42,17 +43,22 @@
4243
import org.springframework.graphql.web.WebGraphQlHandler;
4344
import org.springframework.graphql.web.WebInterceptor;
4445
import org.springframework.graphql.web.webflux.GraphQlHttpHandler;
46+
import org.springframework.graphql.web.webflux.GraphQlWebSocketHandler;
4547
import org.springframework.graphql.web.webflux.GraphiQlHandler;
4648
import org.springframework.graphql.web.webflux.SchemaHandler;
4749
import org.springframework.http.HttpMethod;
4850
import org.springframework.http.HttpStatus;
4951
import org.springframework.http.MediaType;
52+
import org.springframework.http.codec.ServerCodecConfigurer;
5053
import org.springframework.web.cors.CorsConfiguration;
54+
import org.springframework.web.reactive.HandlerMapping;
5155
import org.springframework.web.reactive.config.CorsRegistry;
5256
import org.springframework.web.reactive.config.WebFluxConfigurer;
5357
import org.springframework.web.reactive.function.server.RouterFunction;
5458
import org.springframework.web.reactive.function.server.RouterFunctions;
5559
import org.springframework.web.reactive.function.server.ServerResponse;
60+
import org.springframework.web.reactive.handler.SimpleUrlHandlerMapping;
61+
import org.springframework.web.reactive.socket.server.support.WebSocketUpgradeHandlerPredicate;
5662

5763
import static org.springframework.web.reactive.function.server.RequestPredicates.accept;
5864
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
@@ -139,4 +145,32 @@ public void addCorsMappings(CorsRegistry registry) {
139145

140146
}
141147

148+
@Configuration(proxyBeanMethods = false)
149+
@ConditionalOnProperty(prefix = "spring.graphql.websocket", name = "path")
150+
public static class WebSocketConfiguration {
151+
152+
@Bean
153+
@ConditionalOnMissingBean
154+
public GraphQlWebSocketHandler graphQlWebSocketHandler(WebGraphQlHandler webGraphQlHandler,
155+
GraphQlProperties properties, ServerCodecConfigurer configurer) {
156+
return new GraphQlWebSocketHandler(webGraphQlHandler, configurer,
157+
properties.getWebsocket().getConnectionInitTimeout());
158+
}
159+
160+
@Bean
161+
public HandlerMapping graphQlWebSocketEndpoint(GraphQlWebSocketHandler graphQlWebSocketHandler,
162+
GraphQlProperties properties) {
163+
String path = properties.getWebsocket().getPath();
164+
if (logger.isInfoEnabled()) {
165+
logger.info("GraphQL endpoint WebSocket " + path);
166+
}
167+
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
168+
mapping.setHandlerPredicate(new WebSocketUpgradeHandlerPredicate());
169+
mapping.setUrlMap(Collections.singletonMap(path, graphQlWebSocketHandler));
170+
mapping.setOrder(-2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean)
171+
return mapping;
172+
}
173+
174+
}
175+
142176
}

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfiguration.java

+50
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,11 @@
1717
package org.springframework.boot.autoconfigure.graphql.servlet;
1818

1919
import java.util.Collections;
20+
import java.util.Map;
2021
import java.util.stream.Collectors;
2122

23+
import javax.websocket.server.ServerContainer;
24+
2225
import graphql.GraphQL;
2326
import org.apache.commons.logging.Log;
2427
import org.apache.commons.logging.LogFactory;
@@ -29,10 +32,12 @@
2932
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
3033
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
3134
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
35+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
3236
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
3337
import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
3438
import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties;
3539
import org.springframework.boot.autoconfigure.graphql.GraphQlProperties;
40+
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
3641
import org.springframework.boot.context.properties.EnableConfigurationProperties;
3742
import org.springframework.context.annotation.Bean;
3843
import org.springframework.context.annotation.Configuration;
@@ -43,18 +48,25 @@
4348
import org.springframework.graphql.web.WebGraphQlHandler;
4449
import org.springframework.graphql.web.WebInterceptor;
4550
import org.springframework.graphql.web.webmvc.GraphQlHttpHandler;
51+
import org.springframework.graphql.web.webmvc.GraphQlWebSocketHandler;
4652
import org.springframework.graphql.web.webmvc.GraphiQlHandler;
4753
import org.springframework.graphql.web.webmvc.SchemaHandler;
4854
import org.springframework.http.HttpMethod;
4955
import org.springframework.http.HttpStatus;
5056
import org.springframework.http.MediaType;
57+
import org.springframework.http.converter.GenericHttpMessageConverter;
5158
import org.springframework.web.cors.CorsConfiguration;
59+
import org.springframework.web.servlet.HandlerMapping;
5260
import org.springframework.web.servlet.config.annotation.CorsRegistry;
5361
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
5462
import org.springframework.web.servlet.function.RequestPredicates;
5563
import org.springframework.web.servlet.function.RouterFunction;
5664
import org.springframework.web.servlet.function.RouterFunctions;
5765
import org.springframework.web.servlet.function.ServerResponse;
66+
import org.springframework.web.socket.WebSocketHandler;
67+
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;
68+
import org.springframework.web.socket.server.support.WebSocketHandlerMapping;
69+
import org.springframework.web.socket.server.support.WebSocketHttpRequestHandler;
5870

5971
/**
6072
* {@link EnableAutoConfiguration Auto-configuration} for enabling Spring GraphQL over
@@ -140,4 +152,42 @@ public void addCorsMappings(CorsRegistry registry) {
140152

141153
}
142154

155+
@Configuration(proxyBeanMethods = false)
156+
@ConditionalOnClass({ ServerContainer.class, WebSocketHandler.class })
157+
@ConditionalOnProperty(prefix = "spring.graphql.websocket", name = "path")
158+
public static class WebSocketConfiguration {
159+
160+
@Bean
161+
@ConditionalOnMissingBean
162+
public GraphQlWebSocketHandler graphQlWebSocketHandler(WebGraphQlHandler webGraphQlHandler,
163+
GraphQlProperties properties, HttpMessageConverters converters) {
164+
165+
return new GraphQlWebSocketHandler(webGraphQlHandler, getJsonConverter(converters),
166+
properties.getWebsocket().getConnectionInitTimeout());
167+
}
168+
169+
@SuppressWarnings("unchecked")
170+
private static GenericHttpMessageConverter<Object> getJsonConverter(HttpMessageConverters converters) {
171+
return converters.getConverters().stream()
172+
.filter((candidate) -> candidate.canRead(Map.class, MediaType.APPLICATION_JSON)).findFirst()
173+
.map((converter) -> (GenericHttpMessageConverter<Object>) converter)
174+
.orElseThrow(() -> new IllegalStateException("No JSON converter"));
175+
}
176+
177+
@Bean
178+
public HandlerMapping graphQlWebSocketMapping(GraphQlWebSocketHandler handler, GraphQlProperties properties) {
179+
String path = properties.getWebsocket().getPath();
180+
if (logger.isInfoEnabled()) {
181+
logger.info("GraphQL endpoint WebSocket " + path);
182+
}
183+
WebSocketHandlerMapping mapping = new WebSocketHandlerMapping();
184+
mapping.setWebSocketUpgradeMatch(true);
185+
mapping.setUrlMap(Collections.singletonMap(path,
186+
new WebSocketHttpRequestHandler(handler, new DefaultHandshakeHandler())));
187+
mapping.setOrder(2); // Ahead of HTTP endpoint ("routerFunctionMapping" bean)
188+
return mapping;
189+
}
190+
191+
}
192+
143193
}

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/reactive/GraphQlWebFluxAutoConfigurationTests.java

+16
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,16 @@
3333
import org.springframework.context.annotation.Bean;
3434
import org.springframework.context.annotation.Configuration;
3535
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
36+
import org.springframework.graphql.web.WebGraphQlHandler;
3637
import org.springframework.graphql.web.WebInterceptor;
38+
import org.springframework.graphql.web.webflux.GraphQlHttpHandler;
39+
import org.springframework.graphql.web.webflux.GraphQlWebSocketHandler;
3740
import org.springframework.http.HttpHeaders;
3841
import org.springframework.http.HttpStatus;
3942
import org.springframework.http.MediaType;
4043
import org.springframework.test.web.reactive.server.WebTestClient;
4144

45+
import static org.assertj.core.api.Assertions.assertThat;
4246
import static org.hamcrest.Matchers.containsString;
4347

4448
/**
@@ -60,6 +64,12 @@ class GraphQlWebFluxAutoConfigurationTests {
6064
"spring.graphql.cors.allowed-origins=https://example.com",
6165
"spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true");
6266

67+
@Test
68+
void shouldContributeDefaultBeans() {
69+
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GraphQlHttpHandler.class)
70+
.hasSingleBean(WebGraphQlHandler.class).doesNotHaveBean(GraphQlWebSocketHandler.class));
71+
}
72+
6373
@Test
6474
void simpleQueryShouldWork() {
6575
testWithWebClient((client) -> {
@@ -130,6 +140,12 @@ void shouldSupportCors() {
130140
});
131141
}
132142

143+
@Test
144+
void shouldConfigureWebSocketBeans() {
145+
this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws")
146+
.run((context) -> assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class));
147+
}
148+
133149
private void testWithWebClient(Consumer<WebTestClient> consumer) {
134150
this.contextRunner.run((context) -> {
135151
WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient()

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/graphql/servlet/GraphQlWebMvcAutoConfigurationTests.java

+16
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,17 @@
3131
import org.springframework.context.annotation.Bean;
3232
import org.springframework.context.annotation.Configuration;
3333
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
34+
import org.springframework.graphql.web.WebGraphQlHandler;
3435
import org.springframework.graphql.web.WebInterceptor;
36+
import org.springframework.graphql.web.webmvc.GraphQlHttpHandler;
37+
import org.springframework.graphql.web.webmvc.GraphQlWebSocketHandler;
3538
import org.springframework.http.HttpHeaders;
3639
import org.springframework.http.MediaType;
3740
import org.springframework.test.web.servlet.MockMvc;
3841
import org.springframework.test.web.servlet.MvcResult;
3942
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
4043

44+
import static org.assertj.core.api.Assertions.assertThat;
4145
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
4246
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
4347
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
@@ -65,6 +69,12 @@ class GraphQlWebMvcAutoConfigurationTests {
6569
"spring.graphql.cors.allowed-origins=https://example.com",
6670
"spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true");
6771

72+
@Test
73+
void shouldContributeDefaultBeans() {
74+
this.contextRunner.run((context) -> assertThat(context).hasSingleBean(GraphQlHttpHandler.class)
75+
.hasSingleBean(WebGraphQlHandler.class).doesNotHaveBean(GraphQlWebSocketHandler.class));
76+
}
77+
6878
@Test
6979
void simpleQueryShouldWork() {
7080
testWith((mockMvc) -> {
@@ -137,6 +147,12 @@ void shouldSupportCors() {
137147
});
138148
}
139149

150+
@Test
151+
void shouldConfigureWebSocketBeans() {
152+
this.contextRunner.withPropertyValues("spring.graphql.websocket.path=/ws")
153+
.run((context) -> assertThat(context).hasSingleBean(GraphQlWebSocketHandler.class));
154+
}
155+
140156
private void testWith(MockMvcConsumer mockMvcConsumer) {
141157
this.contextRunner.run((context) -> {
142158
MediaType mediaType = MediaType.APPLICATION_JSON;

0 commit comments

Comments
 (0)