diff --git a/graphql-spring-boot-starter/build.gradle b/graphql-spring-boot-starter/build.gradle index 3bff3a5a..ec5c384a 100644 --- a/graphql-spring-boot-starter/build.gradle +++ b/graphql-spring-boot-starter/build.gradle @@ -1,7 +1,7 @@ import org.springframework.boot.gradle.plugin.SpringBootPlugin plugins { - id 'org.springframework.boot' version '2.4.4' apply false + id 'org.springframework.boot' version '2.4.5' apply false id 'io.spring.dependency-management' version '1.0.10.RELEASE' id 'java-library' id 'maven' @@ -24,6 +24,7 @@ dependencyManagement { } dependencies { + api project(':spring-graphql-core') api project(':spring-graphql-web') api 'com.graphql-java:graphql-java:16.2' api 'io.projectreactor:reactor-core' diff --git a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQLAutoConfiguration.java b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQLAutoConfiguration.java index 685599cc..d48c5a79 100644 --- a/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQLAutoConfiguration.java +++ b/graphql-spring-boot-starter/src/main/java/org/springframework/graphql/boot/GraphQLAutoConfiguration.java @@ -23,6 +23,7 @@ import graphql.GraphQL; import graphql.execution.instrumentation.ChainedInstrumentation; import graphql.execution.instrumentation.Instrumentation; import graphql.schema.GraphQLSchema; +import graphql.schema.SchemaTransformer; import graphql.schema.idl.RuntimeWiring; import graphql.schema.idl.SchemaGenerator; import graphql.schema.idl.SchemaParser; @@ -36,6 +37,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; +import org.springframework.graphql.core.ReactorDataFetcherAdapter; @Configuration @ConditionalOnClass(GraphQL.class) @@ -71,8 +73,11 @@ public class GraphQLAutoConfiguration { public GraphQL.Builder graphQLBuilder(GraphQLProperties properties, RuntimeWiring runtimeWiring, ResourceLoader resourceLoader, ObjectProvider instrumentationsProvider) { + Resource schemaResource = resourceLoader.getResource(properties.getSchemaLocation()); GraphQLSchema schema = buildSchema(schemaResource, runtimeWiring); + schema = SchemaTransformer.transformSchema(schema, ReactorDataFetcherAdapter.TYPE_VISITOR); + GraphQL.Builder builder = GraphQL.newGraphQL(schema); List instrumentations = instrumentationsProvider.orderedStream().collect(Collectors.toList()); if (!instrumentations.isEmpty()) { diff --git a/samples/webflux-websocket/build.gradle b/samples/webflux-websocket/build.gradle index da812d47..8589f367 100644 --- a/samples/webflux-websocket/build.gradle +++ b/samples/webflux-websocket/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.springframework.boot' version '2.4.4' + id 'org.springframework.boot' version '2.4.5' id 'io.spring.dependency-management' version '1.0.10.RELEASE' id 'java' } diff --git a/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/DataRepository.java b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/DataRepository.java new file mode 100644 index 00000000..e9922d9e --- /dev/null +++ b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/DataRepository.java @@ -0,0 +1,59 @@ +/* + * 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. + * 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 io.spring.sample.graphql; + +import java.time.Duration; + +import graphql.schema.DataFetchingEnvironment; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import org.springframework.stereotype.Repository; + +/** + * Repository with data fetcher methods. + */ +@Repository +public class DataRepository { + + public String getBasic(DataFetchingEnvironment environment) { + return "Hello world!"; + } + + public Mono getGreeting(DataFetchingEnvironment environment) { + return Mono.deferContextual(context -> { + Object name = context.get("name"); + return Mono.delay(Duration.ofMillis(50)).map(aLong -> "Hello " + name); + }); + } + + public Flux getGreetings(DataFetchingEnvironment environment) { + return Mono.delay(Duration.ofMillis(50)).flatMapMany(aLong -> + Flux.deferContextual(context -> { + String name = context.get("name"); + return Flux.just("Hi", "Bonjour", "Hola", "Ciao", "Zdravo").map(s -> s + " " + name); + })); + } + + public Flux getGreetingsStream(DataFetchingEnvironment environment) { + return Mono.delay(Duration.ofMillis(50)).flatMapMany(aLong -> + Flux.deferContextual(context -> { + String name = context.get("name"); + return Flux.just("Hi", "Bonjour", "Hola", "Ciao", "Zdravo").map(s -> s + " " + name); + })); + } + +} diff --git a/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/ReactorContextWebFilter.java b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/ReactorContextWebFilter.java new file mode 100644 index 00000000..37ef9bfd --- /dev/null +++ b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/ReactorContextWebFilter.java @@ -0,0 +1,35 @@ +/* + * 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. + * 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 io.spring.sample.graphql; + +import reactor.core.publisher.Mono; + +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; + +/** + * WebFilter that inserts a key-value pair into the Reactor context which is + * transferred to and accessible to Reactor-based data fetchers. + */ +public class ReactorContextWebFilter implements WebFilter { + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + return chain.filter(exchange).contextWrite(context -> context.put("name", "007")); + } + +} diff --git a/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleApplication.java b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleApplication.java index 19225fa6..68e7bbfe 100644 --- a/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleApplication.java +++ b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleApplication.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. @@ -18,6 +18,7 @@ package io.spring.sample.graphql; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; @SpringBootApplication public class SampleApplication { @@ -25,4 +26,10 @@ public class SampleApplication { public static void main(String[] args) { SpringApplication.run(SampleApplication.class, args); } + + @Bean + ReactorContextWebFilter reactorContextWebFilter() { + return new ReactorContextWebFilter(); + } + } diff --git a/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleWiring.java b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleWiring.java index cbdcf023..6c2d6e3c 100644 --- a/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleWiring.java +++ b/samples/webflux-websocket/src/main/java/io/spring/sample/graphql/SampleWiring.java @@ -1,23 +1,51 @@ +/* + * 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. + * 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 io.spring.sample.graphql; -import java.time.Duration; - import graphql.schema.idl.RuntimeWiring; -import reactor.core.publisher.Flux; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.graphql.boot.RuntimeWiringCustomizer; import org.springframework.stereotype.Component; @Component public class SampleWiring implements RuntimeWiringCustomizer { + private final DataRepository dataRepository; + + + public SampleWiring(@Autowired DataRepository dataRepository) { + this.dataRepository = dataRepository; + } + + @Override public void customize(RuntimeWiring.Builder builder) { - builder.type("Query", wiringBuilder -> wiringBuilder.dataFetcher("hello", - env -> "Hello world!")); - builder.type("Subscription", wiringBuilder -> wiringBuilder.dataFetcher("greetings", - env -> Flux.just("Hi", "Bonjour", "Hola", "Ciao", "Zdravo") - .delayElements(Duration.ofMillis(500)))); + + builder.type("Query", typeBuilder -> + typeBuilder.dataFetcher("greeting", this.dataRepository::getBasic)); + + builder.type("Query", typeBuilder -> + typeBuilder.dataFetcher("greetingMono", this.dataRepository::getGreeting)); + + builder.type("Query", typeBuilder -> + typeBuilder.dataFetcher("greetingsFlux", this.dataRepository::getGreetings)); + + builder.type("Subscription", typeBuilder -> + typeBuilder.dataFetcher("greetings", this.dataRepository::getGreetingsStream)); } } diff --git a/samples/webflux-websocket/src/main/resources/schema.graphqls b/samples/webflux-websocket/src/main/resources/schema.graphqls index b3ba26d9..3e296ff0 100644 --- a/samples/webflux-websocket/src/main/resources/schema.graphqls +++ b/samples/webflux-websocket/src/main/resources/schema.graphqls @@ -1,5 +1,7 @@ type Query { - hello: String + greeting: String + greetingMono : String + greetingsFlux : [String] } type Subscription { greetings: String diff --git a/samples/webflux-websocket/src/main/resources/static/index.html b/samples/webflux-websocket/src/main/resources/static/index.html index 4ed8ffb6..03db5492 100644 --- a/samples/webflux-websocket/src/main/resources/static/index.html +++ b/samples/webflux-websocket/src/main/resources/static/index.html @@ -17,7 +17,7 @@ let result; client.subscribe( { - query: '{ hello }', + query: '{ greeting }', }, { next: (data) => (result = data), diff --git a/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/QueryTests.java b/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/QueryTests.java new file mode 100644 index 00000000..ebb4bf92 --- /dev/null +++ b/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/QueryTests.java @@ -0,0 +1,61 @@ +/* + * 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. + * 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 io.spring.sample.graphql; + +import graphql.GraphQL; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.graphql.WebGraphQLService; +import org.springframework.graphql.test.query.GraphQLTester; + +/** + * GraphQL query tests directly via {@link GraphQL}. + */ +@SpringBootTest +public class QueryTests { + + private GraphQLTester graphQLTester; + + + @BeforeEach + public void setUp(@Autowired WebGraphQLService service) { + this.graphQLTester = GraphQLTester.create(webInput -> + service.execute(webInput).contextWrite(context -> context.put("name", "James"))); + } + + + @Test + void greetingMono() { + this.graphQLTester.query("{greetingMono}") + .execute() + .path("greetingMono") + .entity(String.class) + .isEqualTo("Hello James"); + } + + @Test + void greetingsFlux() { + this.graphQLTester.query("{greetingsFlux}") + .execute() + .path("greetingsFlux") + .entityList(String.class) + .containsExactly("Hi James", "Bonjour James", "Hola James", "Ciao James", "Zdravo James"); + } + +} diff --git a/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/SubscriptionGraphQLTests.java b/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/SubscriptionTests.java similarity index 84% rename from samples/webflux-websocket/src/test/java/io/spring/sample/graphql/SubscriptionGraphQLTests.java rename to samples/webflux-websocket/src/test/java/io/spring/sample/graphql/SubscriptionTests.java index 5f7f14ce..cd27310e 100644 --- a/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/SubscriptionGraphQLTests.java +++ b/samples/webflux-websocket/src/test/java/io/spring/sample/graphql/SubscriptionTests.java @@ -30,14 +30,15 @@ import org.springframework.graphql.test.query.GraphQLTester; * GraphQL subscription tests directly via {@link GraphQL}. */ @SpringBootTest -public class SubscriptionGraphQLTests { +public class SubscriptionTests { private GraphQLTester graphQLTester; @BeforeEach public void setUp(@Autowired WebGraphQLService service) { - this.graphQLTester = GraphQLTester.create(service); + this.graphQLTester = GraphQLTester.create(webInput -> + service.execute(webInput).contextWrite(context -> context.put("name", "James"))); } @@ -50,7 +51,11 @@ public class SubscriptionGraphQLTests { .toFlux("greetings", String.class); StepVerifier.create(result) - .expectNext("Hi", "Bonjour", "Hola", "Ciao", "Zdravo") + .expectNext("Hi James") + .expectNext("Bonjour James") + .expectNext("Hola James") + .expectNext("Ciao James") + .expectNext("Zdravo James") .verifyComplete(); } @@ -64,8 +69,8 @@ public class SubscriptionGraphQLTests { StepVerifier.create(result) .consumeNextWith(spec -> spec.path("greetings").valueExists()) - .consumeNextWith(spec -> spec.path("greetings").matchesJson("\"Bonjour\"")) - .consumeNextWith(spec -> spec.path("greetings").matchesJson("\"Hola\"")) + .consumeNextWith(spec -> spec.path("greetings").matchesJson("\"Bonjour James\"")) + .consumeNextWith(spec -> spec.path("greetings").matchesJson("\"Hola James\"")) .expectNextCount(2) .verifyComplete(); } diff --git a/samples/webmvc-http/build.gradle b/samples/webmvc-http/build.gradle index 437b136a..37bd2a90 100644 --- a/samples/webmvc-http/build.gradle +++ b/samples/webmvc-http/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.springframework.boot' version '2.4.4' + id 'org.springframework.boot' version '2.4.5' id 'io.spring.dependency-management' version '1.0.10.RELEASE' id 'java' } diff --git a/settings.gradle b/settings.gradle index a9ecddc9..ac6dd252 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,4 +15,9 @@ pluginManagement { } rootProject.name = 'spring-graphql' -include 'spring-graphql-web', 'spring-graphql-test', 'graphql-spring-boot-starter', 'samples:webmvc-http', 'samples:webflux-websocket' +include 'spring-graphql-core', + 'spring-graphql-web', + 'spring-graphql-test', + 'graphql-spring-boot-starter', + 'samples:webmvc-http', + 'samples:webflux-websocket' diff --git a/spring-graphql-core/build.gradle b/spring-graphql-core/build.gradle new file mode 100644 index 00000000..4649ad90 --- /dev/null +++ b/spring-graphql-core/build.gradle @@ -0,0 +1,48 @@ + +plugins { + id 'io.spring.dependency-management' version '1.0.10.RELEASE' + id 'java-library' + id 'maven' +} + +description = "Basic Support and Utilities for Spring GraphQL Applications" + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencyManagement { + imports { + mavenBom "io.projectreactor:reactor-bom:2020.0.6" + mavenBom "org.springframework:spring-framework-bom:5.3.6" + } + generatedPomCustomization { + enabled = false + } +} + +dependencies { + api 'com.graphql-java:graphql-java:16.2' + api 'io.projectreactor:reactor-core' + api 'org.springframework:spring-context' + + compileOnly "javax.annotation:javax.annotation-api:1.3.2" + + testImplementation 'org.junit.jupiter:junit-jupiter:5.7.1' + testImplementation 'org.assertj:assertj-core:3.19.0' + testImplementation 'org.mockito:mockito-core:3.8.0' + testImplementation 'io.projectreactor:reactor-test' + + testRuntime 'org.apache.logging.log4j:log4j-core:2.14.1' + testRuntime 'org.apache.logging.log4j:log4j-slf4j-impl:2.14.1' +} + +test { + useJUnitPlatform() + testLogging { + events "passed", "skipped", "failed" + } +} + +apply from: "${rootDir}/gradle/publishing.gradle" diff --git a/spring-graphql-core/src/main/java/org/springframework/graphql/core/ReactorDataFetcherAdapter.java b/spring-graphql-core/src/main/java/org/springframework/graphql/core/ReactorDataFetcherAdapter.java new file mode 100644 index 00000000..e5dce782 --- /dev/null +++ b/spring-graphql-core/src/main/java/org/springframework/graphql/core/ReactorDataFetcherAdapter.java @@ -0,0 +1,134 @@ +/* + * 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. + * 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.core; + +import java.lang.reflect.Method; + +import graphql.ExecutionInput; +import graphql.GraphQLContext; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.GraphQLCodeRegistry; +import graphql.schema.GraphQLFieldDefinition; +import graphql.schema.GraphQLFieldsContainer; +import graphql.schema.GraphQLSchemaElement; +import graphql.schema.GraphQLTypeVisitor; +import graphql.schema.GraphQLTypeVisitorStub; +import graphql.util.TraversalControl; +import graphql.util.TraverserContext; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.ContextView; + +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Adapter to wrap a registered {@link DataFetcher} and enable it to return + * {@link Flux} or {@link Mono}, also adding Reactor Context passed through + * the {@link ExecutionInput} via {@link #addReactorContext(ExecutionInput, ContextView)}. + * Use {@link #TYPE_VISITOR} to transform the + * {@link graphql.schema.GraphQLSchema} and apply the adapter. + */ +public class ReactorDataFetcherAdapter implements DataFetcher { + + private static final String REACTOR_CONTEXT_KEY = + ReactorDataFetcherAdapter.class.getName() + ".REACTOR_CONTEXT"; + + + private final DataFetcher delegate; + + private final boolean subscription; + + + private ReactorDataFetcherAdapter(DataFetcher delegate, boolean subscription) { + Assert.notNull(delegate, "'delegate' DataFetcher is required"); + this.delegate = delegate; + this.subscription = subscription; + } + + + @Override + public Object get(DataFetchingEnvironment environment) throws Exception { + Object value = this.delegate.get(environment); + + if (this.subscription) { + ContextView context = getReactorContext(environment); + return (context != null ? Flux.from((Publisher) value).contextWrite(context) : value); + } + + if (value instanceof Flux) { + value = ((Flux) value).collectList(); + } + + if (value instanceof Mono) { + Mono valueMono = (Mono) value; + ContextView reactorContext = getReactorContext(environment); + if (reactorContext != null) { + valueMono = valueMono.contextWrite(reactorContext); + } + value = valueMono.toFuture(); + } + + return value; + } + + private ContextView getReactorContext(DataFetchingEnvironment environment) { + GraphQLContext graphQLContext = environment.getContext(); + return graphQLContext.get(REACTOR_CONTEXT_KEY); + } + + /** + * Insert the given Reactor Context into the {@link ExecutionInput} context + * for later retrieval from the {@link DataFetchingEnvironment}. + */ + public static void addReactorContext(ExecutionInput executionInput, ContextView reactorContext) { + GraphQLContext graphQLContext = (GraphQLContext) executionInput.getContext(); + graphQLContext.put(REACTOR_CONTEXT_KEY, reactorContext); + } + + + /** + * {@link GraphQLTypeVisitor} that wraps non-GraphQL data fetchers and + * adapts them if they return {@link Flux} or {@link Mono}. + */ + public static GraphQLTypeVisitor TYPE_VISITOR = new GraphQLTypeVisitorStub() { + + @Override + public TraversalControl visitGraphQLFieldDefinition( + GraphQLFieldDefinition fieldDefinition, TraverserContext context) { + + GraphQLCodeRegistry.Builder codeRegistry = context.getVarFromParents(GraphQLCodeRegistry.Builder.class); + GraphQLFieldsContainer parent = (GraphQLFieldsContainer) context.getParentNode(); + DataFetcher dataFetcher = codeRegistry.getDataFetcher(parent, fieldDefinition); + + if (dataFetcher.getClass().getPackage().getName().startsWith("graphql.")) { + return TraversalControl.CONTINUE; + } + + Method method = ClassUtils.getMethod(dataFetcher.getClass(), "get", DataFetchingEnvironment.class); + method = ClassUtils.getMostSpecificMethod(method, dataFetcher.getClass()); + Class returnType = method.getReturnType(); + System.out.println(returnType.getName()); + + dataFetcher = new ReactorDataFetcherAdapter(dataFetcher, parent.getName().equals("Subscription")); + codeRegistry.dataFetcher(parent, fieldDefinition, dataFetcher); + return TraversalControl.CONTINUE; + } + }; + +} diff --git a/spring-graphql-core/src/test/java/org/springframework/graphql/core/ReactorDataFetcherAdapterTests.java b/spring-graphql-core/src/test/java/org/springframework/graphql/core/ReactorDataFetcherAdapterTests.java new file mode 100644 index 00000000..c0548efa --- /dev/null +++ b/spring-graphql-core/src/test/java/org/springframework/graphql/core/ReactorDataFetcherAdapterTests.java @@ -0,0 +1,130 @@ +/* + * 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. + * 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.core; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import graphql.ExecutionInput; +import graphql.ExecutionResult; +import graphql.GraphQL; +import graphql.schema.GraphQLSchema; +import graphql.schema.SchemaTransformer; +import graphql.schema.idl.RuntimeWiring; +import graphql.schema.idl.SchemaGenerator; +import graphql.schema.idl.SchemaParser; +import graphql.schema.idl.TypeDefinitionRegistry; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ReactorDataFetcherAdapter}. + */ +public class ReactorDataFetcherAdapterTests { + + @Test + void monoDataFetcher() throws Exception { + + GraphQL graphQL = initGraphQL("type Query { greeting: String }", builder -> { + builder.type("Query", typeBuilder -> typeBuilder.dataFetcher("greeting", + env -> Mono.deferContextual(context -> { + Object name = context.get("name"); + return Mono.delay(Duration.ofMillis(50)).map(aLong -> "Hello " + name); + }))); + }); + + ExecutionInput executionInput = initExecutionInput("{ greeting }", Context.of("name", "007")); + Map data = graphQL.executeAsync(executionInput).get().getData(); + + assertThat(data).hasSize(1).containsEntry("greeting", "Hello 007"); + } + + @Test + void fluxDataFetcher() throws Exception { + + GraphQL graphQL = initGraphQL("type Query { greetings: [String] }", builder -> { + builder.type("Query", typeBuilder -> typeBuilder.dataFetcher("greetings", + env -> Mono.delay(Duration.ofMillis(50)).flatMapMany(aLong -> + Flux.deferContextual(context -> { + String name = context.get("name"); + return Flux.just("Hi", "Bonjour", "Hola").map(s -> s + " " + name); + })))); + }); + + ExecutionInput executionInput = initExecutionInput("{ greetings }", Context.of("name", "007")); + + Map data = graphQL.executeAsync(executionInput).get().getData(); + assertThat((List) data.get("greetings")).containsExactly("Hi 007", "Bonjour 007", "Hola 007"); + } + + @Test + void fluxDataFetcherSubscription() throws Exception { + + GraphQL graphQL = initGraphQL( + "type Query { greeting: String } type Subscription { greetings: String }", builder -> + builder.type("Subscription", typeBuilder -> typeBuilder.dataFetcher("greetings", + env -> Mono.delay(Duration.ofMillis(50)).flatMapMany(aLong -> + Flux.deferContextual(context -> { + String name = context.get("name"); + return Flux.just("Hi", "Bonjour", "Hola").map(s -> s + " " + name); + })) + ))); + + ExecutionInput input = initExecutionInput("subscription { greetings }", Context.of("name", "007")); + Publisher publisher = graphQL.executeAsync(input).get().getData(); + + List actual = Flux.from(publisher) + .cast(ExecutionResult.class) + .map(result -> ((Map) result.getData()).get("greetings")) + .cast(String.class) + .collectList() + .block(); + + assertThat(actual).containsExactly("Hi 007", "Bonjour 007", "Hola 007"); + } + + + private GraphQL initGraphQL(String schemaValue, Consumer consumer) throws IOException { + Resource schemaResource = new ByteArrayResource(schemaValue.getBytes(StandardCharsets.UTF_8)); + TypeDefinitionRegistry typeRegistry = new SchemaParser().parse(schemaResource.getInputStream()); + + RuntimeWiring.Builder wiringBuilder = RuntimeWiring.newRuntimeWiring(); + consumer.accept(wiringBuilder); + + GraphQLSchema schema = new SchemaGenerator().makeExecutableSchema(typeRegistry, wiringBuilder.build()); + schema = SchemaTransformer.transformSchema(schema, ReactorDataFetcherAdapter.TYPE_VISITOR); + return GraphQL.newGraphQL(schema).build(); + } + + private ExecutionInput initExecutionInput(String query, Context reactorContext) { + ExecutionInput input = ExecutionInput.newExecutionInput().query(query).build(); + ReactorDataFetcherAdapter.addReactorContext(input, reactorContext); + return input; + } + +} diff --git a/spring-graphql-core/src/test/resources/log4j2-test.xml b/spring-graphql-core/src/test/resources/log4j2-test.xml new file mode 100644 index 00000000..7c447623 --- /dev/null +++ b/spring-graphql-core/src/test/resources/log4j2-test.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/spring-graphql-test/build.gradle b/spring-graphql-test/build.gradle index 1c128618..c8b32bb5 100644 --- a/spring-graphql-test/build.gradle +++ b/spring-graphql-test/build.gradle @@ -14,8 +14,8 @@ java { dependencyManagement { imports { - mavenBom "com.fasterxml.jackson:jackson-bom:2.12.3" - mavenBom "io.projectreactor:reactor-bom:2020.0.6" + mavenBom "com.fasterxml.jackson:jackson-bom:2.12.3" + mavenBom "io.projectreactor:reactor-bom:2020.0.6" mavenBom "org.springframework:spring-framework-bom:5.3.6" } generatedPomCustomization { @@ -31,7 +31,7 @@ dependencies { api 'org.springframework:spring-test' api 'com.jayway.jsonpath:json-path:2.5.0' - compileOnly "javax.annotation:javax.annotation-api:1.3.2" + compileOnly "javax.annotation:javax.annotation-api:1.3.2" compileOnly 'org.springframework:spring-webflux' compileOnly 'org.springframework:spring-webmvc' compileOnly 'org.springframework:spring-websocket' diff --git a/spring-graphql-web/build.gradle b/spring-graphql-web/build.gradle index e37fcb9d..752393ae 100644 --- a/spring-graphql-web/build.gradle +++ b/spring-graphql-web/build.gradle @@ -24,6 +24,7 @@ dependencyManagement { } dependencies { + api project(':spring-graphql-core') api 'com.graphql-java:graphql-java:16.2' api 'io.projectreactor:reactor-core' api 'org.springframework:spring-context' diff --git a/spring-graphql-web/src/main/java/org/springframework/graphql/AbstractWebGraphQLService.java b/spring-graphql-web/src/main/java/org/springframework/graphql/AbstractWebGraphQLService.java index f2a53b14..63154704 100644 --- a/spring-graphql-web/src/main/java/org/springframework/graphql/AbstractWebGraphQLService.java +++ b/spring-graphql-web/src/main/java/org/springframework/graphql/AbstractWebGraphQLService.java @@ -23,6 +23,8 @@ import graphql.ExecutionInput; import graphql.ExecutionResult; import reactor.core.publisher.Mono; +import org.springframework.graphql.core.ReactorDataFetcherAdapter; + /** * Base class for {@link WebGraphQLService} implementations, providing support * for customizations of the request through a {@link WebInterceptor} chain. @@ -59,7 +61,11 @@ public abstract class AbstractWebGraphQLService implements WebGraphQLService { } private Mono preHandle(WebInput input) { - Mono resultMono = Mono.just(input.toExecutionInput()); + Mono resultMono = Mono.deferContextual(contextView -> { + ExecutionInput executionInput = input.toExecutionInput(); + ReactorDataFetcherAdapter.addReactorContext(executionInput, contextView); + return Mono.just(executionInput); + }); for (WebInterceptor interceptor : this.interceptors) { resultMono = resultMono.flatMap(executionInput -> interceptor.preHandle(executionInput, input)); }