diff --git a/README.md b/README.md index 7a007c8..a97c307 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Spring GraphQL -Experimental project to create [GraphQL](https://graphql.org/) support for Spring applications. +Experimental project for [GraphQL](https://graphql.org/) support in Spring applications with [GraphQL Java](https://github.com/graphql-java/graphql-java). [![Build status](https://ci.spring.io/api/v1/teams/spring-graphql/pipelines/spring-graphql/jobs/build/badge)](https://ci.spring.io/teams/spring-graphql/pipelines/spring-graphql) @@ -27,7 +27,7 @@ dependencies { repositories { mavenCentral() - // don't forget to add spring milestone and snapshot repositories + // don't forget to add spring milestone or snapshot repositories maven { url 'https://repo.spring.io/milestone' } maven { url 'https://repo.spring.io/snapshot' } } @@ -55,6 +55,7 @@ repositories { + spring-milestones @@ -85,7 +86,7 @@ type Person { } ``` -And then you should configure the data fetching using a `RuntimeWiringCustomizer` and custom components like +Then you should configure the data fetching process using a `RuntimeWiringCustomizer` and custom components like Spring Data repositories, `WebClient` instances for Web APIs, a `@Service` bean, etc. ```java @@ -127,6 +128,12 @@ management.metrics.graphql.autotime.enabled=true You can contribute `RuntimeWiringCustomizer` beans to the context in order to configure the runtime wiring of your GraphQL application. +### Extension points + +You can contribute [`WebInterceptor` beans](https://github.com/spring-projects-experimental/spring-graphql/blob/master/spring-graphql-web/src/main/java/org/springframework/graphql/WebInterceptor.java) +to the application context, so as to customize the `ExecutionInput` or the `ExecutionResult` of the query. +A custom `WebInterceptor` can, for example, change the HTTP request/response headers. + ### Metrics If the `spring-boot-starter-actuator` dependency is on the classpath, metrics will be collected for GraphQL queries. diff --git a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/WebFluxGraphQLAutoConfiguration.java b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/WebFluxGraphQLAutoConfiguration.java index 2e37c05..06bfade 100644 --- a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/WebFluxGraphQLAutoConfiguration.java +++ b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/WebFluxGraphQLAutoConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2020 the original author or authors. + * Copyright 2020-2021 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. @@ -16,9 +16,11 @@ package org.springframework.graphql.boot; import java.util.Collections; +import java.util.stream.Collectors; import graphql.GraphQL; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -28,6 +30,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; +import org.springframework.graphql.WebInterceptor; import org.springframework.graphql.webflux.GraphQLHttpHandler; import org.springframework.graphql.webflux.GraphQLWebSocketHandler; import org.springframework.http.MediaType; @@ -50,17 +53,18 @@ public class WebFluxGraphQLAutoConfiguration { @Bean @ConditionalOnMissingBean - public GraphQLHttpHandler graphQLHandler(GraphQL.Builder graphQLBuilder) { - return new GraphQLHttpHandler(graphQLBuilder.build(), Collections.emptyList()); + public GraphQLHttpHandler graphQLHandler(GraphQL.Builder graphQLBuilder, ObjectProvider interceptors) { + return new GraphQLHttpHandler(graphQLBuilder.build(), interceptors.orderedStream().collect(Collectors.toList())); } @Bean @ConditionalOnMissingBean public GraphQLWebSocketHandler graphQLWebSocketHandler( - GraphQL.Builder graphQLBuilder, GraphQLProperties properties, ServerCodecConfigurer configurer) { + GraphQL.Builder graphQLBuilder, GraphQLProperties properties, ServerCodecConfigurer configurer, + ObjectProvider interceptors) { return new GraphQLWebSocketHandler( - graphQLBuilder.build(), Collections.emptyList(), + graphQLBuilder.build(), interceptors.orderedStream().collect(Collectors.toList()), configurer, properties.getConnectionInitTimeoutDuration() ); } diff --git a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/WebMvcGraphQLAutoConfiguration.java b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/WebMvcGraphQLAutoConfiguration.java index e0b8655..fe10528 100644 --- a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/WebMvcGraphQLAutoConfiguration.java +++ b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/WebMvcGraphQLAutoConfiguration.java @@ -17,11 +17,13 @@ import java.util.Collections; import java.util.Map; +import java.util.stream.Collectors; import javax.websocket.server.ServerContainer; import graphql.GraphQL; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -32,6 +34,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; +import org.springframework.graphql.WebInterceptor; import org.springframework.graphql.webmvc.GraphQLHttpHandler; import org.springframework.graphql.webmvc.GraphQLWebSocketHandler; import org.springframework.http.MediaType; @@ -57,8 +60,9 @@ public class WebMvcGraphQLAutoConfiguration { @Bean @ConditionalOnMissingBean - public GraphQLHttpHandler graphQLHandler(GraphQL.Builder graphQLBuilder) { - return new GraphQLHttpHandler(graphQLBuilder.build(), Collections.emptyList()); + public GraphQLHttpHandler graphQLHandler(GraphQL.Builder graphQLBuilder, + ObjectProvider interceptors) { + return new GraphQLHttpHandler(graphQLBuilder.build(), interceptors.orderedStream().collect(Collectors.toList())); } @Bean @@ -81,7 +85,8 @@ static class WebSocketConfiguration { @Bean @ConditionalOnMissingBean public GraphQLWebSocketHandler graphQLWebSocketHandler( - GraphQL.Builder graphQLBuilder, GraphQLProperties properties, HttpMessageConverters converters) { + GraphQL.Builder graphQLBuilder, GraphQLProperties properties, HttpMessageConverters converters, + ObjectProvider interceptors) { HttpMessageConverter converter = converters.getConverters().stream() .filter(candidate -> candidate.canRead(Map.class, MediaType.APPLICATION_JSON)) @@ -89,7 +94,7 @@ public GraphQLWebSocketHandler graphQLWebSocketHandler( .orElseThrow(() -> new IllegalStateException("No JSON converter")); return new GraphQLWebSocketHandler( - graphQLBuilder.build(), Collections.emptyList(), + graphQLBuilder.build(), interceptors.orderedStream().collect(Collectors.toList()), converter, properties.getConnectionInitTimeoutDuration() ); } diff --git a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/WebFluxApplicationContextTests.java b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/WebFluxApplicationContextTests.java index d5f243b..65171a5 100644 --- a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/WebFluxApplicationContextTests.java +++ b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/WebFluxApplicationContextTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2020 the original author or authors. + * Copyright 2002-2021 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. @@ -19,6 +19,7 @@ import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.http.codec.CodecsAutoConfiguration; @@ -30,6 +31,8 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.WebInterceptor; +import org.springframework.graphql.WebOutput; import org.springframework.http.MediaType; import org.springframework.test.web.reactive.server.WebTestClient; @@ -75,6 +78,26 @@ void queryIsInvalidJson() { testWithWebClient(client -> client.post().uri("").bodyValue(":)").exchange().expectStatus().isBadRequest()); } + @Test + void interceptedQuery() { + testWithWebClient(client -> { + String query = "{" + + " bookById(id: \\\"book-1\\\"){ " + + " id" + + " name" + + " pageCount" + + " author" + + " }" + + "}"; + + client.post().uri("") + .bodyValue("{ \"query\": \"" + query + "\"}") + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("X-Custom-Header", "42"); + }); + } + private void testWithWebClient(Consumer consumer) { testWithApplicationContext(context -> { WebTestClient client = WebTestClient.bindToApplicationContext(context) @@ -92,7 +115,7 @@ private void testWithWebClient(Consumer consumer) { private void testWithApplicationContext(ContextConsumer consumer) { new ReactiveWebApplicationContextRunner() .withConfiguration(AUTO_CONFIGURATIONS) - .withUserConfiguration(DataFetchersConfiguration.class) + .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) .withPropertyValues( "spring.main.web-application-type=reactive", "spring.graphql.schema-location:classpath:books/schema.graphqls") @@ -111,4 +134,18 @@ public RuntimeWiringCustomizer bookDataFetcher() { } } + @Configuration(proxyBeanMethods = false) + static class CustomWebInterceptor { + + @Bean + public WebInterceptor customWebInterceptor() { + return new WebInterceptor() { + @Override + public Mono postHandle(WebOutput webOutput) { + return Mono.just(webOutput.transform(output -> output.header("X-Custom-Header", "42"))); + } + }; + } + } + } diff --git a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/WebMvcApplicationContextTests.java b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/WebMvcApplicationContextTests.java index 2e80687..5845eef 100644 --- a/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/WebMvcApplicationContextTests.java +++ b/graphql-spring-boot-starter/src/test/java/org/springframework/graphql/boot/WebMvcApplicationContextTests.java @@ -1,7 +1,24 @@ +/* + * Copyright 2020-2021 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.boot; import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; @@ -11,6 +28,8 @@ import org.springframework.boot.test.context.runner.WebApplicationContextRunner; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.graphql.WebInterceptor; +import org.springframework.graphql.WebOutput; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -60,11 +79,28 @@ void invalidJson() { testWith(mockMvc -> mockMvc.perform(post("/graphql").content(":)")).andExpect(status().isBadRequest())); } + @Test + void interceptedQuery() { + testWith(mockMvc -> { + String query = "{" + + " bookById(id: \\\"book-1\\\"){ " + + " id" + + " name" + + " pageCount" + + " author" + + " }" + + "}"; + MvcResult asyncResult = mockMvc.perform(post("/graphql").content("{\"query\": \"" + query + "\"}")).andReturn(); + mockMvc.perform(asyncDispatch(asyncResult)) + .andExpect(status().isOk()) + .andExpect(header().string("X-Custom-Header", "42")); + }); + } private void testWith(MockMvcConsumer mockMvcConsumer) { new WebApplicationContextRunner() .withConfiguration(AUTO_CONFIGURATIONS) - .withUserConfiguration(DataFetchersConfiguration.class) + .withUserConfiguration(DataFetchersConfiguration.class, CustomWebInterceptor.class) .withPropertyValues( "spring.main.web-application-type=servlet", "spring.graphql.schema-location:classpath:books/schema.graphqls") @@ -94,4 +130,18 @@ public RuntimeWiringCustomizer bookDataFetcher() { } } + @Configuration(proxyBeanMethods = false) + static class CustomWebInterceptor { + + @Bean + public WebInterceptor customWebInterceptor() { + return new WebInterceptor() { + @Override + public Mono postHandle(WebOutput webOutput) { + return Mono.just(webOutput.transform(output -> output.header("X-Custom-Header", "42"))); + } + }; + } + } + }