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:
@@ -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)) {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user