From 9aa506a2803205e5fbb92e02c2cc37617fb4d0bc Mon Sep 17 00:00:00 2001 From: rstoyanchev Date: Thu, 1 Feb 2024 13:44:19 +0000 Subject: [PATCH] Extract base classes for annotated controller methods See gh-864 --- .../AnnotatedControllerConfigurer.java | 292 +++--------------- .../AnnotatedControllerDetectionSupport.java | 255 +++++++++++++++ .../support/DataFetcherHandlerMethod.java | 72 +---- .../DataFetcherHandlerMethodSupport.java | 106 +++++++ .../support/DataFetcherMappingInfo.java | 111 +++++++ .../SchemaMappingInspectorTests.java | 14 +- 6 files changed, 525 insertions(+), 325 deletions(-) create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerDetectionSupport.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethodSupport.java create mode 100644 spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherMappingInfo.java 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 cba16081..aaba7f9a 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 @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -17,12 +17,9 @@ package org.springframework.graphql.data.method.annotation.support; import java.lang.annotation.Annotation; import java.lang.reflect.Method; -import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -32,7 +29,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.function.BiConsumer; import java.util.function.Consumer; -import java.util.stream.Collectors; import graphql.execution.DataFetcherResult; import graphql.schema.DataFetcher; @@ -40,29 +36,19 @@ import graphql.schema.DataFetchingEnvironment; import graphql.schema.FieldCoordinates; import graphql.schema.GraphQLCodeRegistry; import graphql.schema.idl.RuntimeWiring; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; import org.dataloader.DataLoader; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import org.springframework.aop.support.AopUtils; -import org.springframework.beans.factory.InitializingBean; import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.context.ApplicationContext; -import org.springframework.context.ApplicationContextAware; import org.springframework.context.expression.BeanFactoryResolver; import org.springframework.core.KotlinDetector; -import org.springframework.core.MethodIntrospector; import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; import org.springframework.core.annotation.AnnotatedElementUtils; -import org.springframework.core.convert.ConversionService; import org.springframework.data.domain.ScrollPosition; -import org.springframework.format.FormatterRegistrar; -import org.springframework.format.support.DefaultFormattingConversionService; -import org.springframework.format.support.FormattingConversionService; import org.springframework.graphql.data.GraphQlArgumentBinder; import org.springframework.graphql.data.method.HandlerMethod; import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; @@ -83,14 +69,11 @@ import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; import org.springframework.validation.DataBinder; + /** * {@link RuntimeWiringConfigurer} that finds {@link SchemaMapping @SchemaMapping} - * annotated handler methods in {@link Controller @Controller} classes declared in - * Spring configuration, and registers them as {@link DataFetcher}s. - * - *

In addition to initializing a {@link RuntimeWiring.Builder}, this class, also - * provides an option to {@link #configure(GraphQLCodeRegistry.Builder) configure} - * data fetchers on a {@link GraphQLCodeRegistry.Builder}. + * and {@link BatchMapping @BatchMapping} methods in {@link Controller @Controller} + * classes, and registers them as {@link DataFetcher}s. * *

This class detects the following strategies in Spring configuration, * expecting to find a single, unique bean of that type: @@ -103,13 +86,17 @@ import org.springframework.validation.DataBinder; * is configured for use. * * - * + *

In addition to initializing a {@link RuntimeWiring.Builder}, this class, also + * provides an option to {@link #configure(GraphQLCodeRegistry.Builder) configure} + * data fetchers on a {@link GraphQLCodeRegistry.Builder}. * * @author Rossen Stoyanchev * @author Brian Clozel * @since 1.0.0 */ -public class AnnotatedControllerConfigurer implements ApplicationContextAware, InitializingBean, RuntimeWiringConfigurer { +public class AnnotatedControllerConfigurer + extends AnnotatedControllerDetectionSupport + implements RuntimeWiringConfigurer { private static final ClassLoader classLoader = AnnotatedControllerConfigurer.class.getClassLoader(); @@ -123,63 +110,14 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I "jakarta.validation.executable.ExecutableValidator", classLoader); - private final static Log logger = LogFactory.getLog(AnnotatedControllerConfigurer.class); - - /** - * Bean name prefix for target beans behind scoped proxies. Used to exclude those - * targets from handler method detection, in favor of the corresponding proxies. - *

We're not checking the autowire-candidate status here, which is how the - * proxy target filtering problem is being handled at the autowiring level, - * since autowire-candidate may have been turned to {@code false} for other - * reasons, while still expecting the bean to be eligible for handler methods. - *

Originally defined in {@link org.springframework.aop.scope.ScopedProxyUtils} - * but duplicated here to avoid a hard dependency on the spring-aop module. - */ - private static final String SCOPED_TARGET_NAME_PREFIX = "scopedTarget."; - - - private final FormattingConversionService conversionService = new DefaultFormattingConversionService(); - - private boolean fallBackOnDirectFieldAccess; - private final List customArgumentResolvers = new ArrayList<>(8); - @Nullable - private HandlerMethodArgumentResolverComposite argumentResolvers; - @Nullable private ValidationHelper validationHelper; @Nullable private AnnotatedControllerExceptionResolver exceptionResolver; - @Nullable - private Executor executor; - - @Nullable - private ApplicationContext applicationContext; - - - /** - * Add a {@code FormatterRegistrar} to customize the {@link ConversionService} - * that assists in binding GraphQL arguments onto - * {@link org.springframework.graphql.data.method.annotation.Argument @Argument} - * annotated method parameters. - */ - public void addFormatterRegistrar(FormatterRegistrar registrar) { - registrar.registerFormatters(this.conversionService); - } - - /** - * Whether binding GraphQL arguments onto - * {@link org.springframework.graphql.data.method.annotation.Argument @Argument} - * should falls back to direct field access in case the target object does - * not use accessor methods. - * @since 1.2.0 - */ - public void setFallBackOnDirectFieldAccess(boolean fallBackOnDirectFieldAccess) { - this.fallBackOnDirectFieldAccess = fallBackOnDirectFieldAccess; - } /** * Add a {@link HandlerMethodArgumentResolver} for custom controller method @@ -193,12 +131,6 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I this.customArgumentResolvers.add(resolver); } - HandlerMethodArgumentResolverComposite getArgumentResolvers() { - Assert.notNull(this.argumentResolvers, - "HandlerMethodArgumentResolverComposite is not yet initialized, was afterPropertiesSet called?"); - return this.argumentResolvers; - } - /** * Return a {@link DataFetcherExceptionResolver} that resolves exceptions with * {@code @GraphQlExceptionHandler} methods in {@code @ControllerAdvice} @@ -230,31 +162,14 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I public void setDataBinderInitializer(@Nullable Consumer consumer) { } - /** - * Configure an {@link Executor} to use for asynchronous handling of - * {@link Callable} return values from controller methods. - *

By default, this is not set in which case controller methods with a - * {@code Callable} return value cannot be registered. - * @param executor the executor to use - */ - public void setExecutor(Executor executor) { - this.executor = executor; - } - - @Override - public void setApplicationContext(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } - @Override public void afterPropertiesSet() { + super.afterPropertiesSet(); - this.argumentResolvers = initArgumentResolvers(); - - this.exceptionResolver = new AnnotatedControllerExceptionResolver(this.argumentResolvers); - if (this.applicationContext != null) { - this.exceptionResolver.registerControllerAdvice(this.applicationContext); + this.exceptionResolver = new AnnotatedControllerExceptionResolver(getArgumentResolvers()); + if (getApplicationContext() != null) { + this.exceptionResolver.registerControllerAdvice(getApplicationContext()); } if (beanValidationPresent) { @@ -262,7 +177,8 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I } } - private HandlerMethodArgumentResolverComposite initArgumentResolvers() { + @Override + protected HandlerMethodArgumentResolverComposite initArgumentResolvers() { HandlerMethodArgumentResolverComposite resolvers = new HandlerMethodArgumentResolverComposite(); @@ -273,7 +189,7 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I } GraphQlArgumentBinder argumentBinder = - new GraphQlArgumentBinder(this.conversionService, this.fallBackOnDirectFieldAccess); + new GraphQlArgumentBinder(getConversionService(), isFallBackOnDirectFieldAccess()); resolvers.addResolver(new ArgumentMethodArgumentResolver(argumentBinder)); resolvers.addResolver(new ArgumentsMethodArgumentResolver(argumentBinder)); @@ -332,90 +248,28 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I } } - protected final ApplicationContext obtainApplicationContext() { - Assert.state(this.applicationContext != null, "No ApplicationContext"); - return this.applicationContext; - } - @Override public void configure(RuntimeWiring.Builder runtimeWiringBuilder) { - Assert.state(this.argumentResolvers != null, "`argumentResolvers` is not initialized"); Assert.state(this.exceptionResolver != null, "`exceptionResolver` is not initialized"); - findHandlerMethods().forEach((info) -> { + detectHandlerMethods().forEach(info -> { DataFetcher dataFetcher; if (!info.isBatchMapping()) { dataFetcher = new SchemaMappingDataFetcher( - info, this.argumentResolvers, this.validationHelper, this.exceptionResolver, this.executor); + info, getArgumentResolvers(), this.validationHelper, this.exceptionResolver, getExecutor()); } else { dataFetcher = registerBatchLoader(info); } - runtimeWiringBuilder.type(info.getCoordinates().getTypeName(), typeBuilder -> - typeBuilder.dataFetcher(info.getCoordinates().getFieldName(), dataFetcher)); + FieldCoordinates coordinates = info.getCoordinates(); + runtimeWiringBuilder.type(coordinates.getTypeName(), typeBuilder -> + typeBuilder.dataFetcher(coordinates.getFieldName(), dataFetcher)); }); } - /** - * Scan beans in the ApplicationContext, detect and prepare a map of handler methods. - */ - private Collection findHandlerMethods() { - ApplicationContext context = obtainApplicationContext(); - Map result = new HashMap<>(); - for (String beanName : context.getBeanNamesForType(Object.class)) { - if (beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) { - continue; - } - Class beanType = null; - try { - beanType = context.getType(beanName); - } - catch (Throwable ex) { - // An unresolvable bean type, probably from a lazy bean - let's ignore it. - if (logger.isTraceEnabled()) { - logger.trace("Could not resolve type for bean '" + beanName + "'", ex); - } - } - if (beanType == null || !AnnotatedElementUtils.hasAnnotation(beanType, Controller.class)) { - continue; - } - Class beanClass = context.getType(beanName); - findHandlerMethods(beanName, beanClass).forEach((info) -> { - HandlerMethod handlerMethod = info.getHandlerMethod(); - MappingInfo existing = result.put(info.getCoordinates(), info); - if (existing != null && !existing.getHandlerMethod().equals(handlerMethod)) { - throw new IllegalStateException( - "Ambiguous mapping. Cannot map '" + handlerMethod.getBean() + "' method \n" + - handlerMethod + "\nto " + info.getCoordinates() + ": There is already '" + - existing.getHandlerMethod().getBean() + "' bean method\n" + existing + " mapped."); - } - }); - } - return result.values(); - } - - private Collection findHandlerMethods(Object handler, @Nullable Class handlerClass) { - if (handlerClass == null) { - return Collections.emptyList(); - } - - Class userClass = ClassUtils.getUserClass(handlerClass); - Map map = MethodIntrospector.selectMethods( - userClass, (Method method) -> getMappingInfo(method, handler, userClass)); - - Collection mappingInfos = map.values(); - - if (logger.isTraceEnabled() && !mappingInfos.isEmpty()) { - logger.trace(formatMappings(userClass, mappingInfos)); - } - - return mappingInfos; - } - - @Nullable - private MappingInfo getMappingInfo(Method method, Object handler, Class handlerType) { - + @Override + protected DataFetcherMappingInfo getMappingInfo(Method method, Object handler, Class handlerType) { Set annotations = AnnotatedElementUtils.findAllMergedAnnotations( method, new LinkedHashSet<>(Arrays.asList(BatchMapping.class, SchemaMapping.class))); @@ -457,8 +311,7 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I if (!StringUtils.hasText(typeName)) { for (MethodParameter parameter : handlerMethod.getMethodParameters()) { if (!batchMapping) { - Assert.state(this.argumentResolvers != null, "`argumentResolvers` is not initialized"); - HandlerMethodArgumentResolver resolver = this.argumentResolvers.getArgumentResolver(parameter); + HandlerMethodArgumentResolver resolver = getArgumentResolvers().getArgumentResolver(parameter); if (resolver instanceof SourceMethodArgumentResolver) { typeName = parameter.getParameterType().getSimpleName(); break; @@ -477,32 +330,15 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I "No parentType specified, and a source/parent method argument was also not found: " + handlerMethod.getShortLogMessage()); - return new MappingInfo(typeName, field, batchMapping, batchSize, handlerMethod); + return new DataFetcherMappingInfo(typeName, field, batchMapping, batchSize, handlerMethod); } - private HandlerMethod createHandlerMethod(Method method, Object handler, Class handlerType) { - Method theMethod = AopUtils.selectInvocableMethod(method, handlerType); - return (handler instanceof String ? - new HandlerMethod((String) handler, obtainApplicationContext().getAutowireCapableBeanFactory(), theMethod) : - new HandlerMethod(handler, theMethod)); + @Override + protected HandlerMethod getHandlerMethod(DataFetcherMappingInfo mappingInfo) { + return mappingInfo.getHandlerMethod(); } - private String formatMappings(Class handlerType, Collection infos) { - String formattedType = Arrays.stream(ClassUtils.getPackageName(handlerType).split("\\.")) - .map(p -> p.substring(0, 1)) - .collect(Collectors.joining(".", "", "." + handlerType.getSimpleName())); - return infos.stream() - .map(mappingInfo -> { - Method method = mappingInfo.getHandlerMethod().getMethod(); - String methodParameters = Arrays.stream(method.getGenericParameterTypes()) - .map(Type::getTypeName) - .collect(Collectors.joining(",", "(", ")")); - return mappingInfo.getCoordinates() + " => " + method.getName() + methodParameters; - }) - .collect(Collectors.joining("\n\t", "\n\t" + formattedType + ":" + "\n\t", "")); - } - - private DataFetcher registerBatchLoader(MappingInfo info) { + private DataFetcher registerBatchLoader(DataFetcherMappingInfo info) { if (!info.isBatchMapping()) { throw new IllegalArgumentException("Not a @BatchMapping method: " + info); } @@ -515,7 +351,7 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I } HandlerMethod handlerMethod = info.getHandlerMethod(); - BatchLoaderHandlerMethod invocable = new BatchLoaderHandlerMethod(handlerMethod, this.executor); + BatchLoaderHandlerMethod invocable = new BatchLoaderHandlerMethod(handlerMethod, getExecutor()); MethodParameter returnType = handlerMethod.getReturnType(); Class clazz = returnType.getParameterType(); @@ -567,56 +403,12 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I } - private static class MappingInfo { - - private final FieldCoordinates coordinates; - - private final boolean batchMapping; - - private final int maxBatchSize; - - private final HandlerMethod handlerMethod; - - public MappingInfo( - String typeName, String field, boolean batchMapping, int maxBatchSize, - HandlerMethod handlerMethod) { - - this.coordinates = FieldCoordinates.coordinates(typeName, field); - this.batchMapping = batchMapping; - this.maxBatchSize = maxBatchSize; - this.handlerMethod = handlerMethod; - } - - public FieldCoordinates getCoordinates() { - return this.coordinates; - } - - @SuppressWarnings("BooleanMethodIsAlwaysInverted") - public boolean isBatchMapping() { - return this.batchMapping; - } - - public int getMaxBatchSize() { - return this.maxBatchSize; - } - - public HandlerMethod getHandlerMethod() { - return this.handlerMethod; - } - - @Override - public String toString() { - return this.coordinates + " -> " + this.handlerMethod; - } - } - - /** * {@link DataFetcher} that wrap and invokes a {@link HandlerMethod}. */ static class SchemaMappingDataFetcher implements SelfDescribingDataFetcher { - private final MappingInfo info; + private final DataFetcherMappingInfo mappingInfo; private final HandlerMethodArgumentResolverComposite argumentResolvers; @@ -631,11 +423,11 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I private final boolean subscription; SchemaMappingDataFetcher( - MappingInfo info, HandlerMethodArgumentResolverComposite argumentResolvers, + DataFetcherMappingInfo info, HandlerMethodArgumentResolverComposite argumentResolvers, @Nullable ValidationHelper helper, AnnotatedControllerExceptionResolver exceptionResolver, @Nullable Executor executor) { - this.info = info; + this.mappingInfo = info; this.argumentResolvers = argumentResolvers; this.methodValidationHelper = @@ -648,24 +440,24 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I this.exceptionResolver = exceptionResolver; this.executor = executor; - this.subscription = this.info.getCoordinates().getTypeName().equalsIgnoreCase("Subscription"); + this.subscription = this.mappingInfo.getCoordinates().getTypeName().equalsIgnoreCase("Subscription"); } @Override public String getDescription() { - return this.info.getHandlerMethod().getShortLogMessage(); + return this.mappingInfo.getHandlerMethod().getShortLogMessage(); } @Override public ResolvableType getReturnType() { - return ResolvableType.forMethodReturnType(this.info.getHandlerMethod().getMethod()); + return ResolvableType.forMethodReturnType(this.mappingInfo.getHandlerMethod().getMethod()); } /** * Return the {@link HandlerMethod} used to fetch data. */ public HandlerMethod getHandlerMethod() { - return this.info.getHandlerMethod(); + return this.mappingInfo.getHandlerMethod(); } @Override @@ -728,21 +520,21 @@ public class AnnotatedControllerConfigurer implements ApplicationContextAware, I static class BatchMappingDataFetcher implements DataFetcher, SelfDescribingDataFetcher { - private final MappingInfo info; + private final DataFetcherMappingInfo mappingInfo; private final ResolvableType returnType; private final String dataLoaderKey; - BatchMappingDataFetcher(MappingInfo info, ResolvableType valueType, String dataLoaderKey) { - this.info = info; + BatchMappingDataFetcher(DataFetcherMappingInfo info, ResolvableType valueType, String dataLoaderKey) { + this.mappingInfo = info; this.returnType = ResolvableType.forClassWithGenerics(CompletableFuture.class, valueType); this.dataLoaderKey = dataLoaderKey; } @Override public String getDescription() { - return "@BatchMapping " + this.info.getHandlerMethod().getShortLogMessage(); + return "@BatchMapping " + this.mappingInfo.getHandlerMethod().getShortLogMessage(); } @Override diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerDetectionSupport.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerDetectionSupport.java new file mode 100644 index 00000000..39b6861e --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/AnnotatedControllerDetectionSupport.java @@ -0,0 +1,255 @@ +/* + * Copyright 2002-2024 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.lang.reflect.Type; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.stream.Collectors; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.core.MethodIntrospector; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.convert.ConversionService; +import org.springframework.format.FormatterRegistrar; +import org.springframework.format.support.DefaultFormattingConversionService; +import org.springframework.format.support.FormattingConversionService; +import org.springframework.graphql.data.method.HandlerMethod; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite; +import org.springframework.lang.Nullable; +import org.springframework.stereotype.Controller; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Convenient base for classes that find annotated controller method with argument + * values resolved from a {@link graphql.schema.DataFetchingEnvironment}. + * + * @author Rossen Stoyanchev + * @since 1.3 + * @param the type of mapping info prepared from a controller method + */ +public abstract class AnnotatedControllerDetectionSupport implements ApplicationContextAware, InitializingBean { + + protected final static boolean springSecurityPresent = ClassUtils.isPresent( + "org.springframework.security.core.context.SecurityContext", + AnnotatedControllerDetectionSupport.class.getClassLoader()); + + /** + * Bean name prefix for target beans behind scoped proxies. Used to exclude those + * targets from handler method detection, in favor of the corresponding proxies. + *

We're not checking the autowire-candidate status here, which is how the + * proxy target filtering problem is being handled at the autowiring level, + * since autowire-candidate may have been turned to {@code false} for other + * reasons, while still expecting the bean to be eligible for handler methods. + *

Originally defined in {@link org.springframework.aop.scope.ScopedProxyUtils} + * but duplicated here to avoid a hard dependency on the spring-aop module. + */ + private static final String SCOPED_TARGET_NAME_PREFIX = "scopedTarget."; + + + protected final Log logger = LogFactory.getLog(getClass()); + + + private final FormattingConversionService conversionService = new DefaultFormattingConversionService(); + + private boolean fallBackOnDirectFieldAccess; + + @Nullable + private Executor executor; + + @Nullable + private HandlerMethodArgumentResolverComposite argumentResolvers; + + @Nullable + private ApplicationContext applicationContext; + + + /** + * Add a {@code FormatterRegistrar} to customize the {@link ConversionService} + * that assists in binding GraphQL arguments onto + * {@link org.springframework.graphql.data.method.annotation.Argument @Argument} + * annotated method parameters. + */ + public void addFormatterRegistrar(FormatterRegistrar registrar) { + registrar.registerFormatters(this.conversionService); + } + + protected FormattingConversionService getConversionService() { + return this.conversionService; + } + + /** + * Whether binding GraphQL arguments onto + * {@link org.springframework.graphql.data.method.annotation.Argument @Argument} + * should falls back to direct field access in case the target object does + * not use accessor methods. + * @since 1.2.0 + */ + public void setFallBackOnDirectFieldAccess(boolean fallBackOnDirectFieldAccess) { + this.fallBackOnDirectFieldAccess = fallBackOnDirectFieldAccess; + } + + protected boolean isFallBackOnDirectFieldAccess() { + return this.fallBackOnDirectFieldAccess; + } + + /** + * Configure an {@link Executor} to use for asynchronous handling of + * {@link Callable} return values from controller methods. + *

By default, this is not set in which case controller methods with a + * {@code Callable} return value cannot be registered. + * @param executor the executor to use + */ + public void setExecutor(Executor executor) { + this.executor = executor; + } + + @Nullable + public Executor getExecutor() { + return this.executor; + } + + /** + * Return the configured argument resolvers. + */ + protected HandlerMethodArgumentResolverComposite getArgumentResolvers() { + Assert.notNull(this.argumentResolvers, "Not yet initialized, was afterPropertiesSet called?"); + return this.argumentResolvers; + } + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Nullable + protected ApplicationContext getApplicationContext() { + return this.applicationContext; + } + + protected final ApplicationContext obtainApplicationContext() { + Assert.state(this.applicationContext != null, "No ApplicationContext"); + return this.applicationContext; + } + + + @Override + public void afterPropertiesSet() { + this.argumentResolvers = initArgumentResolvers(); + } + + protected abstract HandlerMethodArgumentResolverComposite initArgumentResolvers(); + + + /** + * Scan beans in the ApplicationContext, detect and prepare a map of handler methods. + */ + protected Set detectHandlerMethods() { + Set results = new LinkedHashSet<>(); + ApplicationContext context = obtainApplicationContext(); + for (String beanName : context.getBeanNamesForType(Object.class)) { + if (beanName.startsWith(SCOPED_TARGET_NAME_PREFIX)) { + continue; + } + Class beanType = null; + try { + beanType = context.getType(beanName); + } + catch (Throwable ex) { + // An unresolvable bean type, probably from a lazy bean - let's ignore it. + if (logger.isTraceEnabled()) { + logger.trace("Could not resolve type for bean '" + beanName + "'", ex); + } + } + if (beanType == null || !AnnotatedElementUtils.hasAnnotation(beanType, Controller.class)) { + continue; + } + Class beanClass = context.getType(beanName); + findHandlerMethods(beanName, beanClass).forEach(info -> { + HandlerMethod handlerMethod = getHandlerMethod(info); + M existing = results.stream().filter(o -> o.equals(info)).findFirst().orElse(null); + if (existing != null && !getHandlerMethod(existing).equals(handlerMethod)) { + throw new IllegalStateException( + "Ambiguous mapping. Cannot map '" + handlerMethod.getBean() + "' method \n" + + handlerMethod + "\n" + ": There is already '" + + getHandlerMethod(existing).getBean() + "' bean method\n" + existing + " mapped."); + } + results.add(info); + }); + } + return results; + } + + protected abstract HandlerMethod getHandlerMethod(M mappingInfo); + + private Collection findHandlerMethods(Object handler, @Nullable Class handlerClass) { + if (handlerClass == null) { + return Collections.emptyList(); + } + + Class userClass = ClassUtils.getUserClass(handlerClass); + Map map = MethodIntrospector.selectMethods( + userClass, (Method method) -> getMappingInfo(method, handler, userClass)); + + Collection mappingInfos = map.values(); + + if (logger.isTraceEnabled() && !mappingInfos.isEmpty()) { + logger.trace(formatMappings(userClass, mappingInfos)); + } + + return mappingInfos; + } + + @Nullable + protected abstract M getMappingInfo(Method method, Object handler, Class handlerType); + + protected HandlerMethod createHandlerMethod(Method originalMethod, Object handler, Class handlerType) { + Method method = AopUtils.selectInvocableMethod(originalMethod, handlerType); + return (handler instanceof String beanName ? + new HandlerMethod(beanName, obtainApplicationContext().getAutowireCapableBeanFactory(), method) : + new HandlerMethod(handler, method)); + } + + private String formatMappings(Class handlerType, Collection infos) { + String formattedType = Arrays.stream(ClassUtils.getPackageName(handlerType).split("\\.")) + .map(p -> p.substring(0, 1)) + .collect(Collectors.joining(".", "", "." + handlerType.getSimpleName())); + return infos.stream() + .map(info -> { + Method method = getHandlerMethod(info).getMethod(); + String methodParameters = Arrays.stream(method.getGenericParameterTypes()) + .map(Type::getTypeName) + .collect(Collectors.joining(",", "(", ")")); + return info + methodParameters; + }) + .collect(Collectors.joining("\n\t", "\n\t" + formattedType + ":" + "\n\t", "")); + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java index 0eadc916..c7806f96 100644 --- a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethod.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 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. @@ -25,35 +25,21 @@ import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -import org.springframework.core.DefaultParameterNameDiscoverer; -import org.springframework.core.MethodParameter; -import org.springframework.core.ParameterNameDiscoverer; import org.springframework.graphql.data.method.HandlerMethod; import org.springframework.graphql.data.method.HandlerMethodArgumentResolver; import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite; -import org.springframework.graphql.data.method.InvocableHandlerMethodSupport; import org.springframework.lang.Nullable; import org.springframework.util.Assert; -import org.springframework.util.ObjectUtils; /** - * An extension of {@link HandlerMethod} for annotated handler methods adapted - * to {@link graphql.schema.DataFetcher} with {@link DataFetchingEnvironment} - * as their input. + * Invocable handler to use as a {@link graphql.schema.DataFetcher}. * * @author Rossen Stoyanchev * @since 1.0.0 */ -public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport { - - private static final Object[] EMPTY_ARGS = new Object[0]; - - - private final HandlerMethodArgumentResolverComposite resolvers; +public class DataFetcherHandlerMethod extends DataFetcherHandlerMethodSupport { private final BiConsumer validationHelper; - - private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); private final boolean subscription; @@ -70,22 +56,13 @@ public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport { @Nullable BiConsumer validationHelper, @Nullable Executor executor, boolean subscription) { - super(handlerMethod, executor); + super(handlerMethod, resolvers, executor); Assert.isTrue(!resolvers.getResolvers().isEmpty(), "No argument resolvers"); - this.resolvers = resolvers; this.validationHelper = (validationHelper != null ? validationHelper : (controller, args) -> {}); this.subscription = subscription; } - /** - * Return the configured argument resolvers. - */ - public HandlerMethodArgumentResolverComposite getResolvers() { - return this.resolvers; - } - - /** * Invoke the method after resolving its argument values in the context of * the given {@link DataFetchingEnvironment}. @@ -149,47 +126,6 @@ public class DataFetcherHandlerMethod extends InvocableHandlerMethodSupport { }); } - /** - * Get the method argument values for the current request, checking the provided - * argument values and falling back to the configured argument resolvers. - *

The resulting array will be passed into {@link #doInvoke}. - */ - private Object[] getMethodArgumentValues( - DataFetchingEnvironment environment, Object... providedArgs) throws Exception { - - MethodParameter[] parameters = getMethodParameters(); - if (ObjectUtils.isEmpty(parameters)) { - return EMPTY_ARGS; - } - - Object[] args = new Object[parameters.length]; - for (int i = 0; i < parameters.length; i++) { - MethodParameter parameter = parameters[i]; - parameter.initParameterNameDiscovery(this.parameterNameDiscoverer); - args[i] = findProvidedArgument(parameter, providedArgs); - if (args[i] != null) { - continue; - } - if (!this.resolvers.supportsParameter(parameter)) { - throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver")); - } - try { - args[i] = this.resolvers.resolveArgument(parameter, environment); - } - catch (Exception ex) { - // Leave stack trace for later, exception may actually be resolved and handled... - if (logger.isDebugEnabled()) { - String exMsg = ex.getMessage(); - if (exMsg != null && !exMsg.contains(parameter.getExecutable().toGenericString())) { - logger.debug(formatArgumentError(parameter, exMsg)); - } - } - throw ex; - } - } - return args; - } - @Nullable private Object validateAndInvoke(Object[] args, DataFetchingEnvironment environment) { this.validationHelper.accept(getBean(), args); diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethodSupport.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethodSupport.java new file mode 100644 index 00000000..10be0587 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherHandlerMethodSupport.java @@ -0,0 +1,106 @@ +/* + * Copyright 2002-2024 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.concurrent.Executor; + +import graphql.schema.DataFetchingEnvironment; + +import org.springframework.core.DefaultParameterNameDiscoverer; +import org.springframework.core.MethodParameter; +import org.springframework.core.ParameterNameDiscoverer; +import org.springframework.graphql.data.method.HandlerMethod; +import org.springframework.graphql.data.method.HandlerMethodArgumentResolverComposite; +import org.springframework.graphql.data.method.InvocableHandlerMethodSupport; +import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; + + +/** + * Extension of {@link InvocableHandlerMethodSupport} for handler methods that + * resolve argument values from a {@link DataFetchingEnvironment}. + * + * @author Rossen Stoyanchev + * @since 1.3 + */ +public class DataFetcherHandlerMethodSupport extends InvocableHandlerMethodSupport { + + private static final Object[] EMPTY_ARGS = new Object[0]; + + protected final HandlerMethodArgumentResolverComposite resolvers; + + private final ParameterNameDiscoverer parameterNameDiscoverer = new DefaultParameterNameDiscoverer(); + + + protected DataFetcherHandlerMethodSupport( + HandlerMethod handlerMethod, HandlerMethodArgumentResolverComposite resolvers, + @Nullable Executor executor) { + + super(handlerMethod, executor); + this.resolvers = resolvers; + } + + + /** + * Return the configured argument resolvers. + */ + public HandlerMethodArgumentResolverComposite getResolvers() { + return this.resolvers; + } + + + /** + * Get the method argument values for the current request, checking the provided + * argument values and falling back to the configured argument resolvers. + */ + protected Object[] getMethodArgumentValues( + DataFetchingEnvironment environment, Object... providedArgs) throws Exception { + + MethodParameter[] parameters = getMethodParameters(); + if (ObjectUtils.isEmpty(parameters)) { + return EMPTY_ARGS; + } + + Object[] args = new Object[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + MethodParameter parameter = parameters[i]; + parameter.initParameterNameDiscovery(this.parameterNameDiscoverer); + args[i] = findProvidedArgument(parameter, providedArgs); + if (args[i] != null) { + continue; + } + if (!this.resolvers.supportsParameter(parameter)) { + throw new IllegalStateException(formatArgumentError(parameter, "No suitable resolver")); + } + try { + args[i] = this.resolvers.resolveArgument(parameter, environment); + } + catch (Exception ex) { + // Leave stack trace for later, exception may actually be resolved and handled... + if (logger.isDebugEnabled()) { + String exMsg = ex.getMessage(); + if (exMsg != null && !exMsg.contains(parameter.getExecutable().toGenericString())) { + logger.debug(formatArgumentError(parameter, exMsg)); + } + } + throw ex; + } + } + return args; + } + +} diff --git a/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherMappingInfo.java b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherMappingInfo.java new file mode 100644 index 00000000..8551a478 --- /dev/null +++ b/spring-graphql/src/main/java/org/springframework/graphql/data/method/annotation/support/DataFetcherMappingInfo.java @@ -0,0 +1,111 @@ +/* + * Copyright 2002-2024 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 graphql.schema.FieldCoordinates; + +import org.springframework.graphql.data.method.HandlerMethod; +import org.springframework.graphql.data.method.annotation.BatchMapping; +import org.springframework.graphql.data.method.annotation.SchemaMapping; +import org.springframework.lang.Nullable; + + +/** + * Mapping information for a controller method to be registered as a + * {@link graphql.schema.DataFetcher}. + * + * @author Rossen Stoyanchev + * @since 1.3 + */ +public final class DataFetcherMappingInfo { + + private final FieldCoordinates coordinates; + + private final boolean batchMapping; + + private final int maxBatchSize; + + private final HandlerMethod handlerMethod; + + + public DataFetcherMappingInfo( + String typeName, String field, boolean batchMapping, int maxBatchSize, + HandlerMethod handlerMethod) { + + this.coordinates = FieldCoordinates.coordinates(typeName, field); + this.batchMapping = batchMapping; + this.maxBatchSize = maxBatchSize; + this.handlerMethod = handlerMethod; + } + + + /** + * The field to bind the controller method to. + */ + public FieldCoordinates getCoordinates() { + return this.coordinates; + } + + /** + * Whether it is an {@link BatchMapping} method or not in which case it is + * an {@link SchemaMapping} method. + */ + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + public boolean isBatchMapping() { + return this.batchMapping; + } + + /** + * A batch size limit to apply for a batch mapping method, or -1 if a limit + * does not apply. + */ + public int getMaxBatchSize() { + return this.maxBatchSize; + } + + /** + * The controller method to use for data fetching. + */ + public HandlerMethod getHandlerMethod() { + return this.handlerMethod; + } + + @Override + public int hashCode() { + return getCoordinates().hashCode() * 31; + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (!(other instanceof DataFetcherMappingInfo otherInfo)) { + return false; + } + return (this.coordinates.equals(otherInfo.coordinates)); + } + + @Override + public String toString() { + return this.coordinates + " -> " + getHandlerMethod(); + } + + + + +} diff --git a/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTests.java b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTests.java index da89da64..41e36692 100644 --- a/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTests.java +++ b/spring-graphql/src/test/java/org/springframework/graphql/execution/SchemaMappingInspectorTests.java @@ -530,7 +530,7 @@ class SchemaMappingInspectorTests { @Test void reportUnmappedField() { - String schema = """ + String schema = """ type Query { allBooks: [Book] } @@ -550,13 +550,13 @@ class SchemaMappingInspectorTests { id: ID } """; - SchemaReport report = inspectSchema(schema, BookController.class); + SchemaReport report = inspectSchema(schema, BookController.class); assertThatReport(report).hasUnmappedFieldCount(1).hasSkippedTypeCount(0); - assertThat(report.toString()) - .contains("GraphQL schema inspection:", "Unmapped fields: {Book=[missing]}", "Unmapped registrations:", - "{Book.fetcher=BookController#fetcher[1 args]", " Query.paginatedBooks=BookController#paginatedBooks[0 args]", - "Query.bookObject=BookController#bookObject[1 args]", "Query.bookById=BookController#bookById[1 args]", - "Skipped types: []"); + assertThat(report.toString()) + .contains("GraphQL schema inspection:", "Unmapped fields: {Book=[missing]}", "Unmapped registrations:", + "Book.fetcher=BookController#fetcher[1 args]", " Query.paginatedBooks=BookController#paginatedBooks[0 args]", + "Query.bookObject=BookController#bookObject[1 args]", "Query.bookById=BookController#bookById[1 args]", + "Skipped types: []"); } }