diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/spring-graphql.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/spring-graphql.adoc index 0add65c8a45..6252f65d7c2 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/spring-graphql.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/web/spring-graphql.adoc @@ -96,6 +96,9 @@ The GraphQL HTTP endpoint is at HTTP POST `/graphql` by default. It also supports the `"text/event-stream"` media type over Server Sent Events for subscriptions only. The path can be customized with configprop:spring.graphql.http.path[]. +By default, the HTTP transport only allows HTTP POST requests but this can be configured with configprop:spring.graphql.http.methods[] +and configprop:spring.graphql.http.sse.methods[], see {url-spring-graphql-docs}/transports.html[the transports section of the Spring GraphQL documentation] for more. + TIP: The HTTP endpoint for both Spring MVC and Spring WebFlux is provided by a `RouterFunction` bean with an javadoc:org.springframework.core.annotation.Order[format=annotation] of `0`. If you define your own `RouterFunction` beans, you may want to add appropriate javadoc:org.springframework.core.annotation.Order[format=annotation] annotations to ensure that they are sorted correctly. diff --git a/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/GraphQlProperties.java b/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/GraphQlProperties.java index 553eed0aa03..2ff21b52be6 100644 --- a/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/GraphQlProperties.java +++ b/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/GraphQlProperties.java @@ -18,6 +18,7 @@ package org.springframework.boot.graphql.autoconfigure; import java.time.Duration; import java.util.Arrays; +import java.util.Set; import org.jspecify.annotations.Nullable; @@ -70,6 +71,11 @@ public class GraphQlProperties { */ private String path = "/graphql"; + /** + * HTTP methods supported to make GraphQL requests. + */ + private Set methods = Set.of("POST"); + private final Sse sse = new Sse(); public String getPath() { @@ -80,6 +86,14 @@ public class GraphQlProperties { this.path = path; } + public Set getMethods() { + return this.methods; + } + + public void setMethods(Set methods) { + this.methods = methods; + } + public Sse getSse() { return this.sse; } @@ -308,6 +322,11 @@ public class GraphQlProperties { */ private @Nullable Duration timeout; + /** + * HTTP methods supported to establish a GraphQL SSE connection. + */ + private Set methods = Set.of("POST"); + public @Nullable Duration getKeepAlive() { return this.keepAlive; } @@ -324,6 +343,14 @@ public class GraphQlProperties { this.timeout = timeout; } + public Set getMethods() { + return this.methods; + } + + public void setMethods(Set methods) { + this.methods = methods; + } + } } diff --git a/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/reactive/GraphQlWebFluxAutoConfiguration.java b/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/reactive/GraphQlWebFluxAutoConfiguration.java index f29a34e64f1..d2a68cfa1f6 100644 --- a/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/reactive/GraphQlWebFluxAutoConfiguration.java +++ b/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/reactive/GraphQlWebFluxAutoConfiguration.java @@ -17,6 +17,8 @@ package org.springframework.boot.graphql.autoconfigure.reactive; import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; import graphql.GraphQL; import org.apache.commons.logging.Log; @@ -90,8 +92,10 @@ public final class GraphQlWebFluxAutoConfiguration { @Bean @ConditionalOnMissingBean - GraphQlHttpHandler graphQlHttpHandler(WebGraphQlHandler webGraphQlHandler) { - return GraphQlHttpHandler.builder(webGraphQlHandler).build(); + GraphQlHttpHandler graphQlHttpHandler(WebGraphQlHandler webGraphQlHandler, GraphQlProperties properties) { + return GraphQlHttpHandler.builder(webGraphQlHandler) + .httpMethods(asHttpMethods(properties.getHttp().getMethods())) + .build(); } @Bean @@ -100,6 +104,7 @@ public final class GraphQlWebFluxAutoConfiguration { return GraphQlSseHandler.builder(webGraphQlHandler) .timeout(properties.getHttp().getSse().getTimeout()) .keepAliveDuration(properties.getHttp().getSse().getKeepAlive()) + .httpMethods(asHttpMethods(properties.getHttp().getSse().getMethods())) .build(); } @@ -115,12 +120,15 @@ public final class GraphQlWebFluxAutoConfiguration { RouterFunction graphQlRouterFunction(GraphQlHttpHandler httpHandler, GraphQlSseHandler sseHandler, ObjectProvider graphQlSourceProvider, GraphQlProperties properties) { String path = properties.getHttp().getPath(); - logger.info(LogMessage.format("GraphQL endpoint HTTP POST %s", path)); + Set allowedMethods = concat(httpHandler.getHttpMethods(), sseHandler.getHttpMethods()); + logger.info(LogMessage.format("GraphQL endpoint HTTP %s %s", allowedMethods, path)); RouterFunctions.Builder builder = RouterFunctions.route(); - builder.route(GraphQlRequestPredicates.graphQlHttp(path), httpHandler::handleRequest); - builder.route(GraphQlRequestPredicates.graphQlSse(path), sseHandler::handleRequest); + builder.route(GraphQlRequestPredicates.graphQlHttp(path, httpHandler.getHttpMethods()), + httpHandler::handleRequest); + builder.route(GraphQlRequestPredicates.graphQlSse(path, sseHandler.getHttpMethods()), + sseHandler::handleRequest); builder.POST(path, this::unsupportedMediaType); - builder.GET(path, this::onlyAllowPost); + builder.GET(path, (request) -> methodNotAllowed(allowedMethods)); if (properties.getGraphiql().isEnabled()) { GraphiQlHandler graphQlHandler = createGraphQlHandler(properties, path); builder.GET(properties.getGraphiql().getPath(), graphQlHandler::handleRequest); @@ -145,12 +153,20 @@ public final class GraphQlWebFluxAutoConfiguration { headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); } - private Mono onlyAllowPost(ServerRequest request) { - return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).headers(this::onlyAllowPost).build(); + private Mono methodNotAllowed(Set allowedMethods) { + return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED) + .headers((headers) -> headers.setAllow(allowedMethods)) + .build(); } - private void onlyAllowPost(HttpHeaders headers) { - headers.setAllow(Collections.singleton(HttpMethod.POST)); + private Set concat(Set methods, Set otherMethods) { + Set allowedMethods = new LinkedHashSet<>(methods); + allowedMethods.addAll(otherMethods); + return allowedMethods; + } + + private static HttpMethod[] asHttpMethods(Set methods) { + return methods.stream().map(HttpMethod::valueOf).toArray(HttpMethod[]::new); } @Configuration(proxyBeanMethods = false) diff --git a/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/servlet/GraphQlWebMvcAutoConfiguration.java b/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/servlet/GraphQlWebMvcAutoConfiguration.java index 97cbe30a119..6853069cf6d 100644 --- a/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/servlet/GraphQlWebMvcAutoConfiguration.java +++ b/module/spring-boot-graphql/src/main/java/org/springframework/boot/graphql/autoconfigure/servlet/GraphQlWebMvcAutoConfiguration.java @@ -17,7 +17,9 @@ package org.springframework.boot.graphql.autoconfigure.servlet; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; import graphql.GraphQL; import jakarta.websocket.server.ServerContainer; @@ -95,8 +97,10 @@ public final class GraphQlWebMvcAutoConfiguration { @Bean @ConditionalOnMissingBean - GraphQlHttpHandler graphQlHttpHandler(WebGraphQlHandler webGraphQlHandler) { - return GraphQlHttpHandler.builder(webGraphQlHandler).build(); + GraphQlHttpHandler graphQlHttpHandler(WebGraphQlHandler webGraphQlHandler, GraphQlProperties properties) { + return GraphQlHttpHandler.builder(webGraphQlHandler) + .httpMethods(asHttpMethods(properties.getHttp().getMethods())) + .build(); } @Bean @@ -105,6 +109,7 @@ public final class GraphQlWebMvcAutoConfiguration { return GraphQlSseHandler.builder(webGraphQlHandler) .timeout(properties.getHttp().getSse().getTimeout()) .keepAliveDuration(properties.getHttp().getSse().getKeepAlive()) + .httpMethods(asHttpMethods(properties.getHttp().getSse().getMethods())) .build(); } @@ -120,12 +125,15 @@ public final class GraphQlWebMvcAutoConfiguration { RouterFunction graphQlRouterFunction(GraphQlHttpHandler httpHandler, GraphQlSseHandler sseHandler, ObjectProvider graphQlSourceProvider, GraphQlProperties properties) { String path = properties.getHttp().getPath(); - logger.info(LogMessage.format("GraphQL endpoint HTTP POST %s", path)); + Set allowedMethods = concat(httpHandler.getHttpMethods(), sseHandler.getHttpMethods()); + logger.info(LogMessage.format("GraphQL endpoint HTTP %s %s", allowedMethods, path)); RouterFunctions.Builder builder = RouterFunctions.route(); - builder.route(GraphQlRequestPredicates.graphQlHttp(path), httpHandler::handleRequest); - builder.route(GraphQlRequestPredicates.graphQlSse(path), sseHandler::handleRequest); + builder.route(GraphQlRequestPredicates.graphQlHttp(path, httpHandler.getHttpMethods()), + httpHandler::handleRequest); + builder.route(GraphQlRequestPredicates.graphQlSse(path, sseHandler.getHttpMethods()), + sseHandler::handleRequest); builder.POST(path, this::unsupportedMediaType); - builder.GET(path, this::onlyAllowPost); + builder.GET(path, (request) -> methodNotAllowed(allowedMethods)); if (properties.getGraphiql().isEnabled()) { GraphiQlHandler graphiQLHandler = createGraphiQLHandler(properties, path); builder.GET(properties.getGraphiql().getPath(), graphiQLHandler::handleRequest); @@ -150,12 +158,20 @@ public final class GraphQlWebMvcAutoConfiguration { headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); } - private ServerResponse onlyAllowPost(ServerRequest request) { - return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED).headers(this::onlyAllowPost).build(); + private ServerResponse methodNotAllowed(Set allowedMethods) { + return ServerResponse.status(HttpStatus.METHOD_NOT_ALLOWED) + .headers((headers) -> headers.setAllow(allowedMethods)) + .build(); } - private void onlyAllowPost(HttpHeaders headers) { - headers.setAllow(Collections.singleton(HttpMethod.POST)); + private Set concat(Set methods, Set otherMethods) { + Set allowedMethods = new LinkedHashSet<>(methods); + allowedMethods.addAll(otherMethods); + return allowedMethods; + } + + private static HttpMethod[] asHttpMethods(Set methods) { + return methods.stream().map(HttpMethod::valueOf).toArray(HttpMethod[]::new); } @Configuration(proxyBeanMethods = false) diff --git a/module/spring-boot-graphql/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/module/spring-boot-graphql/src/main/resources/META-INF/additional-spring-configuration-metadata.json index eb4e76ae226..27512716f4b 100644 --- a/module/spring-boot-graphql/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/module/spring-boot-graphql/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -30,6 +30,14 @@ "since": "3.0.0" } }, + { + "name": "spring.graphql.http.methods", + "defaultValue": "POST" + }, + { + "name": "spring.graphql.http.sse.methods", + "defaultValue": "POST" + }, { "name": "spring.graphql.path", "type": "java.lang.String", diff --git a/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/reactive/GraphQlWebFluxAutoConfigurationTests.java b/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/reactive/GraphQlWebFluxAutoConfigurationTests.java index d63dc88bab7..2af452cfdac 100644 --- a/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/reactive/GraphQlWebFluxAutoConfigurationTests.java +++ b/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/reactive/GraphQlWebFluxAutoConfigurationTests.java @@ -38,6 +38,7 @@ import org.springframework.boot.test.context.runner.ReactiveWebApplicationContex import org.springframework.boot.testsupport.classpath.resources.WithResource; import org.springframework.boot.webflux.autoconfigure.HttpHandlerAutoConfiguration; import org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration; +import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; @@ -48,6 +49,7 @@ import org.springframework.graphql.server.webflux.GraphQlHttpHandler; import org.springframework.graphql.server.webflux.GraphQlSseHandler; import org.springframework.graphql.server.webflux.GraphQlWebSocketHandler; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.EntityExchangeResult; @@ -297,6 +299,55 @@ class GraphQlWebFluxAutoConfigurationTests { }); } + @Test + void shouldConfigureHttpMethods() { + this.contextRunner.withPropertyValues("spring.graphql.http.methods=GET,POST").run((context) -> { + GraphQlHttpHandler handler = context.getBean(GraphQlHttpHandler.class); + assertThat(handler.getHttpMethods()).containsExactlyInAnyOrder(HttpMethod.GET, HttpMethod.POST); + }); + } + + @Test + void shouldConfigureSseMethods() { + this.contextRunner.withPropertyValues("spring.graphql.http.sse.methods=GET,POST").run((context) -> { + GraphQlSseHandler handler = context.getBean(GraphQlSseHandler.class); + assertThat(handler.getHttpMethods()).containsExactlyInAnyOrder(HttpMethod.GET, HttpMethod.POST); + }); + } + + @Test + void httpGetQueryShouldWorkWhenConfigured() { + this.contextRunner.withPropertyValues("spring.graphql.http.methods=GET,POST") + .run((context) -> testWithWebClient(context, (client) -> { + String query = "{ bookById(id: \"book-1\"){ id name pageCount author } }"; + client.get() + .uri("/graphql?query={query}", query) + .accept(MediaType.APPLICATION_GRAPHQL_RESPONSE) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("data.bookById.name") + .isEqualTo("GraphQL for beginners"); + })); + } + + @Test + void sseSubscriptionShouldWorkWithGetWhenConfigured() { + this.contextRunner.withPropertyValues("spring.graphql.http.sse.methods=GET,POST") + .run((context) -> testWithWebClient(context, (client) -> { + String query = "subscription TestSubscription { booksOnSale(minPages: 50){ id name pageCount author } }"; + client.get() + .uri("/graphql?query={query}", query) + .accept(MediaType.TEXT_EVENT_STREAM) + .exchange() + .expectStatus() + .isOk() + .expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM); + })); + } + @Test void routerFunctionShouldHaveOrderZero() { this.contextRunner.withUserConfiguration(CustomRouterFunctions.class).run((context) -> { @@ -316,17 +367,19 @@ class GraphQlWebFluxAutoConfigurationTests { } private void testWithWebClient(Consumer consumer) { - this.contextRunner.run((context) -> { - WebTestClient client = WebTestClient.bindToApplicationContext(context) - .configureClient() - .defaultHeaders((headers) -> { - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(Collections.singletonList(MediaType.APPLICATION_GRAPHQL_RESPONSE)); - }) - .baseUrl(BASE_URL) - .build(); - consumer.accept(client); - }); + this.contextRunner.run((context) -> testWithWebClient(context, consumer)); + } + + private void testWithWebClient(ApplicationContext context, Consumer consumer) { + WebTestClient client = WebTestClient.bindToApplicationContext(context) + .configureClient() + .defaultHeaders((headers) -> { + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_GRAPHQL_RESPONSE)); + }) + .baseUrl(BASE_URL) + .build(); + consumer.accept(client); } @Configuration(proxyBeanMethods = false) diff --git a/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/servlet/GraphQlWebMvcAutoConfigurationTests.java b/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/servlet/GraphQlWebMvcAutoConfigurationTests.java index ed5f7207512..780fbdf1329 100644 --- a/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/servlet/GraphQlWebMvcAutoConfigurationTests.java +++ b/module/spring-boot-graphql/src/test/java/org/springframework/boot/graphql/autoconfigure/servlet/GraphQlWebMvcAutoConfigurationTests.java @@ -48,6 +48,7 @@ import org.springframework.graphql.server.webmvc.GraphQlHttpHandler; import org.springframework.graphql.server.webmvc.GraphQlSseHandler; import org.springframework.graphql.server.webmvc.GraphQlWebSocketHandler; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.assertj.MockMvcTester; @@ -123,6 +124,46 @@ class GraphQlWebMvcAutoConfigurationTests { }); } + @Test + void shouldConfigureHttpMethods() { + this.contextRunner.withPropertyValues("spring.graphql.http.methods=GET,POST").run((context) -> { + GraphQlHttpHandler handler = context.getBean(GraphQlHttpHandler.class); + assertThat(handler.getHttpMethods()).containsExactlyInAnyOrder(HttpMethod.GET, HttpMethod.POST); + }); + } + + @Test + void shouldConfigureSseMethods() { + this.contextRunner.withPropertyValues("spring.graphql.http.sse.methods=GET,POST").run((context) -> { + GraphQlSseHandler handler = context.getBean(GraphQlSseHandler.class); + assertThat(handler.getHttpMethods()).containsExactlyInAnyOrder(HttpMethod.GET, HttpMethod.POST); + }); + } + + @Test + void httpGetQueryShouldWorkWhenConfigured() { + this.contextRunner.withPropertyValues("spring.graphql.http.methods=GET,POST").run((context) -> { + MockMvcTester mvc = MockMvcTester.from(context); + String query = "{ bookById(id: \"book-1\"){ id name pageCount author } }"; + assertThat(mvc.get().uri("/graphql?query={query}", query).accept(MediaType.APPLICATION_GRAPHQL_RESPONSE)) + .hasStatusOk() + .bodyJson() + .extractingPath("data.bookById.name") + .asString() + .isEqualTo("GraphQL for beginners"); + }); + } + + @Test + void sseSubscriptionShouldWorkWithGetWhenConfigured() { + this.contextRunner.withPropertyValues("spring.graphql.http.sse.methods=GET,POST").run((context) -> { + MockMvcTester mvc = MockMvcTester.from(context); + String query = "subscription TestSubscription { booksOnSale(minPages: 50){ id name pageCount author } }"; + assertThat(mvc.get().uri("/graphql?query={query}", query).accept(MediaType.TEXT_EVENT_STREAM)).hasStatusOk() + .hasContentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM); + }); + } + @Test void simpleQueryShouldWork() { withMockMvc((mvc) -> {