Adapt HTTP GraphQlClient to GraphQL over HTTP spec

This commit adapts the GraphQlClient HTTP transports (async and sync) to
accept 4xx responses if the response content type is
"application/graphql-response+json".

This allows the client to support the "GraphQL over HTTP" specification.
This commit adds a new test suite to check compatibility against this
spec.

See gh-1117
This commit is contained in:
Brian Clozel
2025-02-12 16:46:28 +01:00
parent dd1a6933dd
commit 99594c4ca6
3 changed files with 329 additions and 6 deletions

View File

@@ -26,9 +26,11 @@ import org.springframework.graphql.GraphQlRequest;
import org.springframework.graphql.GraphQlResponse;
import org.springframework.graphql.MediaTypes;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
@@ -83,11 +85,25 @@ final class HttpGraphQlTransport implements GraphQlTransport {
attributes.putAll(clientRequest.getAttributes());
}
})
.retrieve()
.bodyToMono(MAP_TYPE)
.exchangeToMono((response) -> {
if (response.statusCode().equals(HttpStatus.OK)) {
return response.bodyToMono(MAP_TYPE);
}
else if (response.statusCode().is4xxClientError() && isGraphQlResponse(response)) {
return response.bodyToMono(MAP_TYPE);
}
else {
return response.createError();
}
})
.map(ResponseMapGraphQlResponse::new);
}
private static boolean isGraphQlResponse(ClientResponse clientResponse) {
return MediaTypes.APPLICATION_GRAPHQL_RESPONSE
.isCompatibleWith(clientResponse.headers().contentType().orElse(null));
}
@Override
public Flux<GraphQlResponse> executeSubscription(GraphQlRequest request) {
return this.webClient.post()

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2024 the original author or authors.
* Copyright 2002-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.
@@ -24,8 +24,11 @@ import org.springframework.graphql.GraphQlRequest;
import org.springframework.graphql.GraphQlResponse;
import org.springframework.graphql.MediaTypes;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.Assert;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
@@ -65,10 +68,21 @@ final class HttpSyncGraphQlTransport implements SyncGraphQlTransport {
.contentType(this.contentType)
.accept(MediaType.APPLICATION_JSON, MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
.body(request.toMap())
.retrieve()
.body(MAP_TYPE);
.exchange((httpRequest, httpResponse) -> {
if (httpResponse.getStatusCode().equals(HttpStatus.OK)) {
return httpResponse.bodyTo(MAP_TYPE);
}
else if (httpResponse.getStatusCode().is4xxClientError() && isGraphQlResponse(httpResponse)) {
return httpResponse.bodyTo(MAP_TYPE);
}
throw new HttpClientErrorException(httpResponse.getStatusCode(), httpResponse.getStatusText());
});
return new ResponseMapGraphQlResponse((body != null) ? body : Collections.emptyMap());
}
private static boolean isGraphQlResponse(ClientHttpResponse clientResponse) {
return MediaTypes.APPLICATION_GRAPHQL_RESPONSE
.isCompatibleWith(clientResponse.getHeaders().getContentType());
}
}

View File

@@ -0,0 +1,293 @@
/*
* 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.client;
import java.io.IOException;
import java.util.stream.Stream;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Named.named;
import static org.junit.jupiter.params.provider.Arguments.arguments;
/**
* Tests for {@link GraphQlClient} 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 HttpGraphQlClientProtocolTests {
private MockWebServer server;
@BeforeEach
void setup() throws IOException {
this.server = new MockWebServer();
this.server.start();
}
@AfterEach
void tearDown() throws IOException {
this.server.shutdown();
}
private static Stream<Arguments> graphQlClientTypes() {
return Stream.of(
arguments(named("HttpGraphQlClient", ClientType.ASYNC)),
arguments(named("HttpSyncGraphQlClient", ClientType.SYNC))
);
}
/*
* 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.
*/
@ParameterizedTest
@MethodSource("graphQlClientTypes")
void successWhenValidRequest(ClientType clientType) {
prepareOkResponse("""
{
"data": {
"greeting": "Hello World!"
}
}
""");
ClientGraphQlResponse response = createClient(clientType).document("{ greeting }")
.executeSync();
assertThat(response.isValid()).isTrue();
assertThat(response.getErrors()).isEmpty();
assertThat(response.field("greeting").toEntity(String.class)).isEqualTo("Hello World!");
}
/*
* 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
*/
@ParameterizedTest
@MethodSource("graphQlClientTypes")
void partialSuccessWhenError(ClientType clientType) {
prepareOkResponse("""
{
"errors":[
{
"message":"INTERNAL_ERROR for 4ed1a08d-e0ee-0e6b-bc66-4bb61f2e3edb",
"locations":[{"line":1,"column":24}],
"path":["bookById","author"],
"extensions":{"classification":"INTERNAL_ERROR"}
}
],
"data":{"bookById":{"id":"1","author":null}}
}
""");
ClientGraphQlResponse response = createClient(clientType)
.document("""
{
bookById(id: 1) {
id
author {
firstName
}
}
}
""")
.executeSync();
assertThat(response.isValid()).isTrue();
assertThat(response.getErrors()).singleElement()
.extracting("message").asString().contains("INTERNAL_ERROR");
assertThat(response.field("bookById.id").toEntity(Long.class)).isEqualTo(1L);
}
/*
* 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
*/
@ParameterizedTest
@MethodSource("graphQlClientTypes")
void requestErrorWhenInvalidRequest(ClientType clientType) {
prepareBadRequestResponse();
assertThatThrownBy(() -> createClient(clientType).document("{ greeting }")
.executeSync()).isInstanceOfAny(HttpClientErrorException.class, WebClientResponseException.class);
}
/*
* 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
*/
@ParameterizedTest
@MethodSource("graphQlClientTypes")
void requestErrorWhenDocumentParsingFailure(ClientType clientType) {
prepareBadRequestResponse("""
{
"errors":[
{
"message":"Invalid syntax with offending token '<EOF>' at line 1 column 2",
"locations":[{"line":1,"column":2}],
"extensions":{"classification":"InvalidSyntax"}
}
]
}
""");
ClientGraphQlResponse response = createClient(clientType).document("{")
.executeSync();
assertThat(response.isValid()).isFalse();
assertThat(response.getErrors()).singleElement()
.extracting("message").asString().contains("Invalid syntax with offending token");
}
/*
* 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
*/
@ParameterizedTest
@MethodSource("graphQlClientTypes")
void requestErrorWhenInvalidDocument(ClientType clientType) {
prepareBadRequestResponse("""
{
"errors":[
{
"message":"Validation error (FieldUndefined@[unknown]) : Field 'unknown' in type 'Query' is undefined",
"locations":[{"line":1,"column":3}],
"extensions":{"classification":"ValidationError"}}
]
}
""");
ClientGraphQlResponse response = createClient(clientType).document("{ unknown }")
.executeSync();
assertThat(response.isValid()).isFalse();
assertThat(response.getErrors()).singleElement()
.extracting("message").asString().contains("Validation 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.Operation-cannot-be-determined
*/
@ParameterizedTest
@MethodSource("graphQlClientTypes")
void requestErrorWhenUndeterminedOperation(ClientType clientType) {
prepareBadRequestResponse("""
{
"errors":[
{
"message":"Unknown operation named 'unknown'.",
"extensions":{"classification":"ValidationError"}
}
]
}
""");
ClientGraphQlResponse response = createClient(clientType).document("""
{
"query" : "{ greeting }",
"operationName" : "unknown"
}
""").executeSync();
assertThat(response.isValid()).isFalse();
assertThat(response.getErrors()).singleElement()
.extracting("message").asString().contains("Unknown operation named 'unknown'.");
}
/*
* 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
*/
@ParameterizedTest
@MethodSource("graphQlClientTypes")
void requestErrorWhenVariableCoercion(ClientType clientType) {
prepareBadRequestResponse("""
{
"errors":[
{
"message":"Validation error (WrongType@[bookById]) : argument 'id' with value 'BooleanValue{value=false}' is not a valid 'ID' - Expected an AST type of 'IntValue' or 'StringValue' but it was a 'BooleanValue'",
"locations":[{"line":1,"column":12}],
"extensions":{"classification":"ValidationError"}
}
]
}
""");
ClientGraphQlResponse response = createClient(clientType).document("{ bookById(id: false) { id } }").executeSync();
assertThat(response.isValid()).isFalse();
assertThat(response.getErrors()).singleElement()
.extracting("message").asString().contains("Validation error (WrongType@[bookById])");
}
/*
* 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
*/
@ParameterizedTest
@MethodSource("graphQlClientTypes")
void successWhenEmptyData(ClientType clientType) {
prepareOkResponse("{\"data\":null}");
ClientGraphQlResponse response = createClient(clientType).document("{ bookById(id: 100) { id } }").executeSync();
assertThat(response.isValid()).isFalse();
assertThat(response.getErrors()).isEmpty();
}
private GraphQlClient createClient(ClientType type) {
return switch (type) {
case ASYNC -> HttpGraphQlClient.builder().url(server.url("/").toString()).build();
case SYNC -> HttpSyncGraphQlClient.builder().url(server.url("/").toString()).build();
};
}
private void prepareOkResponse(String body) {
prepareResponse(200, body);
}
private void prepareBadRequestResponse(String body) {
prepareResponse(400, body);
}
private void prepareBadRequestResponse() {
MockResponse mockResponse = new MockResponse();
mockResponse.setResponseCode(400);
this.server.enqueue(mockResponse);
}
private void prepareResponse(int status, String body) {
MockResponse mockResponse = new MockResponse();
mockResponse.setResponseCode(status);
mockResponse.setHeader("Content-Type", "application/graphql-response+json");
mockResponse.setBody(body);
this.server.enqueue(mockResponse);
}
enum ClientType {
ASYNC, SYNC
}
}