diff --git a/spring-graphql/src/main/java/org/springframework/graphql/web/DefaultWebGraphQlHandlerBuilder.java b/spring-graphql/src/main/java/org/springframework/graphql/web/DefaultWebGraphQlHandlerBuilder.java index 7f17209a..8fb6c8a6 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/web/DefaultWebGraphQlHandlerBuilder.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/web/DefaultWebGraphQlHandlerBuilder.java @@ -84,20 +84,18 @@ class DefaultWebGraphQlHandlerBuilder implements WebGraphQlHandler.Builder { List interceptorsToUse = (this.interceptors != null ? this.interceptors : Collections.emptyList()); - WebGraphQlHandler handler = interceptorsToUse.stream() - .reduce(WebInterceptor::andThen) - .map(interceptor -> (WebGraphQlHandler) input -> interceptor.intercept(input, createHandler())) - .orElse(createHandler()); - - return (CollectionUtils.isEmpty(this.accessors) ? handler : - new ThreadLocalExtractingHandler(handler, ThreadLocalAccessor.composite(this.accessors))); - } - - private WebGraphQlHandler createHandler() { - return webInput -> { - ExecutionInput input = webInput.toExecutionInput(); - return this.service.execute(input).map(result -> new WebOutput(webInput, result)); + WebGraphQlHandler targetHandler = webInput -> { + ExecutionInput executionInput = webInput.toExecutionInput(); + return this.service.execute(executionInput).map(result -> new WebOutput(webInput, result)); }; + + WebGraphQlHandler interceptionChain = interceptorsToUse.stream() + .reduce(WebInterceptor::andThen) + .map(interceptor -> (WebGraphQlHandler) input -> interceptor.intercept(input, targetHandler)) + .orElse(targetHandler); + + return (CollectionUtils.isEmpty(this.accessors) ? interceptionChain : + new ThreadLocalExtractingHandler(interceptionChain, ThreadLocalAccessor.composite(this.accessors))); } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/TestGraphQlSource.java b/spring-graphql/src/test/java/org/springframework/graphql/TestGraphQlSource.java new file mode 100644 index 00000000..30c5ee8b --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/TestGraphQlSource.java @@ -0,0 +1,45 @@ +/* + * 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; + +import graphql.GraphQL; +import graphql.schema.GraphQLSchema; + +import org.springframework.graphql.execution.GraphQlSource; + +/** + * {@link GraphQlSource} that wraps a pre-built {@link GraphQL} instance. + */ +public class TestGraphQlSource implements GraphQlSource { + + private final GraphQL graphQl; + + + public TestGraphQlSource(GraphQL graphQl) { + this.graphQl = graphQl; + } + + + @Override + public GraphQL graphQl() { + return this.graphQl; + } + + @Override + public GraphQLSchema schema() { + throw new UnsupportedOperationException(); + } +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/TestThreadLocalAccessor.java b/spring-graphql/src/test/java/org/springframework/graphql/TestThreadLocalAccessor.java similarity index 55% rename from spring-graphql/src/test/java/org/springframework/graphql/execution/TestThreadLocalAccessor.java rename to spring-graphql/src/test/java/org/springframework/graphql/TestThreadLocalAccessor.java index 4937003d..14b4a70a 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/execution/TestThreadLocalAccessor.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/TestThreadLocalAccessor.java @@ -13,35 +13,45 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.graphql.execution; +package org.springframework.graphql; import java.util.Map; +import org.springframework.graphql.execution.ThreadLocalAccessor; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + /** * {@link ThreadLocalAccessor} that operates on the ThreadLocal it is given. */ -class TestThreadLocalAccessor implements ThreadLocalAccessor { +public class TestThreadLocalAccessor implements ThreadLocalAccessor { - private final ThreadLocal threadLocal; + private final ThreadLocal threadLocal; + + @Nullable + private Long threadId; - TestThreadLocalAccessor(ThreadLocal threadLocal) { + public TestThreadLocalAccessor(ThreadLocal threadLocal) { this.threadLocal = threadLocal; } @Override public void extractValues(Map container) { - String name = this.threadLocal.get(); + saveThreadId(); + T name = this.threadLocal.get(); Assert.notNull(name, "No ThreadLocal value"); container.put("name", name); } @Override + @SuppressWarnings("unchecked") public void restoreValues(Map values) { - String name = (String) values.get("name"); + checkThreadId(); + T name = (T) values.get("name"); Assert.notNull(name, "No value to set"); this.threadLocal.set(name); } @@ -50,4 +60,18 @@ class TestThreadLocalAccessor implements ThreadLocalAccessor { public void resetValues(Map values) { this.threadLocal.remove(); } + + private void saveThreadId() { + this.threadId = Thread.currentThread().getId(); + } + + private void checkThreadId() { + assertThat(this.threadId) + .as("No threadId to check. Was extractValues not called?") + .isNotNull(); + assertThat(Thread.currentThread().getId() != this.threadId) + .as("ThreadLocal value extracted and restored on the same thread. Propagation not tested effectively.") + .isTrue(); + } + } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/ContextDataFetcherDecoratorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/ContextDataFetcherDecoratorTests.java index 8b52ad17..ef53abf2 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/execution/ContextDataFetcherDecoratorTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/ContextDataFetcherDecoratorTests.java @@ -30,6 +30,7 @@ import reactor.util.context.Context; import reactor.util.context.ContextView; import org.springframework.graphql.GraphQlTestUtils; +import org.springframework.graphql.TestThreadLocalAccessor; import static org.assertj.core.api.Assertions.assertThat; @@ -101,18 +102,15 @@ public class ContextDataFetcherDecoratorTests { @Test void dataFetcherWithThreadLocalContext() { - long threadId = Thread.currentThread().getId(); ThreadLocal nameThreadLocal = new ThreadLocal<>(); nameThreadLocal.set("007"); + TestThreadLocalAccessor accessor = new TestThreadLocalAccessor<>(nameThreadLocal); try { GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", - "Query", "greeting", env -> { - assertThat(Thread.currentThread().getId() != threadId).as("Not on async thread").isTrue(); - return "Hello " + nameThreadLocal.get(); - }); + "Query", "greeting", env -> "Hello " + nameThreadLocal.get()); ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build(); - ContextView view = ContextManager.extractThreadLocalValues(new TestThreadLocalAccessor(nameThreadLocal)); + ContextView view = ContextManager.extractThreadLocalValues(accessor); ContextManager.setReactorContext(view, input); ExecutionResult result = Mono.delay(Duration.ofMillis(10)) diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandlerTests.java index 2e0dff09..61ed6691 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandlerTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/ExceptionResolversExceptionHandlerTests.java @@ -31,6 +31,7 @@ import reactor.util.context.Context; import reactor.util.context.ContextView; import org.springframework.graphql.GraphQlTestUtils; +import org.springframework.graphql.TestThreadLocalAccessor; import static org.assertj.core.api.Assertions.assertThat; @@ -59,9 +60,8 @@ public class ExceptionResolversExceptionHandlerTests { List errors = result.getErrors(); assertThat(errors).hasSize(1); - GraphQLError error = errors.get(0); - assertThat(error.getMessage()).isEqualTo("Resolved error: Invalid greeting"); - assertThat(error.getErrorType().toString()).isEqualTo("BAD_REQUEST"); + assertThat(errors.get(0).getMessage()).isEqualTo("Resolved error: Invalid greeting"); + assertThat(errors.get(0).getErrorType().toString()).isEqualTo("BAD_REQUEST"); } @Test @@ -81,39 +81,36 @@ public class ExceptionResolversExceptionHandlerTests { ExecutionResult result = graphQl.executeAsync(input).get(); - GraphQLError error = result.getErrors().get(0); - assertThat(error.getMessage()).isEqualTo("Resolved error: Invalid greeting, name=007"); + List errors = result.getErrors(); + assertThat(errors.get(0).getMessage()).isEqualTo("Resolved error: Invalid greeting, name=007"); } @Test void resolveExceptionWithThreadLocal() { - long threadId = Thread.currentThread().getId(); ThreadLocal nameThreadLocal = new ThreadLocal<>(); nameThreadLocal.set("007"); + TestThreadLocalAccessor accessor = new TestThreadLocalAccessor<>(nameThreadLocal); try { GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", "Query", "greeting", env -> { throw new IllegalArgumentException("Invalid greeting"); }, - (SyncDataFetcherExceptionResolver) (ex, env) -> { - assertThat(Thread.currentThread().getId() != threadId).as("Not on async thread").isTrue(); - return Collections.singletonList( - GraphqlErrorBuilder.newError(env) - .message("Resolved error: " + ex.getMessage() + ", name=" + nameThreadLocal.get()) - .errorType(ErrorType.BAD_REQUEST) - .build()); - }); + (SyncDataFetcherExceptionResolver) (ex, env) -> Collections.singletonList( + GraphqlErrorBuilder.newError(env) + .message("Resolved error: " + ex.getMessage() + ", name=" + nameThreadLocal.get()) + .errorType(ErrorType.BAD_REQUEST) + .build())); ExecutionInput input = ExecutionInput.newExecutionInput().query("{ greeting }").build(); - ContextView view = ContextManager.extractThreadLocalValues(new TestThreadLocalAccessor(nameThreadLocal)); + ContextView view = ContextManager.extractThreadLocalValues(accessor); ContextManager.setReactorContext(view, input); ExecutionResult result = Mono.delay(Duration.ofMillis(10)) .flatMap(aLong -> Mono.fromFuture(graphQl.executeAsync(input))) .block(); - GraphQLError error = result.getErrors().get(0); - assertThat(error.getMessage()).isEqualTo("Resolved error: Invalid greeting, name=007"); + List errors = result.getErrors(); + assertThat(errors.get(0).getMessage()).isEqualTo("Resolved error: Invalid greeting, name=007"); } finally { nameThreadLocal.remove(); diff --git a/spring-graphql/src/test/java/org/springframework/graphql/web/WebGraphQlHandlerTests.java b/spring-graphql/src/test/java/org/springframework/graphql/web/WebGraphQlHandlerTests.java new file mode 100644 index 00000000..399de551 --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/web/WebGraphQlHandlerTests.java @@ -0,0 +1,155 @@ +/* + * 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.web; + +import java.net.URI; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import graphql.GraphQL; +import graphql.GraphQLError; +import graphql.GraphqlErrorBuilder; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +import org.springframework.graphql.GraphQlService; +import org.springframework.graphql.GraphQlTestUtils; +import org.springframework.graphql.TestGraphQlSource; +import org.springframework.graphql.TestThreadLocalAccessor; +import org.springframework.graphql.execution.ErrorType; +import org.springframework.graphql.execution.ExecutionGraphQlService; +import org.springframework.graphql.execution.SyncDataFetcherExceptionResolver; +import org.springframework.http.HttpHeaders; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link WebGraphQlHandler}, common to both HTTP and WebSocket. + */ +public class WebGraphQlHandlerTests { + + private static final WebInput webInput = new WebInput( + URI.create("http://abc.org"), new HttpHeaders(), Collections.singletonMap("query", "{ greeting }"), "1"); + + + @Test + void reactorContextPropagation() { + GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", + "Query", "greeting", env -> + Mono.deferContextual(context -> { + Object name = context.get("name"); + return Mono.delay(Duration.ofMillis(50)).map(aLong -> "Hello " + name); + })); + + GraphQlService service = new ExecutionGraphQlService(new TestGraphQlSource(graphQl)); + WebGraphQlHandler handler = WebGraphQlHandler.builder(service).build(); + + WebOutput webOutput = handler.handle(webInput) + .contextWrite(context -> context.put("name", "007")) + .block(); + + Map data = webOutput.getData(); + assertThat(data).hasSize(1).containsEntry("greeting", "Hello 007"); + } + + @Test + void reactorContextPropagationToExceptionResolver() { + GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", + "Query", "greeting", env -> { + throw new IllegalArgumentException("Invalid greeting"); + }, + (ex, env) -> Mono.deferContextual(view -> Mono.just(Collections.singletonList( + GraphqlErrorBuilder.newError(env) + .message("Resolved error: " + ex.getMessage() + ", name=" + view.get("name")) + .errorType(ErrorType.BAD_REQUEST) + .build())))); + + GraphQlService service = new ExecutionGraphQlService(new TestGraphQlSource(graphQl)); + WebGraphQlHandler handler = WebGraphQlHandler.builder(service).build(); + + WebOutput webOutput = handler.handle(webInput) + .contextWrite(context -> context.put("name", "007")) + .block(); + + Map data = webOutput.getData(); + assertThat(data).hasSize(1).containsEntry("greeting", null); + + List errors = webOutput.getErrors(); + assertThat(errors).hasSize(1); + assertThat(errors.get(0).getMessage()).isEqualTo("Resolved error: Invalid greeting, name=007"); + } + + @Test + void threadLocalContextPropagation() { + ThreadLocal nameThreadLocal = new ThreadLocal<>(); + nameThreadLocal.set("007"); + TestThreadLocalAccessor threadLocalAccessor = new TestThreadLocalAccessor<>(nameThreadLocal); + try { + GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", + "Query", "greeting", env -> "Hello " + nameThreadLocal.get()); + + GraphQlService service = new ExecutionGraphQlService(new TestGraphQlSource(graphQl)); + + WebGraphQlHandler handler = WebGraphQlHandler.builder(service) + .interceptor((input, next) -> Mono.delay(Duration.ofMillis(10)).flatMap(aLong -> next.handle(input))) + .threadLocalAccessor(threadLocalAccessor) + .build(); + + Map data = handler.handle(webInput).block().getData(); + + assertThat(data).hasSize(1).containsEntry("greeting", "Hello 007"); + } + finally { + nameThreadLocal.remove(); + } + } + + @Test + void threadLocalContextPropagationToExceptionResolver() { + ThreadLocal nameThreadLocal = new ThreadLocal<>(); + nameThreadLocal.set("007"); + TestThreadLocalAccessor threadLocalAccessor = new TestThreadLocalAccessor<>(nameThreadLocal); + try { + GraphQL graphQl = GraphQlTestUtils.initGraphQl("type Query { greeting: String }", + "Query", "greeting", env -> { + throw new IllegalArgumentException("Invalid greeting"); + }, + (SyncDataFetcherExceptionResolver) (ex, env) -> Collections.singletonList( + GraphqlErrorBuilder.newError(env) + .message("Resolved error: " + ex.getMessage() + ", name=" + nameThreadLocal.get()) + .errorType(ErrorType.BAD_REQUEST) + .build())); + + GraphQlService service = new ExecutionGraphQlService(new TestGraphQlSource(graphQl)); + + WebGraphQlHandler handler = WebGraphQlHandler.builder(service) + .interceptor((input, next) -> Mono.delay(Duration.ofMillis(10)).flatMap(aLong -> next.handle(input))) + .threadLocalAccessor(threadLocalAccessor) + .build(); + + WebOutput webOutput = handler.handle(webInput).block(); + + List errors = webOutput.getErrors(); + assertThat(errors.get(0).getMessage()).isEqualTo("Resolved error: Invalid greeting, name=007"); + } + finally { + nameThreadLocal.remove(); + } + } + +}