From e455ebb3d819470225ff5a43c59611a14c477ea5 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 4 Apr 2022 13:34:18 +0200 Subject: [PATCH] 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 --- build.gradle | 2 +- .../src/docs/asciidoc/index.adoc | 4 +- .../test/tester/WebTestClientTransport.java | 6 +-- .../DefaultRSocketGraphQlClientBuilder.java | 4 +- .../graphql/client/HttpGraphQlTransport.java | 4 +- .../server/webflux/GraphQlHttpHandler.java | 15 ++++++ .../server/webmvc/GraphQlHttpHandler.java | 18 ++++++- .../webflux/GraphQlHttpHandlerTests.java | 47 +++++++++++++++++-- .../webmvc/GraphQlHttpHandlerTests.java | 36 ++++++++++++-- 9 files changed, 117 insertions(+), 19 deletions(-) diff --git a/build.gradle b/build.gradle index a92c67fe..e5a5d0d1 100644 --- a/build.gradle +++ b/build.gradle @@ -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" diff --git a/spring-graphql-docs/src/docs/asciidoc/index.adoc b/spring-graphql-docs/src/docs/asciidoc/index.adoc index a0f9492a..a08b66db 100644 --- a/spring-graphql-docs/src/docs/asciidoc/index.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/index.adoc @@ -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 diff --git a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/WebTestClientTransport.java b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/WebTestClientTransport.java index 90469759..b98b1dc7 100644 --- a/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/WebTestClientTransport.java +++ b/spring-graphql-test/src/main/java/org/springframework/graphql/test/tester/WebTestClientTransport.java @@ -55,12 +55,12 @@ final class WebTestClientTransport implements GraphQlTransport { public Mono execute(GraphQlRequest request) { Map 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(); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRSocketGraphQlClientBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRSocketGraphQlClientBuilder.java index 2370cea1..92a0cb4d 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRSocketGraphQlClientBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/DefaultRSocketGraphQlClientBuilder.java @@ -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() diff --git a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java index 31045231..400b3be6 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/client/HttpGraphQlTransport.java @@ -56,8 +56,8 @@ final class HttpGraphQlTransport implements GraphQlTransport { @Override public Mono 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) 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 ab9e8f9b..cf9703fd 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 @@ -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>() { }; + private static final List 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; + } + } 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 9338bac1..ab3282d7 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 @@ -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_PARAMETERIZED_TYPE_REF = - new ParameterizedTypeReference>() {}; + new ParameterizedTypeReference>() { + }; + + private static final List 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; + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpHandlerTests.java index 6d43e117..694f1283 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpHandlerTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/server/webflux/GraphQlHttpHandlerTests.java @@ -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}")); diff --git a/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandlerTests.java index a0fb92e0..fcab7679 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandlerTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/server/webmvc/GraphQlHttpHandlerTests.java @@ -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> 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; }