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
This commit is contained in:
Brian Clozel
2025-02-05 10:17:22 +01:00
parent c015dbcb15
commit 16aa33a2fb
4 changed files with 572 additions and 3 deletions

View File

@@ -23,6 +23,7 @@ import reactor.core.publisher.Mono;
import org.springframework.graphql.MediaTypes;
import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.WebGraphQlResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.CodecConfigurer;
import org.springframework.web.reactive.function.server.ServerRequest;
@@ -43,6 +44,8 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler {
private static final List<MediaType> SUPPORTED_MEDIA_TYPES = List.of(
MediaTypes.APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, APPLICATION_GRAPHQL);
private boolean isStandardMode = false;
/**
* Create a new instance.
@@ -61,14 +64,50 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler {
super(graphQlHandler, codecConfigurer);
}
/**
* Return whether this HTTP handler should conform to the "GraphQL over HTTP specification"
* when the {@link MediaTypes#APPLICATION_GRAPHQL_RESPONSE} is selected.
* <p>When enabled, this mode will use 4xx/5xx HTTP response status if an error occurs before
* the GraphQL request execution phase starts; for example, if JSON parsing, GraphQL document parsing,
* or GraphQL document validation fails. When disabled, behavior will remain consistent with the
* "application/json" response content type.
* <p>By default, this is set to {@code false}.
* @since 1.4.0
* @see <a href="https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json">GraphQL over HTTP specification</a>
*/
public boolean isStandardMode() {
return this.isStandardMode;
}
/**
* Set whether this HTTP handler should conform to the "GraphQL over HTTP specification"
* when the {@link MediaTypes#APPLICATION_GRAPHQL_RESPONSE} is selected.
* @param standardMode whether the "standard mode" should be enabled
* @since 1.4.0
* @see #isStandardMode
*/
public void setStandardMode(boolean standardMode) {
this.isStandardMode = standardMode;
}
protected Mono<ServerResponse> prepareResponse(ServerRequest request, WebGraphQlResponse response) {
ServerResponse.BodyBuilder builder = ServerResponse.ok();
MediaType responseMediaType = selectResponseMediaType(request);
HttpStatus responseStatus = selectResponseStatus(response, responseMediaType);
ServerResponse.BodyBuilder builder = ServerResponse.status(responseStatus);
builder.headers((headers) -> headers.putAll(response.getResponseHeaders()));
builder.contentType(selectResponseMediaType(request));
builder.contentType(responseMediaType);
return builder.bodyValue(encodeResponseIfNecessary(response));
}
protected HttpStatus selectResponseStatus(WebGraphQlResponse response, MediaType responseMediaType) {
if (this.isStandardMode
&& !response.getExecutionResult().isDataPresent()
&& MediaTypes.APPLICATION_GRAPHQL_RESPONSE.equals(responseMediaType)) {
return HttpStatus.BAD_REQUEST;
}
return HttpStatus.OK;
}
private static MediaType selectResponseMediaType(ServerRequest serverRequest) {
for (MediaType accepted : serverRequest.headers().accept()) {
if (SUPPORTED_MEDIA_TYPES.contains(accepted)) {

View File

@@ -26,6 +26,7 @@ import reactor.core.publisher.Mono;
import org.springframework.graphql.MediaTypes;
import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.WebGraphQlResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.lang.Nullable;
@@ -48,6 +49,7 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler {
private static final List<MediaType> SUPPORTED_MEDIA_TYPES = List.of(
MediaTypes.APPLICATION_GRAPHQL_RESPONSE, MediaType.APPLICATION_JSON, APPLICATION_GRAPHQL);
private boolean isStandardMode = false;
/**
* Create a new instance.
@@ -69,13 +71,40 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler {
super(graphQlHandler, converter);
}
/**
* Return whether this HTTP handler should conform to the "GraphQL over HTTP specification"
* when the {@link MediaTypes#APPLICATION_GRAPHQL_RESPONSE} is selected.
* <p>When enabled, this mode will use 4xx/5xx HTTP response status if an error occurs before
* the GraphQL request execution phase starts; for example, if JSON parsing, GraphQL document parsing,
* or GraphQL document validation fails. When disabled, behavior will remain consistent with the
* "application/json" response content type.
* <p>By default, this is set to {@code false}.
* @since 1.4.0
* @see <a href="https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json">GraphQL over HTTP specification</a>
*/
public boolean isStandardMode() {
return this.isStandardMode;
}
/**
* Set whether this HTTP handler should conform to the "GraphQL over HTTP specification"
* when the {@link MediaTypes#APPLICATION_GRAPHQL_RESPONSE} is selected.
* @param standardMode whether the "standard mode" should be enabled
* @since 1.4.0
* @see #isStandardMode
*/
public void setStandardMode(boolean standardMode) {
this.isStandardMode = standardMode;
}
@Override
protected ServerResponse prepareResponse(ServerRequest request, Mono<WebGraphQlResponse> responseMono) {
CompletableFuture<ServerResponse> future = responseMono.map((response) -> {
MediaType contentType = selectResponseMediaType(request);
ServerResponse.BodyBuilder builder = ServerResponse.ok();
HttpStatus responseStatus = selectResponseStatus(response, contentType);
ServerResponse.BodyBuilder builder = ServerResponse.status(responseStatus);
builder.headers((headers) -> headers.putAll(response.getResponseHeaders()));
builder.contentType(contentType);
@@ -99,6 +128,15 @@ public class GraphQlHttpHandler extends AbstractGraphQlHttpHandler {
return ServerResponse.async(future);
}
protected HttpStatus selectResponseStatus(WebGraphQlResponse response, MediaType responseMediaType) {
if (this.isStandardMode
&& !response.getExecutionResult().isDataPresent()
&& MediaTypes.APPLICATION_GRAPHQL_RESPONSE.equals(responseMediaType)) {
return HttpStatus.BAD_REQUEST;
}
return HttpStatus.OK;
}
private static MediaType selectResponseMediaType(ServerRequest request) {
for (MediaType mediaType : request.headers().accept()) {
if (SUPPORTED_MEDIA_TYPES.contains(mediaType)) {

View File

@@ -0,0 +1,247 @@
/*
* Copyright 2020-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.server.webflux;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.context.annotation.AnnotatedBeanDefinitionReader;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.BookSource;
import org.springframework.graphql.GraphQlSetup;
import org.springframework.graphql.MediaTypes;
import org.springframework.graphql.server.WebGraphQlInterceptor;
import org.springframework.graphql.server.WebGraphQlRequest;
import org.springframework.graphql.server.WebGraphQlResponse;
import org.springframework.graphql.server.WebGraphQlSetup;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.context.support.GenericWebApplicationContext;
import org.springframework.web.reactive.config.EnableWebFlux;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Tests for {@link GraphQlHttpHandler} that check whether it supports
* the GraphQL over HTTP specification.
*
* @see <a href="https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json">GraphQL over HTTP specification</a>
*/
public class GraphQlHttpProtocolTests {
private GraphQlSetup greetingSetup = GraphQlSetup.schemaContent("type Query { greeting: String }")
.queryFetcher("greeting", (env) -> "Hello");
/*
* If the GraphQL response contains the data entry, and it is not null,
* then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code.
*/
@Test
void successWhenValidRequest() {
WebTestClient testClient = createTestClient(greetingSetup);
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ greeting }");
response.expectStatus().isOk()
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.expectBody()
.jsonPath("$.data.greeting").isEqualTo("Hello")
.jsonPath("$.errors").doesNotExist();
}
/*
* If the GraphQL response contains the data entry and it is not null,
* then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Field-errors-encountered-during-execution
*/
@Test
void partialSuccessWhenError() {
GraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema)
.queryFetcher("bookById", (env) -> BookSource.getBookWithoutAuthor(1L))
.dataFetcher("Book", "author", (env) -> {
throw new IllegalStateException("custom error");
});
WebTestClient testClient = createTestClient(graphQlSetup);
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ bookById(id: 1) { id author { firstName } } }");
response.expectStatus().isOk()
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.expectBody()
.jsonPath("$.data.bookById.id").isEqualTo("1")
.jsonPath("$.data.bookById.author").isEmpty()
.jsonPath("$.errors[*].extensions.classification").isEqualTo("INTERNAL_ERROR");
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.JSON-parsing-failure
*/
@Test
void requestErrorWhenJsonParsingFailure() {
WebTestClient testClient = createTestClient(greetingSetup);
testClient.post().uri("/graphql")
.contentType(MediaType.APPLICATION_JSON).accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.bodyValue("NONSENSE")
.exchange().expectStatus().isBadRequest()
.expectHeader().doesNotExist("Content-Type")
.expectBody().isEmpty();
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Invalid-parameters
*/
@Test
void requestErrorWhenInvalidParameters() {
WebTestClient testClient = createTestClient(greetingSetup);
testClient.post().uri("/graphql")
.contentType(MediaType.APPLICATION_JSON).accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.bodyValue("{\"qeury\": \"{__typename}\"}")
.exchange().expectStatus().isBadRequest()
.expectHeader().doesNotExist("Content-Type")
.expectBody().isEmpty();
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-parsing-failure
*/
@Test
void requestErrorWhenDocumentParsingFailure() {
WebTestClient testClient = createTestClient(greetingSetup);
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{");
response.expectStatus().isBadRequest()
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.expectBody()
.jsonPath("$.data").doesNotExist()
.jsonPath("$.errors[*].extensions.classification").isEqualTo("InvalidSyntax");
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-validation-failure
*/
@Test
void requestErrorWhenInvalidDocument() {
WebTestClient testClient = createTestClient(greetingSetup);
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ unknown }");
response.expectStatus().isBadRequest()
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.expectBody()
.jsonPath("$.data").doesNotExist()
.jsonPath("$.errors[*].extensions.classification").isEqualTo("ValidationError");
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Operation-cannot-be-determined
*/
@Test
void requestErrorWhenUndeterminedOperation() {
WebTestClient testClient = createTestClient(greetingSetup);
String document = """
{
"query" : "{ greeting }",
"operationName" : "unknown"
}
""";
testClient.post().uri("/graphql")
.contentType(MediaType.APPLICATION_JSON).accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.bodyValue(document)
.exchange().expectStatus().isBadRequest()
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.expectBody()
.jsonPath("$.data").doesNotExist()
.jsonPath("$.errors[*].extensions.classification").isEqualTo("ValidationError");
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Variable-coercion-failure
*/
@Test
void requestErrorWhenVariableCoercion() {
GraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema)
.queryFetcher("bookById", (env) -> BookSource.getBookWithoutAuthor(1L));
WebTestClient testClient = createTestClient(graphQlSetup);
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ bookById(id: false) { id } }");
response.expectStatus().isBadRequest()
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.expectBody().jsonPath("$.data").doesNotExist()
.jsonPath("$.errors[*].extensions.classification").isEqualTo("ValidationError");
}
/*
* If the GraphQL response contains the data entry and it is null, then the server SHOULD reply
* with a 2xx status code and it is RECOMMENDED it replies with 200 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json
*/
@Test
void successWhenEmptyData() {
WebGraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema)
.queryFetcher("bookById", (env) -> null)
.interceptor(new WebGraphQlInterceptor() {
@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
return chain.next(request).map(response ->
response.transform(builder -> builder.data(null).build()));
}
});
WebTestClient testClient = createTestClient(graphQlSetup);
WebTestClient.ResponseSpec response = postGraphQlRequest(testClient, "{ bookById(id: 100) { id } }");
response.expectStatus().isOk()
.expectHeader().contentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.expectBody().jsonPath("$.data").isEmpty();
}
WebTestClient.ResponseSpec postGraphQlRequest(WebTestClient testClient, String query) {
String document = "{ \"query\" : \"" + query + "\" }";
return testClient.post().uri("/graphql")
.accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(document)
.exchange();
}
static WebTestClient createTestClient(WebGraphQlSetup graphQlSetup) {
GenericWebApplicationContext context = new GenericWebApplicationContext();
AnnotatedBeanDefinitionReader reader = new AnnotatedBeanDefinitionReader(context);
reader.register(WebFluxTestConfig.class);
GraphQlHttpHandler httpHandler = graphQlSetup.toHttpHandlerWebFlux();
httpHandler.setStandardMode(true);
RouterFunction<ServerResponse> routerFunction = RouterFunctions
.route()
.POST("/graphql", RequestPredicates.accept(MediaType.APPLICATION_JSON, MediaTypes.APPLICATION_GRAPHQL_RESPONSE),
httpHandler::handleRequest).build();
context.registerBean(RouterFunction.class, () -> routerFunction);
context.refresh();
return WebTestClient.bindToRouterFunction(routerFunction).build();
}
@Configuration
@EnableWebFlux
static class WebFluxTestConfig implements WebFluxConfigurer {
}
}

View File

@@ -0,0 +1,245 @@
/*
* Copyright 2020-2025 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.graphql.server.webmvc;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.context.annotation.AnnotatedBeanDefinitionReader;
import org.springframework.context.annotation.Configuration;
import org.springframework.graphql.BookSource;
import org.springframework.graphql.GraphQlSetup;
import org.springframework.graphql.MediaTypes;
import org.springframework.graphql.server.WebGraphQlInterceptor;
import org.springframework.graphql.server.WebGraphQlRequest;
import org.springframework.graphql.server.WebGraphQlResponse;
import org.springframework.graphql.server.WebGraphQlSetup;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockServletContext;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.test.web.servlet.assertj.MvcTestResultAssert;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.support.GenericWebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
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 static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link GraphQlHttpHandler} that check whether it supports
* the GraphQL over HTTP specification.
*
* @see <a href="https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json">GraphQL over HTTP specification</a>
*/
public class GraphQlHttpProtocolTests {
private GraphQlSetup greetingSetup = GraphQlSetup.schemaContent("type Query { greeting: String }")
.queryFetcher("greeting", (env) -> "Hello");
/*
* If the GraphQL response contains the data entry, and it is not null,
* then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code.
*/
@Test
void successWhenValidRequest() {
MockMvcTester mvcTester = createMvcTester(greetingSetup);
MvcTestResultAssert resultAssert = postGraphQlRequest(mvcTester, "{ greeting }");
resultAssert.hasStatusOk()
.hasContentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.bodyJson().extractingPath("$.data.greeting").isEqualTo("Hello");
resultAssert.bodyJson().doesNotHavePath("$.errors");
}
/*
* If the GraphQL response contains the data entry and it is not null,
* then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Field-errors-encountered-during-execution
*/
@Test
void partialSuccessWhenError() {
GraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema)
.queryFetcher("bookById", (env) -> BookSource.getBookWithoutAuthor(1L))
.dataFetcher("Book", "author", (env) -> {
throw new IllegalStateException("custom error");
});
MockMvcTester mvcTester = createMvcTester(graphQlSetup);
MvcTestResultAssert resultAssert = postGraphQlRequest(mvcTester, "{ bookById(id: 1) { id author { firstName } } }");
resultAssert.hasStatusOk().hasContentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE);
resultAssert.bodyJson().extractingPath("$.data.bookById.id").isEqualTo("1");
resultAssert.bodyJson().extractingPath("$.data.bookById.author").isNull();
resultAssert.bodyJson().extractingPath("$.errors[*].extensions.classification").asArray().contains("INTERNAL_ERROR");
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.JSON-parsing-failure
*/
@Test
void requestErrorWhenJsonParsingFailure() {
MockMvcTester mvcTester = createMvcTester(greetingSetup);
assertThat(mvcTester.post().accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE).contentType(MediaType.APPLICATION_JSON)
.uri("/graphql")
.content("NONSENSE"))
.hasStatus(HttpStatus.BAD_REQUEST)
.contentType().isNull();
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Invalid-parameters
*/
@Test
void requestErrorWhenInvalidParameters() {
MockMvcTester mvcTester = createMvcTester(greetingSetup);
mvcTester.post().accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE).contentType(MediaType.APPLICATION_JSON)
.uri("/graphql")
.content("{\"qeury\": \"{__typename}\"}")
.assertThat()
.hasStatus(HttpStatus.BAD_REQUEST)
.contentType().isNull();
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-parsing-failure
*/
@Test
void requestErrorWhenDocumentParsingFailure() {
MockMvcTester mvcTester = createMvcTester(greetingSetup);
MvcTestResultAssert resultAssert = postGraphQlRequest(mvcTester, "{");
resultAssert.bodyJson().doesNotHavePath("$.data");
resultAssert.bodyJson().extractingPath("$.errors[*].extensions.classification").asArray().contains("InvalidSyntax");
resultAssert.hasStatus(HttpStatus.BAD_REQUEST).hasContentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE);
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-validation-failure
*/
@Test
void requestErrorWhenInvalidDocument() {
MockMvcTester mvcTester = createMvcTester(greetingSetup);
MvcTestResultAssert resultAssert = postGraphQlRequest(mvcTester, "{ unknown }");
resultAssert.bodyJson().doesNotHavePath("$.data");
resultAssert.bodyJson().extractingPath("$.errors[*].extensions.classification").asArray().contains("ValidationError");
resultAssert.hasStatus(HttpStatus.BAD_REQUEST).hasContentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE);
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Operation-cannot-be-determined
*/
@Test
void requestErrorWhenUndeterminedOperation() {
MockMvcTester mvcTester = createMvcTester(greetingSetup);
String document = """
{
"query" : "{ greeting }",
"operationName" : "unknown"
}
""";
MvcTestResultAssert resultAssert = mvcTester.post().accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE).contentType(MediaType.APPLICATION_JSON)
.uri("/graphql")
.content(document)
.assertThat();
resultAssert.hasStatus(HttpStatus.BAD_REQUEST).hasContentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE);
resultAssert.bodyJson().doesNotHavePath("$.data");
resultAssert.bodyJson().extractingPath("$.errors[*].extensions.classification").asArray().contains("ValidationError");
}
/*
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
* then the server SHOULD reply with 400 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Variable-coercion-failure
*/
@Test
void requestErrorWhenVariableCoercion() {
GraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema)
.queryFetcher("bookById", (env) -> BookSource.getBookWithoutAuthor(1L));
MockMvcTester mvcTester = createMvcTester(graphQlSetup);
MvcTestResultAssert resultAssert = postGraphQlRequest(mvcTester, "{ bookById(id: false) { id } }");
resultAssert.hasStatus(HttpStatus.BAD_REQUEST).hasContentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE);
resultAssert.bodyJson().doesNotHavePath("$.data");
resultAssert.bodyJson().extractingPath("$.errors[*].extensions.classification").asArray().contains("ValidationError");
}
/*
* If the GraphQL response contains the data entry and it is null, then the server SHOULD reply
* with a 2xx status code and it is RECOMMENDED it replies with 200 status code.
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json
*/
@Test
void successWhenEmptyData() {
WebGraphQlSetup graphQlSetup = GraphQlSetup.schemaResource(BookSource.schema)
.queryFetcher("bookById", (env) -> null)
.interceptor(new WebGraphQlInterceptor() {
@Override
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
return chain.next(request).map(response ->
response.transform(builder -> builder.data(null).build()));
}
});
MockMvcTester mvcTester = createMvcTester(graphQlSetup);
MvcTestResultAssert resultAssert = postGraphQlRequest(mvcTester, "{ bookById(id: 100) { id } }");
resultAssert
.hasStatusOk()
.hasContentType(MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.bodyJson().extractingPath("$.data").isNull();
}
MvcTestResultAssert postGraphQlRequest(MockMvcTester mvcTester, String query) {
String document = "{ \"query\" : \"" + query + "\" }";
return mvcTester.post().accept(MediaTypes.APPLICATION_GRAPHQL_RESPONSE).contentType(MediaType.APPLICATION_JSON)
.uri("/graphql")
.content(document).assertThat();
}
static MockMvcTester createMvcTester(WebGraphQlSetup graphQlSetup) {
GenericWebApplicationContext context = new GenericWebApplicationContext();
AnnotatedBeanDefinitionReader reader = new AnnotatedBeanDefinitionReader(context);
reader.register(MvcTestConfig.class);
context.setServletContext(new MockServletContext());
GraphQlHttpHandler httpHandler = graphQlSetup.toHttpHandler();
httpHandler.setStandardMode(true);
RouterFunction<ServerResponse> routerFunction = RouterFunctions
.route()
.POST("/graphql", RequestPredicates.accept(MediaType.APPLICATION_JSON, MediaTypes.APPLICATION_GRAPHQL_RESPONSE),
httpHandler::handleRequest).build();
context.registerBean(RouterFunction.class, () -> routerFunction);
context.refresh();
return MockMvcTester.create(MockMvcBuilders.routerFunctions(routerFunction).build());
}
@Configuration
@EnableWebMvc
static class MvcTestConfig implements WebMvcConfigurer {
}
}