Skip to content

Commit 16aa33a

Browse files
committed
Align server transports with GraphQL/HTTP spec
Prior to this commit, the `GraphQlHttpHandler` implementations for MVC and WebFlux would support the HTTP transport protocol for servers. They would align with the well-known GraphQL behavior, using HTTP as a transport and always using HTTP 200 OK as response status. The new GraphQL over HTTP specification changes that, and requires servers to respond with HTTP 4xx/5xx statuses when an error occurs before the GraphQL request execution: for example, if the JSON document cannot be parsed, or the GraphQL document is invalid. This commit introduces a new "standard mode" option on HTTP transports to follow this new requirement. Because this is a breaking change for GraphQL clients, this mode is opt-in only for now. See gh-1117
1 parent c015dbc commit 16aa33a

File tree

4 files changed

+572
-3
lines changed

4 files changed

+572
-3
lines changed

spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.springframework.graphql.MediaTypes;
2424
import org.springframework.graphql.server.WebGraphQlHandler;
2525
import org.springframework.graphql.server.WebGraphQlResponse;
26+
import org.springframework.http.HttpStatus;
2627
import org.springframework.http.MediaType;
2728
import org.springframework.http.codec.CodecConfigurer;
2829
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -43,6 +44,8 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler {
4344
private static final List<MediaType> SUPPORTED_MEDIA_TYPES = List.of(
4445
MediaTypes.APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, APPLICATION_GRAPHQL);
4546

47+
private boolean isStandardMode = false;
48+
4649

4750
/**
4851
* Create a new instance.
@@ -61,14 +64,50 @@ public GraphQlHttpHandler(WebGraphQlHandler graphQlHandler, CodecConfigurer code
6164
super(graphQlHandler, codecConfigurer);
6265
}
6366

67+
/**
68+
* Return whether this HTTP handler should conform to the "GraphQL over HTTP specification"
69+
* when the {@link MediaTypes#APPLICATION_GRAPHQL_RESPONSE} is selected.
70+
* <p>When enabled, this mode will use 4xx/5xx HTTP response status if an error occurs before
71+
* the GraphQL request execution phase starts; for example, if JSON parsing, GraphQL document parsing,
72+
* or GraphQL document validation fails. When disabled, behavior will remain consistent with the
73+
* "application/json" response content type.
74+
* <p>By default, this is set to {@code false}.
75+
* @since 1.4.0
76+
* @see <a href="https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json">GraphQL over HTTP specification</a>
77+
*/
78+
public boolean isStandardMode() {
79+
return this.isStandardMode;
80+
}
81+
82+
/**
83+
* Set whether this HTTP handler should conform to the "GraphQL over HTTP specification"
84+
* when the {@link MediaTypes#APPLICATION_GRAPHQL_RESPONSE} is selected.
85+
* @param standardMode whether the "standard mode" should be enabled
86+
* @since 1.4.0
87+
* @see #isStandardMode
88+
*/
89+
public void setStandardMode(boolean standardMode) {
90+
this.isStandardMode = standardMode;
91+
}
6492

6593
protected Mono<ServerResponse> prepareResponse(ServerRequest request, WebGraphQlResponse response) {
66-
ServerResponse.BodyBuilder builder = ServerResponse.ok();
94+
MediaType responseMediaType = selectResponseMediaType(request);
95+
HttpStatus responseStatus = selectResponseStatus(response, responseMediaType);
96+
ServerResponse.BodyBuilder builder = ServerResponse.status(responseStatus);
6797
builder.headers((headers) -> headers.putAll(response.getResponseHeaders()));
68-
builder.contentType(selectResponseMediaType(request));
98+
builder.contentType(responseMediaType);
6999
return builder.bodyValue(encodeResponseIfNecessary(response));
70100
}
71101

102+
protected HttpStatus selectResponseStatus(WebGraphQlResponse response, MediaType responseMediaType) {
103+
if (this.isStandardMode
104+
&& !response.getExecutionResult().isDataPresent()
105+
&& MediaTypes.APPLICATION_GRAPHQL_RESPONSE.equals(responseMediaType)) {
106+
return HttpStatus.BAD_REQUEST;
107+
}
108+
return HttpStatus.OK;
109+
}
110+
72111
private static MediaType selectResponseMediaType(ServerRequest serverRequest) {
73112
for (MediaType accepted : serverRequest.headers().accept()) {
74113
if (SUPPORTED_MEDIA_TYPES.contains(accepted)) {

spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import org.springframework.graphql.MediaTypes;
2727
import org.springframework.graphql.server.WebGraphQlHandler;
2828
import org.springframework.graphql.server.WebGraphQlResponse;
29+
import org.springframework.http.HttpStatus;
2930
import org.springframework.http.MediaType;
3031
import org.springframework.http.converter.HttpMessageConverter;
3132
import org.springframework.lang.Nullable;
@@ -48,6 +49,7 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler {
4849
private static final List<MediaType> SUPPORTED_MEDIA_TYPES = List.of(
4950
MediaTypes.APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, APPLICATION_GRAPHQL);
5051

52+
private boolean isStandardMode = false;
5153

5254
/**
5355
* Create a new instance.
@@ -69,13 +71,40 @@ public GraphQlHttpHandler(WebGraphQlHandler graphQlHandler, @Nullable HttpMessag
6971
super(graphQlHandler, converter);
7072
}
7173

74+
/**
75+
* Return whether this HTTP handler should conform to the "GraphQL over HTTP specification"
76+
* when the {@link MediaTypes#APPLICATION_GRAPHQL_RESPONSE} is selected.
77+
* <p>When enabled, this mode will use 4xx/5xx HTTP response status if an error occurs before
78+
* the GraphQL request execution phase starts; for example, if JSON parsing, GraphQL document parsing,
79+
* or GraphQL document validation fails. When disabled, behavior will remain consistent with the
80+
* "application/json" response content type.
81+
* <p>By default, this is set to {@code false}.
82+
* @since 1.4.0
83+
* @see <a href="https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json">GraphQL over HTTP specification</a>
84+
*/
85+
public boolean isStandardMode() {
86+
return this.isStandardMode;
87+
}
88+
89+
/**
90+
* Set whether this HTTP handler should conform to the "GraphQL over HTTP specification"
91+
* when the {@link MediaTypes#APPLICATION_GRAPHQL_RESPONSE} is selected.
92+
* @param standardMode whether the "standard mode" should be enabled
93+
* @since 1.4.0
94+
* @see #isStandardMode
95+
*/
96+
public void setStandardMode(boolean standardMode) {
97+
this.isStandardMode = standardMode;
98+
}
99+
72100

73101
@Override
74102
protected ServerResponse prepareResponse(ServerRequest request, Mono<WebGraphQlResponse> responseMono) {
75103

76104
CompletableFuture<ServerResponse> future = responseMono.map((response) -> {
77105
MediaType contentType = selectResponseMediaType(request);
78-
ServerResponse.BodyBuilder builder = ServerResponse.ok();
106+
HttpStatus responseStatus = selectResponseStatus(response, contentType);
107+
ServerResponse.BodyBuilder builder = ServerResponse.status(responseStatus);
79108
builder.headers((headers) -> headers.putAll(response.getResponseHeaders()));
80109
builder.contentType(contentType);
81110

@@ -99,6 +128,15 @@ protected ServerResponse prepareResponse(ServerRequest request, Mono<WebGraphQlR
99128
return ServerResponse.async(future);
100129
}
101130

131+
protected HttpStatus selectResponseStatus(WebGraphQlResponse response, MediaType responseMediaType) {
132+
if (this.isStandardMode
133+
&& !response.getExecutionResult().isDataPresent()
134+
&& MediaTypes.APPLICATION_GRAPHQL_RESPONSE.equals(responseMediaType)) {
135+
return HttpStatus.BAD_REQUEST;
136+
}
137+
return HttpStatus.OK;
138+
}
139+
102140
private static MediaType selectResponseMediaType(ServerRequest request) {
103141
for (MediaType mediaType : request.headers().accept()) {
104142
if (SUPPORTED_MEDIA_TYPES.contains(mediaType)) {
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/*
2+
* Copyright 2020-2025 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.graphql.server.webflux;
18+
19+
20+
import org.junit.jupiter.api.Test;
21+
import reactor.core.publisher.Mono;
22+
23+
import org.springframework.context.annotation.AnnotatedBeanDefinitionReader;
24+
import org.springframework.context.annotation.Configuration;
25+
import org.springframework.graphql.BookSource;
26+
import org.springframework.graphql.GraphQlSetup;
27+
import org.springframework.graphql.MediaTypes;
28+
import org.springframework.graphql.server.WebGraphQlInterceptor;
29+
import org.springframework.graphql.server.WebGraphQlRequest;
30+
import org.springframework.graphql.server.WebGraphQlResponse;
31+
import org.springframework.graphql.server.WebGraphQlSetup;
32+
import org.springframework.http.MediaType;
33+
import org.springframework.test.web.reactive.server.WebTestClient;
34+
import org.springframework.web.context.support.GenericWebApplicationContext;
35+
import org.springframework.web.reactive.config.EnableWebFlux;
36+
import org.springframework.web.reactive.config.WebFluxConfigurer;
37+
import org.springframework.web.reactive.function.server.RequestPredicates;
38+
import org.springframework.web.reactive.function.server.RouterFunction;
39+
import org.springframework.web.reactive.function.server.RouterFunctions;
40+
import org.springframework.web.reactive.function.server.ServerResponse;
41+
42+
/**
43+
* Tests for {@link GraphQlHttpHandler} that check whether it supports
44+
* the GraphQL over HTTP specification.
45+
*
46+
* @see <a href="https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json">GraphQL over HTTP specification</a>
47+
*/
48+
public class GraphQlHttpProtocolTests {
49+
50+
private GraphQlSetup greetingSetup = GraphQlSetup.schemaContent("type Query { greeting: String }")
51+
.queryFetcher("greeting", (env) -> "Hello");
52+
53+
/*
54+
* If the GraphQL response contains the data entry, and it is not null,
55+
* then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code.
56+
*/
57+
@Test
58+
void successWhenValidRequest() {
59+
WebTestClient testClient = createTestClient(greetingSetup);
60+
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ greeting }");
61+
response.expectStatus().isOk()
62+
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
63+
.expectBody()
64+
.jsonPath("$.data.greeting").isEqualTo("Hello")
65+
.jsonPath("$.errors").doesNotExist();
66+
}
67+
68+
/*
69+
* If the GraphQL response contains the data entry and it is not null,
70+
* then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code.
71+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Field-errors-encountered-during-execution
72+
*/
73+
@Test
74+
void partialSuccessWhenError() {
75+
GraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema)
76+
.queryFetcher("bookById", (env) -> BookSource.getBookWithoutAuthor(1L))
77+
.dataFetcher("Book", "author", (env) -> {
78+
throw new IllegalStateException("custom error");
79+
});
80+
WebTestClient testClient = createTestClient(graphQlSetup);
81+
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ bookById(id: 1) { id author { firstName } } }");
82+
response.expectStatus().isOk()
83+
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
84+
.expectBody()
85+
.jsonPath("$.data.bookById.id").isEqualTo("1")
86+
.jsonPath("$.data.bookById.author").isEmpty()
87+
.jsonPath("$.errors[*].extensions.classification").isEqualTo("INTERNAL_ERROR");
88+
}
89+
90+
/*
91+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
92+
* then the server SHOULD reply with 400 status code.
93+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.JSON-parsing-failure
94+
*/
95+
@Test
96+
void requestErrorWhenJsonParsingFailure() {
97+
WebTestClient testClient = createTestClient(greetingSetup);
98+
testClient.post().uri("/graphql")
99+
.contentType(MediaType.APPLICATION_JSON).accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
100+
.bodyValue("NONSENSE")
101+
.exchange().expectStatus().isBadRequest()
102+
.expectHeader().doesNotExist("Content-Type")
103+
.expectBody().isEmpty();
104+
}
105+
106+
/*
107+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
108+
* then the server SHOULD reply with 400 status code.
109+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Invalid-parameters
110+
*/
111+
@Test
112+
void requestErrorWhenInvalidParameters() {
113+
WebTestClient testClient = createTestClient(greetingSetup);
114+
testClient.post().uri("/graphql")
115+
.contentType(MediaType.APPLICATION_JSON).accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
116+
.bodyValue("{\"qeury\": \"{__typename}\"}")
117+
.exchange().expectStatus().isBadRequest()
118+
.expectHeader().doesNotExist("Content-Type")
119+
.expectBody().isEmpty();
120+
}
121+
122+
/*
123+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
124+
* then the server SHOULD reply with 400 status code.
125+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-parsing-failure
126+
*/
127+
@Test
128+
void requestErrorWhenDocumentParsingFailure() {
129+
WebTestClient testClient = createTestClient(greetingSetup);
130+
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{");
131+
response.expectStatus().isBadRequest()
132+
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
133+
.expectBody()
134+
.jsonPath("$.data").doesNotExist()
135+
.jsonPath("$.errors[*].extensions.classification").isEqualTo("InvalidSyntax");
136+
}
137+
138+
/*
139+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
140+
* then the server SHOULD reply with 400 status code.
141+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-validation-failure
142+
*/
143+
@Test
144+
void requestErrorWhenInvalidDocument() {
145+
WebTestClient testClient = createTestClient(greetingSetup);
146+
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ unknown }");
147+
response.expectStatus().isBadRequest()
148+
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
149+
.expectBody()
150+
.jsonPath("$.data").doesNotExist()
151+
.jsonPath("$.errors[*].extensions.classification").isEqualTo("ValidationError");
152+
}
153+
154+
/*
155+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
156+
* then the server SHOULD reply with 400 status code.
157+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Operation-cannot-be-determined
158+
*/
159+
@Test
160+
void requestErrorWhenUndeterminedOperation() {
161+
WebTestClient testClient = createTestClient(greetingSetup);
162+
String document = """
163+
{
164+
"query" : "{ greeting }",
165+
"operationName" : "unknown"
166+
}
167+
""";
168+
testClient.post().uri("/graphql")
169+
.contentType(MediaType.APPLICATION_JSON).accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
170+
.bodyValue(document)
171+
.exchange().expectStatus().isBadRequest()
172+
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
173+
.expectBody()
174+
.jsonPath("$.data").doesNotExist()
175+
.jsonPath("$.errors[*].extensions.classification").isEqualTo("ValidationError");
176+
}
177+
178+
/*
179+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
180+
* then the server SHOULD reply with 400 status code.
181+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Variable-coercion-failure
182+
*/
183+
@Test
184+
void requestErrorWhenVariableCoercion() {
185+
GraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema)
186+
.queryFetcher("bookById", (env) -> BookSource.getBookWithoutAuthor(1L));
187+
WebTestClient testClient = createTestClient(graphQlSetup);
188+
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ bookById(id: false) { id } }");
189+
response.expectStatus().isBadRequest()
190+
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
191+
.expectBody().jsonPath("$.data").doesNotExist()
192+
.jsonPath("$.errors[*].extensions.classification").isEqualTo("ValidationError");
193+
}
194+
195+
/*
196+
* If the GraphQL response contains the data entry and it is null, then the server SHOULD reply
197+
* with a 2xx status code and it is RECOMMENDED it replies with 200 status code.
198+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json
199+
*/
200+
@Test
201+
void successWhenEmptyData() {
202+
WebGraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema)
203+
.queryFetcher("bookById", (env) -> null)
204+
.interceptor(new WebGraphQlInterceptor() {
205+
@Override
206+
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
207+
return chain.next(request).map(response ->
208+
response.transform(builder -> builder.data(null).build()));
209+
}
210+
});
211+
WebTestClient testClient = createTestClient(graphQlSetup);
212+
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ bookById(id: 100) { id } }");
213+
response.expectStatus().isOk()
214+
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
215+
.expectBody().jsonPath("$.data").isEmpty();
216+
}
217+
218+
WebTestClient.ResponseSpec postGraphQlRequest(WebTestClient testClient, String query) {
219+
String document = "{ \"query\" : \"" + query + "\" }";
220+
return testClient.post().uri("/graphql")
221+
.accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
222+
.contentType(MediaType.APPLICATION_JSON)
223+
.bodyValue(document)
224+
.exchange();
225+
}
226+
227+
static WebTestClient createTestClient(WebGraphQlSetup graphQlSetup) {
228+
GenericWebApplicationContext context = new GenericWebApplicationContext();
229+
AnnotatedBeanDefinitionReader reader = new AnnotatedBeanDefinitionReader(context);
230+
reader.register(WebFluxTestConfig.class);
231+
GraphQlHttpHandler httpHandler = graphQlSetup.toHttpHandlerWebFlux();
232+
httpHandler.setStandardMode(true);
233+
RouterFunction<ServerResponse> routerFunction = RouterFunctions
234+
.route()
235+
.POST("/graphql", RequestPredicates.accept(MediaType.APPLICATION_JSON, MediaTypes.APPLICATION_GRAPHQL_RESPONSE),
236+
httpHandler::handleRequest).build();
237+
context.registerBean(RouterFunction.class, () -> routerFunction);
238+
context.refresh();
239+
return WebTestClient.bindToRouterFunction(routerFunction).build();
240+
}
241+
242+
@Configuration
243+
@EnableWebFlux
244+
static class WebFluxTestConfig implements WebFluxConfigurer {
245+
246+
}
247+
}

0 commit comments

Comments
 (0)