From 50aeaba63e39138ed24bcdc4c65e89e515244886 Mon Sep 17 00:00:00 2001 From: Rossen Stoyanchev Date: Wed, 8 Dec 2021 09:47:49 +0000 Subject: [PATCH] Add support for `@ContextValue` method parameters See gh-172 --- .../src/docs/asciidoc/index.adoc | 4 + .../data/method/annotation/ContextValue.java | 65 +++++++++ .../AnnotatedControllerConfigurer.java | 10 +- .../ContextValueMethodArgumentResolver.java | 99 +++++++++++++ .../ArgumentMethodArgumentResolverTests.java | 3 +- ...ntextValueMethodArgumentResolverTests.java | 138 ++++++++++++++++++ 6 files changed, 315 insertions(+), 4 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/ContextValue.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ContextValueMethodArgumentResolver.java create mode 100644 spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ContextValueMethodArgumentResolverTests.java diff --git a/spring-graphql-docs/src/docs/asciidoc/index.adoc b/spring-graphql-docs/src/docs/asciidoc/index.adoc index 42dddaa3..7652d77b 100644 --- a/spring-graphql-docs/src/docs/asciidoc/index.adoc +++ b/spring-graphql-docs/src/docs/asciidoc/index.adoc @@ -683,6 +683,10 @@ See <>. | For access to a `DataLoader` in the `DataLoaderRegistry`. See <>. +| `@ContextValue` +| For access to a value from the localContext, if it is an instance of `GraphQLContext`, + or from the `GraphQLContext` of `DataFetchingEnvironment`. + | `GraphQLContext` | For access to the context from the `DataFetchingEnvironment`. diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/ContextValue.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/ContextValue.java new file mode 100644 index 00000000..a4151c2c --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/ContextValue.java @@ -0,0 +1,65 @@ +/* + * 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.data.method.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import graphql.GraphQLContext; +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation for method parameters obtained from one of the following: + *
    + *
  • {@link DataFetchingEnvironment#getLocalContext()} -- if it is an + * instance of {@link GraphQLContext}. + *
  • {@link DataFetchingEnvironment#getGraphQlContext()} + *
+ * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ContextValue { + + /** + * Alias for {@link #name}. + */ + @AliasFor("name") + String value() default ""; + + /** + * The name of the value to bind to. + */ + @AliasFor("value") + String name() default ""; + + /** + * Whether the value is required. + *

Defaults to "true", leading to an exception thrown if the value is + * missing. Switch to "false" if you prefer {@code null} if the value is + * not present, or use {@link java.util.Optional}. + */ + boolean required() default true; + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java index e7cc8c41..78f9966c 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerConfigurer.java @@ -121,23 +121,27 @@ public class AnnotatedControllerConfigurer @Override public void afterPropertiesSet() { this.argumentResolvers = new HandlerMethodArgumentResolverComposite(); + + // Annotation based if (springDataPresent) { - // This must be ahead of ArgumentMethodArgumentResolver + // Must be ahead of ArgumentMethodArgumentResolver this.argumentResolvers.addResolver(new ProjectedPayloadMethodArgumentResolver()); } this.argumentResolvers.addResolver(new ArgumentMapMethodArgumentResolver()); this.argumentResolvers.addResolver(new ArgumentMethodArgumentResolver(this.conversionService)); + this.argumentResolvers.addResolver(new ContextValueMethodArgumentResolver()); + + // Type based this.argumentResolvers.addResolver(new DataFetchingEnvironmentMethodArgumentResolver()); this.argumentResolvers.addResolver(new DataLoaderMethodArgumentResolver()); if (springSecurityPresent) { this.argumentResolvers.addResolver(new PrincipalMethodArgumentResolver()); } - if (KotlinDetector.isKotlinPresent()) { this.argumentResolvers.addResolver(new ContinuationHandlerMethodArgumentResolver()); } - // This works as a fallback, after all other resolvers + // This works as a fallback, after other resolvers this.argumentResolvers.addResolver(new SourceMethodArgumentResolver()); } diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ContextValueMethodArgumentResolver.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ContextValueMethodArgumentResolver.java new file mode 100644 index 00000000..ec9768dd --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/ContextValueMethodArgumentResolver.java @@ -0,0 +1,99 @@ +/* + * 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.data.method.annotation.support; + +import java.util.Optional; + +import graphql.GraphQLContext; +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.MethodParameter; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; +import org.springframework.graphql.data.method.annotation.ContextValue; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Resolver for {@link ContextValue @ContextValue} annotated method parameters. + * Values are resolved through one of the following: + *

    + *
  • {@link DataFetchingEnvironment#getLocalContext()} -- if it is an + * instance of {@link GraphQLContext}. + *
  • {@link DataFetchingEnvironment#getGraphQlContext()} + *
+ * + * @author Rossen Stoyanchev + * @since 1.0.0 + */ +public class ContextValueMethodArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return (parameter.getParameterAnnotation(ContextValue.class) != null); + } + + @Override + public Object resolveArgument(MethodParameter parameter, DataFetchingEnvironment environment) { + return resolveContextValue(parameter, environment.getLocalContext(), environment.getGraphQlContext()); + } + + @Nullable + private Object resolveContextValue( + MethodParameter parameter, @Nullable Object localContext, GraphQLContext graphQlContext) { + + ContextValue annotation = parameter.getParameterAnnotation(ContextValue.class); + Assert.state(annotation != null, "Expected @ContextValue annotation"); + String name = getValueName(parameter, annotation); + + Class parameterType = parameter.getParameterType(); + Object value = null; + + if (localContext instanceof GraphQLContext) { + value = ((GraphQLContext) localContext).get(name); + } + + if (value != null) { + return wrapAsOptionalIfNecessary(value, parameterType); + } + + value = graphQlContext.get(name); + if (value == null && annotation.required() && !parameterType.equals(Optional.class)) { + throw new IllegalStateException("Missing required context value for " + parameter); + } + + return wrapAsOptionalIfNecessary(value, parameterType); + } + + private String getValueName(MethodParameter parameter, ContextValue annotation) { + if (StringUtils.hasText(annotation.name())) { + return annotation.name(); + } + String parameterName = parameter.getParameterName(); + if (parameterName != null) { + return parameterName; + } + throw new IllegalArgumentException("Name for @ContextValue argument " + + "of type [" + parameter.getNestedParameterType().getName() + "] not specified, " + + "and parameter name information not found in class file either."); + } + + @Nullable + private Object wrapAsOptionalIfNecessary(@Nullable Object value, Class type) { + return (type.equals(Optional.class) ? Optional.ofNullable(value) : value); + } + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java index 2d088990..89b033f6 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ArgumentMethodArgumentResolverTests.java @@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test; import org.springframework.core.DefaultParameterNameDiscoverer; import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.SynthesizingMethodParameter; import org.springframework.format.support.DefaultFormattingConversionService; import org.springframework.graphql.Book; import org.springframework.graphql.data.method.annotation.Argument; @@ -111,7 +112,7 @@ class ArgumentMethodArgumentResolverTests { } private MethodParameter methodParam(Method method, int index) { - MethodParameter methodParameter = new MethodParameter(method, index); + MethodParameter methodParameter = new SynthesizingMethodParameter(method, index); methodParameter.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); return methodParameter; } diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ContextValueMethodArgumentResolverTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ContextValueMethodArgumentResolverTests.java new file mode 100644 index 00000000..57b4f2cc --- /dev/null +++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/ContextValueMethodArgumentResolverTests.java @@ -0,0 +1,138 @@ +/* + * 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.data.method.annotation.support; + +import java.lang.reflect.Method; +import java.util.Optional; +import java.util.function.BiConsumer; + +import graphql.GraphQLContext; +import graphql.schema.DataFetchingEnvironment; +import graphql.schema.DataFetchingEnvironmentImpl; +import org.junit.jupiter.api.Test; + +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.SynthesizingMethodParameter; +import org.springframework.graphql.Book; +import org.springframework.graphql.data.method.annotation.ContextValue; +import org.springframework.lang.Nullable; +import org.springframework.util.ClassUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; + +/** + * Unit tests for {@link ContextValueMethodArgumentResolver}. + * @author Rossen Stoyanchev + */ +public class ContextValueMethodArgumentResolverTests { + + private static final Method method = ClassUtils.getMethod( + ContextValueMethodArgumentResolverTests.class, "handle", (Class[]) null); + + private final ContextValueMethodArgumentResolver resolver = new ContextValueMethodArgumentResolver(); + + private final Book book = new Book(); + + + @Test + void supportsParameter() { + assertThat(this.resolver.supportsParameter(methodParam(0))).isTrue(); + assertThat(this.resolver.supportsParameter(methodParam(1))).isTrue(); + assertThat(this.resolver.supportsParameter(methodParam(2))).isTrue(); + assertThat(this.resolver.supportsParameter(methodParam(3))).isTrue(); + assertThat(this.resolver.supportsParameter(methodParam(4))).isFalse(); + } + + @Test + void resolve() { + BiConsumer tester = (key, index) -> { + GraphQLContext context = GraphQLContext.newContext().of(key, this.book).build(); + Object actual = resolveValue(null, context, index); + assertThat(actual).isSameAs(this.book); + }; + tester.accept("book", 0); + tester.accept("customKey", 1); + } + + @Test + void resolveFromLocalContext() { + BiConsumer tester = (key, index) -> { + GraphQLContext context = GraphQLContext.newContext().of(key, this.book).build(); + Object actual = resolveValue(context, null, index); + assertThat(actual).isSameAs(this.book); + }; + tester.accept("book", 0); + tester.accept("customKey", 1); + } + + @Test + @SuppressWarnings({"unchecked", "ConstantConditions"}) + void resolveMissing() { + GraphQLContext context = GraphQLContext.newContext().build(); + + // Required + assertThatIllegalStateException() + .isThrownBy(() -> resolveValue(context, context, 0)) + .withMessage("Missing required context value for method 'handle' parameter 0"); + + // Not required + assertThat(resolveValue(context, context, 2)).isNull(); + + // Optional + Optional actual = (Optional) resolveValue(context, context, 3); + assertThat(actual.isPresent()).isFalse(); + } + + @Test + @SuppressWarnings({"unchecked", "ConstantConditions", "OptionalGetWithoutIsPresent"}) + void resolveOptional() { + GraphQLContext context = GraphQLContext.newContext().of("optionalBook", this.book).build(); + Optional actual = (Optional) resolveValue(context, context, 3); + + assertThat(actual.get()).isSameAs(this.book); + } + + @Nullable + private Object resolveValue( + @Nullable GraphQLContext localContext, @Nullable GraphQLContext graphQLContext, int index) { + + DataFetchingEnvironment environment = DataFetchingEnvironmentImpl.newDataFetchingEnvironment() + .localContext(localContext) + .graphQLContext(graphQLContext) + .build(); + + return this.resolver.resolveArgument(methodParam(index), environment); + } + + private MethodParameter methodParam(int index) { + MethodParameter methodParameter = new SynthesizingMethodParameter(method, index); + methodParameter.initParameterNameDiscovery(new DefaultParameterNameDiscoverer()); + return methodParameter; + } + + + @SuppressWarnings({"unused", "rawtypes", "OptionalUsedAsFieldOrParameterType"}) + public void handle( + @ContextValue Book book, + @ContextValue("customKey") Book customKeyBook, + @ContextValue(required = false) Book notRequiredBook, + @ContextValue Optional optionalBook, + Book otherBook) { + } + +}