Skip to content

Commit 4ef9b9e

Browse files
committed
Auto-configure CORS options for GraphQL web endpoints
This commit adds `"spring.graphql.cors.*"` configuration properties to customize the CORS configuration for GraphQL web endpoints. See gh-29140
1 parent 0099460 commit 4ef9b9e

File tree

6 files changed

+291
-2
lines changed

6 files changed

+291
-2
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/*
2+
* Copyright 2012-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.graphql;
18+
19+
import java.time.Duration;
20+
import java.time.temporal.ChronoUnit;
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
24+
import org.springframework.boot.context.properties.ConfigurationProperties;
25+
import org.springframework.boot.context.properties.PropertyMapper;
26+
import org.springframework.boot.convert.DurationUnit;
27+
import org.springframework.lang.Nullable;
28+
import org.springframework.util.CollectionUtils;
29+
import org.springframework.web.cors.CorsConfiguration;
30+
31+
/**
32+
* Configuration properties for GraphQL endpoint's CORS support.
33+
*
34+
* @author Andy Wilkinson
35+
* @author Brian Clozel
36+
* @since 2.7.0
37+
*/
38+
@ConfigurationProperties(prefix = "spring.graphql.cors")
39+
public class GraphQlCorsProperties {
40+
41+
/**
42+
* Comma-separated list of origins to allow with '*' allowing all origins. When
43+
* allow-credentials is enabled, '*' cannot be used, and setting origin patterns
44+
* should be considered instead. When neither allowed origins nor allowed origin
45+
* patterns are set, cross-origin requests are effectively disabled.
46+
*/
47+
private List<String> allowedOrigins = new ArrayList<>();
48+
49+
/**
50+
* Comma-separated list of origin patterns to allow. Unlike allowed origins which only
51+
* support '*', origin patterns are more flexible, e.g. 'https://*.example.com', and
52+
* can be used with allow-credentials. When neither allowed origins nor allowed origin
53+
* patterns are set, cross-origin requests are effectively disabled.
54+
*/
55+
private List<String> allowedOriginPatterns = new ArrayList<>();
56+
57+
/**
58+
* Comma-separated list of HTTP methods to allow. '*' allows all methods. When not
59+
* set, defaults to GET.
60+
*/
61+
private List<String> allowedMethods = new ArrayList<>();
62+
63+
/**
64+
* Comma-separated list of HTTP headers to allow in a request. '*' allows all headers.
65+
*/
66+
private List<String> allowedHeaders = new ArrayList<>();
67+
68+
/**
69+
* Comma-separated list of headers to include in a response.
70+
*/
71+
private List<String> exposedHeaders = new ArrayList<>();
72+
73+
/**
74+
* Whether credentials are supported. When not set, credentials are not supported.
75+
*/
76+
@Nullable
77+
private Boolean allowCredentials;
78+
79+
/**
80+
* How long the response from a pre-flight request can be cached by clients. If a
81+
* duration suffix is not specified, seconds will be used.
82+
*/
83+
@DurationUnit(ChronoUnit.SECONDS)
84+
private Duration maxAge = Duration.ofSeconds(1800);
85+
86+
public List<String> getAllowedOrigins() {
87+
return this.allowedOrigins;
88+
}
89+
90+
public void setAllowedOrigins(List<String> allowedOrigins) {
91+
this.allowedOrigins = allowedOrigins;
92+
}
93+
94+
public List<String> getAllowedOriginPatterns() {
95+
return this.allowedOriginPatterns;
96+
}
97+
98+
public void setAllowedOriginPatterns(List<String> allowedOriginPatterns) {
99+
this.allowedOriginPatterns = allowedOriginPatterns;
100+
}
101+
102+
public List<String> getAllowedMethods() {
103+
return this.allowedMethods;
104+
}
105+
106+
public void setAllowedMethods(List<String> allowedMethods) {
107+
this.allowedMethods = allowedMethods;
108+
}
109+
110+
public List<String> getAllowedHeaders() {
111+
return this.allowedHeaders;
112+
}
113+
114+
public void setAllowedHeaders(List<String> allowedHeaders) {
115+
this.allowedHeaders = allowedHeaders;
116+
}
117+
118+
public List<String> getExposedHeaders() {
119+
return this.exposedHeaders;
120+
}
121+
122+
public void setExposedHeaders(List<String> exposedHeaders) {
123+
this.exposedHeaders = exposedHeaders;
124+
}
125+
126+
@Nullable
127+
public Boolean getAllowCredentials() {
128+
return this.allowCredentials;
129+
}
130+
131+
public void setAllowCredentials(Boolean allowCredentials) {
132+
this.allowCredentials = allowCredentials;
133+
}
134+
135+
public Duration getMaxAge() {
136+
return this.maxAge;
137+
}
138+
139+
public void setMaxAge(Duration maxAge) {
140+
this.maxAge = maxAge;
141+
}
142+
143+
@Nullable
144+
public CorsConfiguration toCorsConfiguration() {
145+
if (CollectionUtils.isEmpty(this.allowedOrigins) && CollectionUtils.isEmpty(this.allowedOriginPatterns)) {
146+
return null;
147+
}
148+
PropertyMapper map = PropertyMapper.get();
149+
CorsConfiguration config = new CorsConfiguration();
150+
map.from(this::getAllowedOrigins).to(config::setAllowedOrigins);
151+
map.from(this::getAllowedOriginPatterns).to(config::setAllowedOriginPatterns);
152+
map.from(this::getAllowedHeaders).whenNot(CollectionUtils::isEmpty).to(config::setAllowedHeaders);
153+
map.from(this::getAllowedMethods).whenNot(CollectionUtils::isEmpty).to(config::setAllowedMethods);
154+
map.from(this::getExposedHeaders).whenNot(CollectionUtils::isEmpty).to(config::setExposedHeaders);
155+
map.from(this::getMaxAge).whenNonNull().as(Duration::getSeconds).to(config::setMaxAge);
156+
map.from(this::getAllowCredentials).whenNonNull().to(config::setAllowCredentials);
157+
return config;
158+
}
159+
160+
}

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

+28
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
3232
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
3333
import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
34+
import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties;
3435
import org.springframework.boot.autoconfigure.graphql.GraphQlProperties;
36+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
3537
import org.springframework.context.annotation.Bean;
3638
import org.springframework.context.annotation.Configuration;
3739
import org.springframework.core.io.ResourceLoader;
@@ -45,6 +47,9 @@
4547
import org.springframework.http.HttpMethod;
4648
import org.springframework.http.HttpStatus;
4749
import org.springframework.http.MediaType;
50+
import org.springframework.web.cors.CorsConfiguration;
51+
import org.springframework.web.reactive.config.CorsRegistry;
52+
import org.springframework.web.reactive.config.WebFluxConfigurer;
4853
import org.springframework.web.reactive.function.server.RouterFunction;
4954
import org.springframework.web.reactive.function.server.RouterFunctions;
5055
import org.springframework.web.reactive.function.server.ServerResponse;
@@ -64,6 +69,7 @@
6469
@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class })
6570
@ConditionalOnBean(GraphQlService.class)
6671
@AutoConfigureAfter(GraphQlAutoConfiguration.class)
72+
@EnableConfigurationProperties(GraphQlCorsProperties.class)
6773
public class GraphQlWebFluxAutoConfiguration {
6874

6975
private static final Log logger = LogFactory.getLog(GraphQlWebFluxAutoConfiguration.class);
@@ -111,4 +117,26 @@ public RouterFunction<ServerResponse> graphQlEndpoint(GraphQlHttpHandler handler
111117
return builder.build();
112118
}
113119

120+
@Configuration(proxyBeanMethods = false)
121+
public static class GraphQlEndpointCorsConfiguration implements WebFluxConfigurer {
122+
123+
final GraphQlProperties graphQlProperties;
124+
125+
final GraphQlCorsProperties corsProperties;
126+
127+
public GraphQlEndpointCorsConfiguration(GraphQlProperties graphQlProps, GraphQlCorsProperties corsProps) {
128+
this.graphQlProperties = graphQlProps;
129+
this.corsProperties = corsProps;
130+
}
131+
132+
@Override
133+
public void addCorsMappings(CorsRegistry registry) {
134+
CorsConfiguration configuration = this.corsProperties.toCorsConfiguration();
135+
if (configuration != null) {
136+
registry.addMapping(this.graphQlProperties.getPath()).combine(configuration);
137+
}
138+
}
139+
140+
}
141+
114142
}

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

+28
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@
3131
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
3232
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
3333
import org.springframework.boot.autoconfigure.graphql.GraphQlAutoConfiguration;
34+
import org.springframework.boot.autoconfigure.graphql.GraphQlCorsProperties;
3435
import org.springframework.boot.autoconfigure.graphql.GraphQlProperties;
36+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
3537
import org.springframework.context.annotation.Bean;
3638
import org.springframework.context.annotation.Configuration;
3739
import org.springframework.core.io.ResourceLoader;
@@ -46,6 +48,9 @@
4648
import org.springframework.http.HttpMethod;
4749
import org.springframework.http.HttpStatus;
4850
import org.springframework.http.MediaType;
51+
import org.springframework.web.cors.CorsConfiguration;
52+
import org.springframework.web.servlet.config.annotation.CorsRegistry;
53+
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
4954
import org.springframework.web.servlet.function.RequestPredicates;
5055
import org.springframework.web.servlet.function.RouterFunction;
5156
import org.springframework.web.servlet.function.RouterFunctions;
@@ -63,6 +68,7 @@
6368
@ConditionalOnClass({ GraphQL.class, GraphQlHttpHandler.class })
6469
@ConditionalOnBean(GraphQlService.class)
6570
@AutoConfigureAfter(GraphQlAutoConfiguration.class)
71+
@EnableConfigurationProperties(GraphQlCorsProperties.class)
6672
public class GraphQlWebMvcAutoConfiguration {
6773

6874
private static final Log logger = LogFactory.getLog(GraphQlWebMvcAutoConfiguration.class);
@@ -112,4 +118,26 @@ public RouterFunction<ServerResponse> graphQlRouterFunction(GraphQlHttpHandler h
112118
return builder.build();
113119
}
114120

121+
@Configuration(proxyBeanMethods = false)
122+
public static class GraphQlEndpointCorsConfiguration implements WebMvcConfigurer {
123+
124+
final GraphQlProperties graphQlProperties;
125+
126+
final GraphQlCorsProperties corsProperties;
127+
128+
public GraphQlEndpointCorsConfiguration(GraphQlProperties graphQlProps, GraphQlCorsProperties corsProps) {
129+
this.graphQlProperties = graphQlProps;
130+
this.corsProperties = corsProps;
131+
}
132+
133+
@Override
134+
public void addCorsMappings(CorsRegistry registry) {
135+
CorsConfiguration configuration = this.corsProperties.toCorsConfiguration();
136+
if (configuration != null) {
137+
registry.addMapping(this.graphQlProperties.getPath()).combine(configuration);
138+
}
139+
}
140+
141+
}
142+
115143
}

spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/additional-spring-configuration-metadata.json

+39
Original file line numberDiff line numberDiff line change
@@ -2210,6 +2210,45 @@
22102210
}
22112211
]
22122212
},
2213+
{
2214+
"name": "spring.graphql.cors.allowed-headers",
2215+
"values": [
2216+
{
2217+
"value": "*"
2218+
}
2219+
],
2220+
"providers": [
2221+
{
2222+
"name": "any"
2223+
}
2224+
]
2225+
},
2226+
{
2227+
"name": "spring.graphql.cors.allowed-methods",
2228+
"values": [
2229+
{
2230+
"value": "*"
2231+
}
2232+
],
2233+
"providers": [
2234+
{
2235+
"name": "any"
2236+
}
2237+
]
2238+
},
2239+
{
2240+
"name": "spring.graphql.cors.allowed-origins",
2241+
"values": [
2242+
{
2243+
"value": "*"
2244+
}
2245+
],
2246+
"providers": [
2247+
{
2248+
"name": "any"
2249+
}
2250+
]
2251+
},
22132252
{
22142253
"name": "spring.jmx.server",
22152254
"providers": [

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

+17-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import org.springframework.context.annotation.Configuration;
3535
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
3636
import org.springframework.graphql.web.WebInterceptor;
37+
import org.springframework.http.HttpHeaders;
3738
import org.springframework.http.HttpStatus;
3839
import org.springframework.http.MediaType;
3940
import org.springframework.test.web.reactive.server.WebTestClient;
@@ -55,7 +56,9 @@ class GraphQlWebFluxAutoConfigurationTests {
5556
GraphQlWebFluxAutoConfiguration.class))
5657
.withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class)
5758
.withPropertyValues("spring.main.web-application-type=reactive", "spring.graphql.graphiql.enabled=true",
58-
"spring.graphql.schema.printer.enabled=true");
59+
"spring.graphql.schema.printer.enabled=true",
60+
"spring.graphql.cors.allowed-origins=https://example.com",
61+
"spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true");
5962

6063
@Test
6164
void simpleQueryShouldWork() {
@@ -114,6 +117,19 @@ void shouldExposeGraphiqlEndpoint() {
114117
});
115118
}
116119

120+
@Test
121+
void shouldSupportCors() {
122+
testWithWebClient((client) -> {
123+
String query = "{" + " bookById(id: \\\"book-1\\\"){ " + " id" + " name" + " pageCount"
124+
+ " author" + " }" + "}";
125+
client.post().uri("/graphql").bodyValue("{ \"query\": \"" + query + "\"}")
126+
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
127+
.header(HttpHeaders.ORIGIN, "https://example.com").exchange().expectStatus().isOk().expectHeader()
128+
.valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://example.com").expectHeader()
129+
.valueEquals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
130+
});
131+
}
132+
117133
private void testWithWebClient(Consumer<WebTestClient> consumer) {
118134
this.contextRunner.run((context) -> {
119135
WebTestClient client = WebTestClient.bindToApplicationContext(context).configureClient()

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

+19-1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import org.springframework.context.annotation.Configuration;
3333
import org.springframework.graphql.execution.RuntimeWiringConfigurer;
3434
import org.springframework.graphql.web.WebInterceptor;
35+
import org.springframework.http.HttpHeaders;
3536
import org.springframework.http.MediaType;
3637
import org.springframework.test.web.servlet.MockMvc;
3738
import org.springframework.test.web.servlet.MvcResult;
@@ -60,7 +61,9 @@ class GraphQlWebMvcAutoConfigurationTests {
6061
GraphQlAutoConfiguration.class, GraphQlWebMvcAutoConfiguration.class))
6162
.withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class)
6263
.withPropertyValues("spring.main.web-application-type=servlet", "spring.graphql.graphiql.enabled=true",
63-
"spring.graphql.schema.printer.enabled=true");
64+
"spring.graphql.schema.printer.enabled=true",
65+
"spring.graphql.cors.allowed-origins=https://example.com",
66+
"spring.graphql.cors.allowed-methods=POST", "spring.graphql.cors.allow-credentials=true");
6467

6568
@Test
6669
void simpleQueryShouldWork() {
@@ -119,6 +122,21 @@ void shouldExposeGraphiqlEndpoint() {
119122
});
120123
}
121124

125+
@Test
126+
void shouldSupportCors() {
127+
testWith((mockMvc) -> {
128+
String query = "{" + " bookById(id: \\\"book-1\\\"){ " + " id" + " name" + " pageCount"
129+
+ " author" + " }" + "}";
130+
MvcResult result = mockMvc.perform(post("/graphql")
131+
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")
132+
.header(HttpHeaders.ORIGIN, "https://example.com").content("{\"query\": \"" + query + "\"}"))
133+
.andReturn();
134+
mockMvc.perform(asyncDispatch(result)).andExpect(status().isOk())
135+
.andExpect(header().stringValues(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://example.com"))
136+
.andExpect(header().stringValues(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"));
137+
});
138+
}
139+
122140
private void testWith(MockMvcConsumer mockMvcConsumer) {
123141
this.contextRunner.run((context) -> {
124142
MediaType mediaType = MediaType.APPLICATION_JSON;

0 commit comments

Comments
 (0)