diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java index 191928b4..c9c14425 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webflux/GraphQlHttpHandler.java @@ -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 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. + *

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. + *

By default, this is set to {@code false}. + * @since 1.4.0 + * @see GraphQL over HTTP specification + */ + 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 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)) { diff --git a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java index 7592d938..071dade0 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandler.java @@ -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 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. + *

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. + *

By default, this is set to {@code false}. + * @since 1.4.0 + * @see GraphQL over HTTP specification + */ + 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 responseMono) { CompletableFuture 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)) { diff --git a/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpProtocolTests.java b/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpProtocolTests.java new file mode 100644 index 00000000..e1c35aa3 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpProtocolTests.java @@ -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 GraphQL over HTTP specification + */ +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 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 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 { + + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpProtocolTests.java b/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpProtocolTests.java new file mode 100644 index 00000000..657ddbc2 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpProtocolTests.java @@ -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 GraphQL over HTTP specification + */ +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 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 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 { + + } + +}