diff --git a/spring-graphql/build.gradle b/spring-graphql/build.gradle index 1320e0ce..e49ea7b3 100644 --- a/spring-graphql/build.gradle +++ b/spring-graphql/build.gradle @@ -35,6 +35,7 @@ dependencies { testImplementation 'org.assertj:assertj-core' testImplementation 'org.mockito:mockito-core' testImplementation 'io.projectreactor:reactor-test' + testImplementation 'org.springframework:spring-core-test' testImplementation 'org.springframework:spring-messaging' testImplementation 'org.springframework:spring-test' testImplementation 'org.springframework:spring-webflux' 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 a86146f4..051db7f4 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 @@ -165,6 +165,10 @@ public class AnnotatedControllerConfigurer this.applicationContext = applicationContext; } + @Nullable + HandlerMethodArgumentResolverComposite getArgumentResolvers() { + return this.argumentResolvers; + } @Override public void afterPropertiesSet() { diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingBeanFactoryInitializationAotProcessor.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingBeanFactoryInitializationAotProcessor.java new file mode 100644 index 00000000..8e18ccba --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingBeanFactoryInitializationAotProcessor.java @@ -0,0 +1,244 @@ +/* + * Copyright 2020-2022 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.AnnotatedElement; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.Type; +import java.util.Arrays; + +import org.springframework.aop.SpringProxy; +import org.springframework.aot.generate.GenerationContext; +import org.springframework.aot.hint.BindingReflectionHintsRegistrar; +import org.springframework.aot.hint.ExecutableMode; +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.TypeReference; +import org.springframework.aot.hint.annotation.RegisterReflectionForBinding; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution; +import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor; +import org.springframework.beans.factory.aot.BeanFactoryInitializationCode; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.core.DecoratingProxy; +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.projection.TargetAware; +import org.springframework.graphql.data.ArgumentValue; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.stereotype.Controller; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; + +import static org.springframework.core.annotation.MergedAnnotations.SearchStrategy.TYPE_HIERARCHY; + +/** + * {@link BeanFactoryInitializationAotProcessor} implementation for registering + * runtime hints discoverable through GraphQL controllers, such as: + *
This processor is using a {@link HandlerMethodArgumentResolver} resolution mechanism similar + * to the one used in {@link AnnotatedControllerConfigurer}. The type of runtime hints registered + * for each method argument depends on the {@link HandlerMethodArgumentResolver} resolved. + *
Manual registration of {@link graphql.schema.DataFetcher} cannot be detected by this
+ * processor; developers will need to declare bound types with {@link RegisterReflectionForBinding}
+ * annotations on their configuration class.
+ *
+ * @author Brian Clozel
+ * @see org.springframework.graphql.data.method.HandlerMethodArgumentResolver
+ * @since 1.1.0
+ */
+class SchemaMappingBeanFactoryInitializationAotProcessor implements BeanFactoryInitializationAotProcessor {
+
+ private final static boolean springDataPresent = ClassUtils.isPresent(
+ "org.springframework.data.projection.SpelAwareProxyProjectionFactory",
+ SchemaMappingBeanFactoryInitializationAotProcessor.class.getClassLoader());
+
+
+ @Override
+ public BeanFactoryInitializationAotContribution processAheadOfTime(ConfigurableListableBeanFactory beanFactory) {
+ Class>[] controllerTypes = Arrays.stream(beanFactory.getBeanDefinitionNames())
+ .map(beanName -> RegisteredBean.of(beanFactory, beanName).getBeanClass())
+ .filter(this::isController)
+ .toArray(Class>[]::new);
+ return new SchemaMappingBeanFactoryInitializationAotContribution(controllerTypes);
+ }
+
+ private boolean isController(AnnotatedElement element) {
+ return MergedAnnotations.from(element, TYPE_HIERARCHY).isPresent(Controller.class);
+ }
+
+ private static class SchemaMappingBeanFactoryInitializationAotContribution implements BeanFactoryInitializationAotContribution {
+
+ private final Class>[] controllers;
+
+ private final HandlerMethodArgumentResolverComposite argumentResolvers;
+
+ public SchemaMappingBeanFactoryInitializationAotContribution(Class>[] controllers) {
+ this.controllers = controllers;
+ this.argumentResolvers = createArgumentResolvers();
+ }
+
+ private HandlerMethodArgumentResolverComposite createArgumentResolvers() {
+ AnnotatedControllerConfigurer controllerConfigurer = new AnnotatedControllerConfigurer();
+ controllerConfigurer.setApplicationContext(new StaticApplicationContext());
+ controllerConfigurer.afterPropertiesSet();
+ HandlerMethodArgumentResolverComposite argumentResolverComposite = controllerConfigurer.getArgumentResolvers();
+ Assert.notNull(argumentResolverComposite, "argument resolvers should not be null");
+ return argumentResolverComposite;
+ }
+
+ @Override
+ public void applyTo(GenerationContext generationContext, BeanFactoryInitializationCode beanFactoryInitializationCode) {
+ RuntimeHints runtimeHints = generationContext.getRuntimeHints();
+ registerSpringDataSpelSupport(runtimeHints);
+ Arrays.stream(this.controllers).forEach(controller -> {
+ runtimeHints.reflection().registerType(controller);
+ ReflectionUtils.doWithMethods(controller, method -> processSchemaMappingMethod(runtimeHints, method), this::isGraphQlHandlerMethod);
+ });
+ }
+
+ private void registerSpringDataSpelSupport(RuntimeHints runtimeHints) {
+ if (springDataPresent) {
+ runtimeHints.reflection()
+ .registerType(SpelAwareProxyProjectionFactory.class)
+ .registerType(TypeReference.of("org.springframework.data.projection.SpelEvaluatingMethodInterceptor$TargetWrapper"),
+ builder -> builder.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS,
+ MemberCategory.INVOKE_DECLARED_METHODS, MemberCategory.INVOKE_PUBLIC_METHODS));
+ }
+ }
+
+ private boolean isGraphQlHandlerMethod(AnnotatedElement element) {
+ MergedAnnotations mergedAnnotations = MergedAnnotations.from(element, TYPE_HIERARCHY);
+ return mergedAnnotations.isPresent(SchemaMapping.class)
+ || mergedAnnotations.isPresent(BatchMapping.class);
+ }
+
+ private void processSchemaMappingMethod(RuntimeHints runtimeHints, Method method) {
+ runtimeHints.reflection().registerMethod(method, ExecutableMode.INVOKE);
+ for (Parameter parameter : method.getParameters()) {
+ processMethodParameter(runtimeHints, MethodParameter.forParameter(parameter));
+ }
+ processReturnType(runtimeHints, MethodParameter.forExecutable(method, -1));
+ }
+
+ private void processMethodParameter(RuntimeHints runtimeHints, MethodParameter methodParameter) {
+ MethodParameterRuntimeHintsRegistrar.fromMethodParameter(this.argumentResolvers, methodParameter)
+ .apply(runtimeHints);
+ }
+
+ private void processReturnType(RuntimeHints runtimeHints, MethodParameter methodParameter) {
+ new ArgumentBindingHints(methodParameter).apply(runtimeHints);
+ }
+
+ }
+
+ @FunctionalInterface
+ private interface MethodParameterRuntimeHintsRegistrar {
+
+ BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar();
+
+ void apply(RuntimeHints runtimeHints);
+
+ static MethodParameterRuntimeHintsRegistrar fromMethodParameter(HandlerMethodArgumentResolverComposite argumentResolvers, MethodParameter methodParameter) {
+ HandlerMethodArgumentResolver argumentResolver = argumentResolvers.getArgumentResolver(methodParameter);
+ if (argumentResolver instanceof ArgumentMethodArgumentResolver
+ || argumentResolver instanceof ArgumentsMethodArgumentResolver) {
+ return new ArgumentBindingHints(methodParameter);
+ }
+ if (argumentResolver instanceof DataLoaderMethodArgumentResolver) {
+ return new DataLoaderHints(methodParameter);
+ }
+ if (springDataPresent) {
+ if (argumentResolver instanceof ProjectedPayloadMethodArgumentResolver) {
+ return new ProjectedPayloadHints(methodParameter);
+ }
+ }
+ return new NoHintsRequired();
+ }
+
+ }
+
+ private static class NoHintsRequired implements MethodParameterRuntimeHintsRegistrar {
+
+ @Override
+ public void apply(RuntimeHints runtimeHints) {
+ // no runtime hints are required for this type of argument
+ }
+ }
+
+ private static class ArgumentBindingHints implements MethodParameterRuntimeHintsRegistrar {
+
+ private final MethodParameter methodParameter;
+
+ public ArgumentBindingHints(MethodParameter methodParameter) {
+ this.methodParameter = methodParameter;
+ }
+
+ @Override
+ public void apply(RuntimeHints runtimeHints) {
+ Type parameterType = this.methodParameter.getGenericParameterType();
+ if (ArgumentValue.class.isAssignableFrom(methodParameter.getParameterType())) {
+ parameterType = this.methodParameter.nested().getNestedGenericParameterType();
+ }
+ bindingRegistrar.registerReflectionHints(runtimeHints.reflection(), parameterType);
+ }
+ }
+
+ private static class DataLoaderHints implements MethodParameterRuntimeHintsRegistrar {
+
+ private final MethodParameter methodParameter;
+
+ public DataLoaderHints(MethodParameter methodParameter) {
+ this.methodParameter = methodParameter;
+ }
+
+ @Override
+ public void apply(RuntimeHints runtimeHints) {
+ bindingRegistrar.registerReflectionHints(runtimeHints.reflection(),
+ this.methodParameter.nested().getNestedGenericParameterType());
+ }
+ }
+
+ private static class ProjectedPayloadHints implements MethodParameterRuntimeHintsRegistrar {
+
+ private final MethodParameter methodParameter;
+
+ public ProjectedPayloadHints(MethodParameter methodParameter) {
+ this.methodParameter = methodParameter;
+ }
+
+ @Override
+ public void apply(RuntimeHints runtimeHints) {
+ Class> parameterType = this.methodParameter.nestedIfOptional().getNestedParameterType();
+ runtimeHints.reflection().registerType(parameterType);
+ runtimeHints.proxies().registerJdkProxy(parameterType, TargetAware.class, SpringProxy.class, DecoratingProxy.class);
+ }
+ }
+
+}
diff --git a/spring-graphql/src/main/resources/META-INF/spring/aot.factories b/spring-graphql/src/main/resources/META-INF/spring/aot.factories
new file mode 100644
index 00000000..95d83aed
--- /dev/null
+++ b/spring-graphql/src/main/resources/META-INF/spring/aot.factories
@@ -0,0 +1 @@
+org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor=org.springframework.graphql.data.method.annotation.support.SchemaMappingBeanFactoryInitializationAotProcessor
\ No newline at end of file
diff --git a/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingBeanFactoryInitializationAotProcessorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingBeanFactoryInitializationAotProcessorTests.java
new file mode 100644
index 00000000..3ffb582d
--- /dev/null
+++ b/spring-graphql/src/test/java/org/springframework/graphql/data/method/annotation/support/SchemaMappingBeanFactoryInitializationAotProcessorTests.java
@@ -0,0 +1,433 @@
+/*
+ * Copyright 2020-2022 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.beans.BeanInfo;
+import java.beans.IntrospectionException;
+import java.beans.Introspector;
+import java.beans.PropertyDescriptor;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Predicate;
+
+import graphql.GraphQLContext;
+import graphql.schema.DataFetchingFieldSelectionSet;
+import org.dataloader.DataLoader;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import org.springframework.aop.SpringProxy;
+import org.springframework.aot.generate.GenerationContext;
+import org.springframework.aot.hint.MemberCategory;
+import org.springframework.aot.hint.RuntimeHints;
+import org.springframework.aot.hint.TypeReference;
+import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
+import org.springframework.aot.test.generate.TestGenerationContext;
+import org.springframework.beans.factory.aot.AotServices;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationAotContribution;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationAotProcessor;
+import org.springframework.beans.factory.aot.BeanFactoryInitializationCode;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.core.DecoratingProxy;
+import org.springframework.data.projection.TargetAware;
+import org.springframework.data.web.ProjectedPayload;
+import org.springframework.graphql.Author;
+import org.springframework.graphql.Book;
+import org.springframework.graphql.data.ArgumentValue;
+import org.springframework.graphql.data.method.annotation.Argument;
+import org.springframework.graphql.data.method.annotation.BatchMapping;
+import org.springframework.graphql.data.method.annotation.ContextValue;
+import org.springframework.graphql.data.method.annotation.LocalContextValue;
+import org.springframework.graphql.data.method.annotation.MutationMapping;
+import org.springframework.graphql.data.method.annotation.QueryMapping;
+import org.springframework.graphql.data.method.annotation.SchemaMapping;
+import org.springframework.stereotype.Controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link SchemaMappingBeanFactoryInitializationAotProcessor}.
+ *
+ * @author Brian Clozel
+ */
+class SchemaMappingBeanFactoryInitializationAotProcessorTests {
+
+ private GenerationContext generationContext = new TestGenerationContext();
+
+ private SchemaMappingBeanFactoryInitializationAotProcessor processor = new SchemaMappingBeanFactoryInitializationAotProcessor();
+
+ @Test
+ void processorIsRegisteredInAotFactories() {
+ assertThat(AotServices.factories(getClass().getClassLoader()).load(BeanFactoryInitializationAotProcessor.class))
+ .anyMatch(SchemaMappingBeanFactoryInitializationAotProcessor.class::isInstance);
+ }
+
+ @Nested
+ class ArgumentTests {
+
+ @Test
+ void registerBindingReflectionOnReturnType() {
+ processController(ReturnTypeController.class);
+ assertThatHintsForJavaBeanBindingRegisteredForTypes(Book.class);
+ }
+
+ @Test
+ void registerBindingReflectionOnInput() {
+ processController(InputController.class);
+ assertThatHintsForJavaBeanBindingRegisteredForTypes(Book.class, BookInput.class);
+ }
+
+ @Test
+ void registerBindingReflectionOnArgumentCollection() {
+ processController(ArgumentCollectionController.class);
+ assertThatHintsForJavaBeanBindingRegisteredForTypes(Book.class);
+ }
+
+ @Test
+ void registerBindingReflectionOnArgumentValue() {
+ processController(ArgumentValueController.class);
+ assertThatHintsForJavaBeanBindingRegisteredForTypes(Book.class, BookInput.class);
+ assertThatHintsAreNotRegisteredForTypes(ArgumentValue.class);
+ }
+
+ @Test
+ void registerBindingReflectionOnDataLoaderArgument() {
+ processController(DataLoaderController.class);
+ assertThatHintsForJavaBeanBindingRegisteredForTypes(Author.class);
+ assertThatHintsAreNotRegisteredForTypes(DataLoader.class);
+ }
+
+ @Test
+ void registerBindingReflectionOnAsyncReturnType() {
+ processController(AsyncReturnTypeController.class);
+ assertThatHintsForJavaBeanBindingRegisteredForTypes(Author.class);
+ }
+
+
+ @Controller
+ static class ReturnTypeController {
+ @QueryMapping
+ public Book bookById(@Argument Long id) {
+ return null;
+ }
+
+ }
+
+ @Controller
+ static class InputController {
+ @MutationMapping
+ public Book addBook(@Argument BookInput bookInput) {
+ return null;
+ }
+
+ }
+
+ @Controller
+ static class ArgumentCollectionController {
+ @MutationMapping
+ public void addBooks(@Argument List