Support application/graphql+json content type

The GraphQL HTTP spec now requires the `"application/graphql+json"`
content type.
This commit applies this type by default in the server and client
implementations. `"application/json"` is still accepted and produced if
requested by clients.

Closes gh-108
This commit is contained in:
Brian Clozel
2022-04-04 13:34:18 +02:00
parent 2cdaf344fc
commit e455ebb3d8
9 changed files with 117 additions and 19 deletions

View File

@@ -59,7 +59,7 @@ configure(moduleProjects) {
imports {
mavenBom "com.fasterxml.jackson:jackson-bom:2.13.1"
mavenBom "io.projectreactor:reactor-bom:2020.0.17"
mavenBom "org.springframework:spring-framework-bom:5.3.17"
mavenBom "org.springframework:spring-framework-bom:5.3.19-SNAPSHOT"
mavenBom "org.springframework.data:spring-data-bom:2021.2.0-M4"
mavenBom "org.springframework.security:spring-security-bom:5.7.0-M3"
mavenBom "com.querydsl:querydsl-bom:5.0.0"

View File

@@ -56,7 +56,9 @@ request body, as defined in the proposed
https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md[GraphQL over HTTP]
specification. Once the JSON body has been successfully decoded, the HTTP response
status is always 200 (OK), and any errors from GraphQL request execution appear in the
"errors" section of the GraphQL response.
"errors" section of the GraphQL response. The default and preferred choice of media type is
`"application/graphql+json"`, but `"application/json"` is also supported, as described in the
specification.
`GraphQlHttpHandler` can be exposed as an HTTP endpoint by declaring a `RouterFunction`
bean and using the `RouterFunctions` from Spring MVC or WebFlux to create the route. The

View File

@@ -55,12 +55,12 @@ final class WebTestClientTransport implements GraphQlTransport {
public Mono<GraphQlResponse> execute(GraphQlRequest request) {
Map<String, Object> responseMap = this.webTestClient.post()
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_GRAPHQL)
.accept(MediaType.APPLICATION_GRAPHQL)
.bodyValue(request.toMap())
.exchange()
.expectStatus().isOk()
.expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)
.expectHeader().contentTypeCompatibleWith(MediaType.APPLICATION_GRAPHQL)
.expectBody(MAP_TYPE)
.returnResult()
.getResponseBody();

View File

@@ -28,6 +28,7 @@ import org.springframework.messaging.rsocket.RSocketRequester;
import org.springframework.messaging.rsocket.RSocketStrategies;
import org.springframework.util.Assert;
import org.springframework.util.MimeType;
import org.springframework.util.MimeTypeUtils;
/**
@@ -60,8 +61,7 @@ final class DefaultRSocketGraphQlClientBuilder
}
private static RSocketRequester.Builder initRSocketRequestBuilder() {
MimeType mimeType = MimeType.valueOf("application/graphql+json");
RSocketRequester.Builder requesterBuilder = RSocketRequester.builder().dataMimeType(mimeType);
RSocketRequester.Builder requesterBuilder = RSocketRequester.builder().dataMimeType(MimeTypeUtils.APPLICATION_GRAPHQL);
if (jackson2Present) {
requesterBuilder.rsocketStrategies(
RSocketStrategies.builder()

View File

@@ -56,8 +56,8 @@ final class HttpGraphQlTransport implements GraphQlTransport {
@Override
public Mono<GraphQlResponse> execute(GraphQlRequest request) {
return this.webClient.post()
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_GRAPHQL)
.accept(MediaType.APPLICATION_GRAPHQL, MediaType.APPLICATION_JSON)
.bodyValue(request.toMap())
.retrieve()
.bodyToMono(MAP_TYPE)

View File

@@ -16,6 +16,8 @@
package org.springframework.graphql.server.webflux;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import org.apache.commons.logging.Log;
@@ -25,6 +27,7 @@ import reactor.core.publisher.Mono;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.WebGraphQlRequest;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
@@ -44,6 +47,8 @@ public class GraphQlHttpHandler {
new ParameterizedTypeReference<Map<String, Object>>() {
};
private static final List<MediaType> SUPPORTED_MEDIA_TYPES = Arrays.asList(MediaType.APPLICATION_GRAPHQL, MediaType.APPLICATION_JSON);
private final WebGraphQlHandler graphQlHandler;
/**
@@ -78,8 +83,18 @@ public class GraphQlHttpHandler {
}
ServerResponse.BodyBuilder builder = ServerResponse.ok();
builder.headers(headers -> headers.putAll(response.getResponseHeaders()));
builder.contentType(selectResponseMediaType(serverRequest));
return builder.bodyValue(response.toMap());
});
}
private static MediaType selectResponseMediaType(ServerRequest serverRequest) {
for (MediaType accepted : serverRequest.headers().accept()) {
if (SUPPORTED_MEDIA_TYPES.contains(accepted)) {
return accepted;
}
}
return MediaType.APPLICATION_GRAPHQL;
}
}

View File

@@ -17,6 +17,8 @@
package org.springframework.graphql.server.webmvc;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import javax.servlet.ServletException;
@@ -29,6 +31,7 @@ import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.graphql.server.WebGraphQlHandler;
import org.springframework.graphql.server.WebGraphQlRequest;
import org.springframework.http.MediaType;
import org.springframework.util.AlternativeJdkIdGenerator;
import org.springframework.util.Assert;
import org.springframework.util.IdGenerator;
@@ -50,7 +53,10 @@ public class GraphQlHttpHandler {
private static final Log logger = LogFactory.getLog(GraphQlHttpHandler.class);
private static final ParameterizedTypeReference<Map<String, Object>> MAP_PARAMETERIZED_TYPE_REF =
new ParameterizedTypeReference<Map<String, Object>>() {};
new ParameterizedTypeReference<Map<String, Object>>() {
};
private static final List<MediaType> SUPPORTED_MEDIA_TYPES = Arrays.asList(MediaType.APPLICATION_GRAPHQL, MediaType.APPLICATION_JSON);
private final IdGenerator idGenerator = new AlternativeJdkIdGenerator();
@@ -89,6 +95,7 @@ public class GraphQlHttpHandler {
}
ServerResponse.BodyBuilder builder = ServerResponse.ok();
builder.headers(headers -> headers.putAll(response.getResponseHeaders()));
builder.contentType(selectResponseMediaType(serverRequest));
return builder.body(response.toMap());
});
@@ -104,4 +111,13 @@ public class GraphQlHttpHandler {
}
}
private static MediaType selectResponseMediaType(ServerRequest serverRequest) {
for (MediaType accepted : serverRequest.headers().accept()) {
if (SUPPORTED_MEDIA_TYPES.contains(accepted)) {
return accepted;
}
}
return MediaType.APPLICATION_GRAPHQL;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 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.
@@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
import org.springframework.graphql.GraphQlSetup;
import org.springframework.http.MediaType;
import org.springframework.http.codec.EncoderHttpMessageWriter;
import org.springframework.http.codec.HttpMessageWriter;
import org.springframework.http.codec.json.Jackson2JsonEncoder;
@@ -45,14 +46,51 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
public class GraphQlHttpHandlerTests {
private final GraphQlHttpHandler greetingHandler = GraphQlSetup.schemaContent("type Query { greeting: String }")
.queryFetcher("greeting", (env) -> "Hello").toHttpHandlerWebFlux();
@Test
void shouldProduceApplicationGraphQlByDefault() {
MockServerHttpRequest httpRequest = MockServerHttpRequest.post("/")
.contentType(MediaType.APPLICATION_GRAPHQL).accept(MediaType.ALL).build();
MockServerHttpResponse httpResponse = handleRequest(
httpRequest, this.greetingHandler, Collections.singletonMap("query", "{greeting}"));
assertThat(httpResponse.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_GRAPHQL);
}
@Test
void shouldProduceApplicationGraphQl() {
MockServerHttpRequest httpRequest = MockServerHttpRequest.post("/")
.contentType(MediaType.APPLICATION_GRAPHQL).accept(MediaType.APPLICATION_GRAPHQL).build();
MockServerHttpResponse httpResponse = handleRequest(
httpRequest, this.greetingHandler, Collections.singletonMap("query", "{greeting}"));
assertThat(httpResponse.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_GRAPHQL);
}
@Test
void shouldProduceApplicationJson() {
MockServerHttpRequest httpRequest = MockServerHttpRequest.post("/")
.contentType(MediaType.APPLICATION_GRAPHQL).accept(MediaType.APPLICATION_JSON).build();
MockServerHttpResponse httpResponse = handleRequest(
httpRequest, this.greetingHandler, Collections.singletonMap("query", "{greeting}"));
assertThat(httpResponse.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
}
@Test
void locale() {
GraphQlHttpHandler handler = GraphQlSetup.schemaContent("type Query { greeting: String }")
.queryFetcher("greeting", (env) -> "Hello in " + env.getLocale())
.toHttpHandlerWebFlux();
MockServerHttpRequest httpRequest =
MockServerHttpRequest.post("/").acceptLanguageAsLocales(Locale.FRENCH).build();
MockServerHttpRequest httpRequest = MockServerHttpRequest.post("/")
.contentType(MediaType.APPLICATION_GRAPHQL).accept(MediaType.APPLICATION_GRAPHQL).acceptLanguageAsLocales(Locale.FRENCH).build();
MockServerHttpResponse httpResponse = handleRequest(
httpRequest, handler, Collections.singletonMap("query", "{greeting}"));
@@ -67,7 +105,8 @@ public class GraphQlHttpHandlerTests {
.queryFetcher("showId", (env) -> env.getExecutionId().toString())
.toHttpHandlerWebFlux();
MockServerHttpRequest httpRequest = MockServerHttpRequest.post("/").build();
MockServerHttpRequest httpRequest = MockServerHttpRequest.post("/")
.contentType(MediaType.APPLICATION_GRAPHQL).accept(MediaType.APPLICATION_GRAPHQL).build();
MockServerHttpResponse httpResponse = handleRequest(
httpRequest, handler, Collections.singletonMap("query", "{showId}"));

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* Copyright 2002-2022 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.
@@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.graphql.GraphQlSetup;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.mock.web.MockHttpServletRequest;
@@ -44,19 +45,43 @@ import static org.assertj.core.api.Assertions.assertThatNoException;
/**
* Tests for {@link GraphQlHttpHandler}.
* @author Rossen Stoyanchev
* @author Brian Clozel
*/
public class GraphQlHttpHandlerTests {
private static final List<HttpMessageConverter<?>> MESSAGE_READERS =
Collections.singletonList(new MappingJackson2HttpMessageConverter());
private final GraphQlHttpHandler greetingHandler = GraphQlSetup.schemaContent("type Query { greeting: String }")
.queryFetcher("greeting", (env) -> "Hello").toHttpHandler();
@Test
void shouldProduceApplicationGraphQlByDefault() throws Exception {
MockHttpServletRequest servletRequest = createServletRequest("{\"query\":\"{ greeting }\"}", "*/*");
MockHttpServletResponse servletResponse = handleRequest(servletRequest, this.greetingHandler);
assertThat(servletResponse.getContentType()).isEqualTo(MediaType.APPLICATION_GRAPHQL_VALUE);
}
@Test
void shouldProduceApplicationGraphQl() throws Exception {
MockHttpServletRequest servletRequest = createServletRequest("{\"query\":\"{ greeting }\"}", MediaType.APPLICATION_GRAPHQL_VALUE);
MockHttpServletResponse servletResponse = handleRequest(servletRequest, this.greetingHandler);
assertThat(servletResponse.getContentType()).isEqualTo(MediaType.APPLICATION_GRAPHQL_VALUE);
}
@Test
void shouldProduceApplicationJson() throws Exception {
MockHttpServletRequest servletRequest = createServletRequest("{\"query\":\"{ greeting }\"}", "application/json");
MockHttpServletResponse servletResponse = handleRequest(servletRequest, this.greetingHandler);
assertThat(servletResponse.getContentType()).isEqualTo("application/json");
}
@Test
void locale() throws Exception {
GraphQlHttpHandler handler = GraphQlSetup.schemaContent("type Query { greeting: String }")
.queryFetcher("greeting", (env) -> "Hello in " + env.getLocale())
.toHttpHandler();
MockHttpServletRequest servletRequest = createServletRequest("{\"query\":\"{ greeting }\"}");
MockHttpServletRequest servletRequest = createServletRequest("{\"query\":\"{ greeting }\"}", MediaType.APPLICATION_GRAPHQL_VALUE);
LocaleContextHolder.setLocale(Locale.FRENCH);
try {
@@ -76,7 +101,7 @@ public class GraphQlHttpHandlerTests {
.queryFetcher("showId", (env) -> env.getExecutionId().toString())
.toHttpHandler();
MockHttpServletRequest servletRequest = createServletRequest("{\"query\":\"{ showId }\"}");
MockHttpServletRequest servletRequest = createServletRequest("{\"query\":\"{ showId }\"}", MediaType.APPLICATION_GRAPHQL_VALUE);
MockHttpServletResponse servletResponse = handleRequest(servletRequest, handler);
DocumentContext document = JsonPath.parse(servletResponse.getContentAsString());
@@ -84,10 +109,11 @@ public class GraphQlHttpHandlerTests {
assertThatNoException().isThrownBy(() -> UUID.fromString(id));
}
private MockHttpServletRequest createServletRequest(String query) {
private MockHttpServletRequest createServletRequest(String query, String accept) {
MockHttpServletRequest servletRequest = new MockHttpServletRequest("POST", "/");
servletRequest.setContentType("application/json");
servletRequest.setContentType(MediaType.APPLICATION_GRAPHQL_VALUE);
servletRequest.setContent(query.getBytes(StandardCharsets.UTF_8));
servletRequest.addHeader("Accept", accept);
servletRequest.setAsyncSupported(true);
return servletRequest;
}