Skip to content

Commit 99594c4

Browse files
committed
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
1 parent dd1a693 commit 99594c4

File tree

3 files changed

+329
-6
lines changed

3 files changed

+329
-6
lines changed

spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
import org.springframework.graphql.GraphQlResponse;
2727
import org.springframework.graphql.MediaTypes;
2828
import org.springframework.http.HttpHeaders;
29+
import org.springframework.http.HttpStatus;
2930
import org.springframework.http.MediaType;
3031
import org.springframework.http.codec.ServerSentEvent;
3132
import org.springframework.util.Assert;
33+
import org.springframework.web.reactive.function.client.ClientResponse;
3234
import org.springframework.web.reactive.function.client.WebClient;
3335

3436

@@ -83,11 +85,25 @@ public Mono<GraphQlResponse> execute(GraphQlRequest request) {
8385
attributes.putAll(clientRequest.getAttributes());
8486
}
8587
})
86-
.retrieve()
87-
.bodyToMono(MAP_TYPE)
88+
.exchangeToMono((response) -> {
89+
if (response.statusCode().equals(HttpStatus.OK)) {
90+
return response.bodyToMono(MAP_TYPE);
91+
}
92+
else if (response.statusCode().is4xxClientError() && isGraphQlResponse(response)) {
93+
return response.bodyToMono(MAP_TYPE);
94+
}
95+
else {
96+
return response.createError();
97+
}
98+
})
8899
.map(ResponseMapGraphQlResponse::new);
89100
}
90101

102+
private static boolean isGraphQlResponse(ClientResponse clientResponse) {
103+
return MediaTypes.APPLICATION_GRAPHQL_RESPONSE
104+
.isCompatibleWith(clientResponse.headers().contentType().orElse(null));
105+
}
106+
91107
@Override
92108
public Flux<GraphQlResponse> executeSubscription(GraphQlRequest request) {
93109
return this.webClient.post()

spring-graphql/src/main/java/org/springframework/graphql/client/HttpSyncGraphQlTransport.java

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -24,8 +24,11 @@
2424
import org.springframework.graphql.GraphQlResponse;
2525
import org.springframework.graphql.MediaTypes;
2626
import org.springframework.http.HttpHeaders;
27+
import org.springframework.http.HttpStatus;
2728
import org.springframework.http.MediaType;
29+
import org.springframework.http.client.ClientHttpResponse;
2830
import org.springframework.util.Assert;
31+
import org.springframework.web.client.HttpClientErrorException;
2932
import org.springframework.web.client.RestClient;
3033

3134

@@ -65,10 +68,21 @@ public GraphQlResponse execute(GraphQlRequest request) {
6568
.contentType(this.contentType)
6669
.accept(MediaType.APPLICATION_JSON, MediaTypes.APPLICATION_GRAPHQL_RESPONSE)
6770
.body(request.toMap())
68-
.retrieve()
69-
.body(MAP_TYPE);
70-
71+
.exchange((httpRequest, httpResponse) -> {
72+
if (httpResponse.getStatusCode().equals(HttpStatus.OK)) {
73+
return httpResponse.bodyTo(MAP_TYPE);
74+
}
75+
else if (httpResponse.getStatusCode().is4xxClientError() && isGraphQlResponse(httpResponse)) {
76+
return httpResponse.bodyTo(MAP_TYPE);
77+
}
78+
throw new HttpClientErrorException(httpResponse.getStatusCode(), httpResponse.getStatusText());
79+
});
7180
return new ResponseMapGraphQlResponse((body != null) ? body : Collections.emptyMap());
7281
}
7382

83+
private static boolean isGraphQlResponse(ClientHttpResponse clientResponse) {
84+
return MediaTypes.APPLICATION_GRAPHQL_RESPONSE
85+
.isCompatibleWith(clientResponse.getHeaders().getContentType());
86+
}
87+
7488
}
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
/*
2+
* Copyright 2020-2025 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.graphql.client;
18+
19+
import java.io.IOException;
20+
import java.util.stream.Stream;
21+
22+
import okhttp3.mockwebserver.MockResponse;
23+
import okhttp3.mockwebserver.MockWebServer;
24+
import org.junit.jupiter.api.AfterEach;
25+
import org.junit.jupiter.api.BeforeEach;
26+
import org.junit.jupiter.params.ParameterizedTest;
27+
import org.junit.jupiter.params.provider.Arguments;
28+
import org.junit.jupiter.params.provider.MethodSource;
29+
30+
import org.springframework.web.client.HttpClientErrorException;
31+
import org.springframework.web.reactive.function.client.WebClientResponseException;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
35+
import static org.junit.jupiter.api.Named.named;
36+
import static org.junit.jupiter.params.provider.Arguments.arguments;
37+
38+
/**
39+
* Tests for {@link GraphQlClient} that check whether it supports
40+
* the GraphQL over HTTP specification.
41+
*
42+
* @see <a href="https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json">GraphQL over HTTP specification</a>
43+
*/
44+
public class HttpGraphQlClientProtocolTests {
45+
46+
private MockWebServer server;
47+
48+
@BeforeEach
49+
void setup() throws IOException {
50+
this.server = new MockWebServer();
51+
this.server.start();
52+
}
53+
54+
@AfterEach
55+
void tearDown() throws IOException {
56+
this.server.shutdown();
57+
}
58+
59+
private static Stream<Arguments> graphQlClientTypes() {
60+
return Stream.of(
61+
arguments(named("HttpGraphQlClient", ClientType.ASYNC)),
62+
arguments(named("HttpSyncGraphQlClient", ClientType.SYNC))
63+
);
64+
}
65+
66+
/*
67+
* If the GraphQL response contains the data entry, and it is not null,
68+
* then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code.
69+
*/
70+
@ParameterizedTest
71+
@MethodSource("graphQlClientTypes")
72+
void successWhenValidRequest(ClientType clientType) {
73+
prepareOkResponse("""
74+
{
75+
"data": {
76+
"greeting": "Hello World!"
77+
}
78+
}
79+
""");
80+
ClientGraphQlResponse response = createClient(clientType).document("{ greeting }")
81+
.executeSync();
82+
assertThat(response.isValid()).isTrue();
83+
assertThat(response.getErrors()).isEmpty();
84+
assertThat(response.field("greeting").toEntity(String.class)).isEqualTo("Hello World!");
85+
}
86+
87+
/*
88+
* If the GraphQL response contains the data entry and it is not null,
89+
* then the server MUST reply with a 2xx status code and SHOULD reply with 200 status code.
90+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Field-errors-encountered-during-execution
91+
*/
92+
@ParameterizedTest
93+
@MethodSource("graphQlClientTypes")
94+
void partialSuccessWhenError(ClientType clientType) {
95+
prepareOkResponse("""
96+
{
97+
"errors":[
98+
{
99+
"message":"INTERNAL_ERROR for 4ed1a08d-e0ee-0e6b-bc66-4bb61f2e3edb",
100+
"locations":[{"line":1,"column":24}],
101+
"path":["bookById","author"],
102+
"extensions":{"classification":"INTERNAL_ERROR"}
103+
}
104+
],
105+
"data":{"bookById":{"id":"1","author":null}}
106+
}
107+
""");
108+
109+
ClientGraphQlResponse response = createClient(clientType)
110+
.document("""
111+
{
112+
bookById(id: 1) {
113+
id
114+
author {
115+
firstName
116+
}
117+
}
118+
}
119+
""")
120+
.executeSync();
121+
122+
assertThat(response.isValid()).isTrue();
123+
assertThat(response.getErrors()).singleElement()
124+
.extracting("message").asString().contains("INTERNAL_ERROR");
125+
assertThat(response.field("bookById.id").toEntity(Long.class)).isEqualTo(1L);
126+
}
127+
128+
/*
129+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
130+
* then the server SHOULD reply with 400 status code.
131+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.JSON-parsing-failure
132+
*/
133+
@ParameterizedTest
134+
@MethodSource("graphQlClientTypes")
135+
void requestErrorWhenInvalidRequest(ClientType clientType) {
136+
prepareBadRequestResponse();
137+
assertThatThrownBy(() -> createClient(clientType).document("{ greeting }")
138+
.executeSync()).isInstanceOfAny(HttpClientErrorException.class, WebClientResponseException.class);
139+
}
140+
141+
/*
142+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
143+
* then the server SHOULD reply with 400 status code.
144+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-parsing-failure
145+
*/
146+
@ParameterizedTest
147+
@MethodSource("graphQlClientTypes")
148+
void requestErrorWhenDocumentParsingFailure(ClientType clientType) {
149+
prepareBadRequestResponse("""
150+
{
151+
"errors":[
152+
{
153+
"message":"Invalid syntax with offending token '<EOF>' at line 1 column 2",
154+
"locations":[{"line":1,"column":2}],
155+
"extensions":{"classification":"InvalidSyntax"}
156+
}
157+
]
158+
}
159+
""");
160+
ClientGraphQlResponse response = createClient(clientType).document("{")
161+
.executeSync();
162+
assertThat(response.isValid()).isFalse();
163+
assertThat(response.getErrors()).singleElement()
164+
.extracting("message").asString().contains("Invalid syntax with offending token");
165+
}
166+
167+
/*
168+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
169+
* then the server SHOULD reply with 400 status code.
170+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Document-validation-failure
171+
*/
172+
@ParameterizedTest
173+
@MethodSource("graphQlClientTypes")
174+
void requestErrorWhenInvalidDocument(ClientType clientType) {
175+
prepareBadRequestResponse("""
176+
{
177+
"errors":[
178+
{
179+
"message":"Validation error (FieldUndefined@[unknown]) : Field 'unknown' in type 'Query' is undefined",
180+
"locations":[{"line":1,"column":3}],
181+
"extensions":{"classification":"ValidationError"}}
182+
]
183+
}
184+
""");
185+
ClientGraphQlResponse response = createClient(clientType).document("{ unknown }")
186+
.executeSync();
187+
assertThat(response.isValid()).isFalse();
188+
assertThat(response.getErrors()).singleElement()
189+
.extracting("message").asString().contains("Validation error");
190+
}
191+
192+
/*
193+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
194+
* then the server SHOULD reply with 400 status code.
195+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Operation-cannot-be-determined
196+
*/
197+
@ParameterizedTest
198+
@MethodSource("graphQlClientTypes")
199+
void requestErrorWhenUndeterminedOperation(ClientType clientType) {
200+
prepareBadRequestResponse("""
201+
{
202+
"errors":[
203+
{
204+
"message":"Unknown operation named 'unknown'.",
205+
"extensions":{"classification":"ValidationError"}
206+
}
207+
]
208+
}
209+
""");
210+
ClientGraphQlResponse response = createClient(clientType).document("""
211+
{
212+
"query" : "{ greeting }",
213+
"operationName" : "unknown"
214+
}
215+
""").executeSync();
216+
assertThat(response.isValid()).isFalse();
217+
assertThat(response.getErrors()).singleElement()
218+
.extracting("message").asString().contains("Unknown operation named 'unknown'.");
219+
}
220+
221+
/*
222+
* If the request is not a well-formed GraphQL-over-HTTP request, or it does not pass validation,
223+
* then the server SHOULD reply with 400 status code.
224+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json.Examples.Variable-coercion-failure
225+
*/
226+
@ParameterizedTest
227+
@MethodSource("graphQlClientTypes")
228+
void requestErrorWhenVariableCoercion(ClientType clientType) {
229+
prepareBadRequestResponse("""
230+
{
231+
"errors":[
232+
{
233+
"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'",
234+
"locations":[{"line":1,"column":12}],
235+
"extensions":{"classification":"ValidationError"}
236+
}
237+
]
238+
}
239+
""");
240+
ClientGraphQlResponse response = createClient(clientType).document("{ bookById(id: false) { id } }").executeSync();
241+
assertThat(response.isValid()).isFalse();
242+
assertThat(response.getErrors()).singleElement()
243+
.extracting("message").asString().contains("Validation error (WrongType@[bookById])");
244+
}
245+
246+
/*
247+
* If the GraphQL response contains the data entry and it is null, then the server SHOULD reply
248+
* with a 2xx status code and it is RECOMMENDED it replies with 200 status code.
249+
* https://graphql.github.io/graphql-over-http/draft/#sec-application-graphql-response-json
250+
*/
251+
@ParameterizedTest
252+
@MethodSource("graphQlClientTypes")
253+
void successWhenEmptyData(ClientType clientType) {
254+
prepareOkResponse("{\"data\":null}");
255+
ClientGraphQlResponse response = createClient(clientType).document("{ bookById(id: 100) { id } }").executeSync();
256+
assertThat(response.isValid()).isFalse();
257+
assertThat(response.getErrors()).isEmpty();
258+
}
259+
260+
private GraphQlClient createClient(ClientType type) {
261+
return switch (type) {
262+
case ASYNC -> HttpGraphQlClient.builder().url(server.url("/").toString()).build();
263+
case SYNC -> HttpSyncGraphQlClient.builder().url(server.url("/").toString()).build();
264+
};
265+
}
266+
267+
private void prepareOkResponse(String body) {
268+
prepareResponse(200, body);
269+
}
270+
271+
private void prepareBadRequestResponse(String body) {
272+
prepareResponse(400, body);
273+
}
274+
275+
private void prepareBadRequestResponse() {
276+
MockResponse mockResponse = new MockResponse();
277+
mockResponse.setResponseCode(400);
278+
this.server.enqueue(mockResponse);
279+
}
280+
281+
private void prepareResponse(int status, String body) {
282+
MockResponse mockResponse = new MockResponse();
283+
mockResponse.setResponseCode(status);
284+
mockResponse.setHeader("Content-Type", "application/graphql-response+json");
285+
mockResponse.setBody(body);
286+
this.server.enqueue(mockResponse);
287+
}
288+
289+
enum ClientType {
290+
ASYNC, SYNC
291+
}
292+
293+
}

0 commit comments

Comments
 (0)