diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java index 6eb81fe86d6d..21972ab97da4 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/AbstractHttpServiceRegistrar.java @@ -211,7 +211,7 @@ private Object getProxyInstance(String groupName, String httpServiceType) { * @param basePackage the names of packages to look under * @return match bean definitions */ - private Stream findHttpServices(String basePackage) { + protected final Stream findHttpServices(String basePackage) { if (this.scanner == null) { Assert.state(this.environment != null, "Environment has not been set"); Assert.state(this.resourceLoader != null, "ResourceLoader has not been set"); diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServiceRegistrar.java b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServiceRegistrar.java index 81f165e59d0f..748dfcfa136c 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServiceRegistrar.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServiceRegistrar.java @@ -16,8 +16,26 @@ package org.springframework.web.service.registry; +import java.util.Arrays; +import java.util.function.Consumer; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.io.ResourceLoader; import org.springframework.core.type.AnnotationMetadata; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.util.function.ThrowingFunction; +import org.springframework.web.service.registry.HttpServiceGroup.ClientType; /** * Built-in implementation of {@link AbstractHttpServiceRegistrar} that uses @@ -29,7 +47,15 @@ * @author Olga Maciaszek-Sharma * @since 7.0 */ -class ImportHttpServiceRegistrar extends AbstractHttpServiceRegistrar { +public class ImportHttpServiceRegistrar extends AbstractHttpServiceRegistrar { + + private @Nullable MetadataReaderFactory metadataReaderFactory; + + @Override + public void setResourceLoader(ResourceLoader resourceLoader) { + super.setResourceLoader(resourceLoader); + this.metadataReaderFactory = new CachingMetadataReaderFactory(resourceLoader); + } @Override protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata metadata) { @@ -37,23 +63,99 @@ protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata m MergedAnnotation groupsAnnot = metadata.getAnnotations().get(ImportHttpServices.Container.class); if (groupsAnnot.isPresent()) { for (MergedAnnotation annot : groupsAnnot.getAnnotationArray("value", ImportHttpServices.class)) { - processImportAnnotation(annot, registry); + processImportAnnotation(annot, registry, metadata); } } metadata.getAnnotations().stream(ImportHttpServices.class) - .forEach(annot -> processImportAnnotation(annot, registry)); + .forEach(annot -> processImportAnnotation(annot, registry, metadata)); } - private void processImportAnnotation(MergedAnnotation annotation, GroupRegistry groupRegistry) { + private void processImportAnnotation(MergedAnnotation annotation, GroupRegistry groupRegistry, + AnnotationMetadata metadata) { - String groupName = annotation.getString("group"); + ImportHttpServices.GroupProvider groupProvider = getGroupProvider(annotation); HttpServiceGroup.ClientType clientType = annotation.getEnum("clientType", HttpServiceGroup.ClientType.class); + Class[] types = annotation.getClassArray("types"); + Class[] basePackageClasses = annotation.getClassArray("basePackageClasses"); + String[] basePackages = annotation.getStringArray("basePackages"); + + if (ObjectUtils.isEmpty(types) && ObjectUtils.isEmpty(basePackages) && ObjectUtils.isEmpty(basePackageClasses)) { + basePackages = new String[] { ClassUtils.getPackageName(metadata.getClassName()) }; + } + + registerHttpServices(groupRegistry, groupProvider, clientType, types, basePackageClasses, basePackages); + } + + private ImportHttpServices.GroupProvider getGroupProvider(MergedAnnotation annotation) { + String group = annotation.getString("group"); + Class groupProvider = annotation.getClass("groupProvider"); + if (groupProvider == ImportHttpServices.GroupProvider.class) { + return new FixedGroupProvider(StringUtils.hasText(group) ? group : HttpServiceGroup.DEFAULT_GROUP_NAME); + } + Assert.state(!StringUtils.hasText(group), "'group' cannot be mixed with 'groupProvider'"); + return (ImportHttpServices.GroupProvider) BeanUtils.instantiateClass(groupProvider); + } + + /** + * Register HTTP service to given registry. + * @param groupRegistry the group registry + * @param groupProvider the group provider to use + * @param clientType the client type to use + * @param types the types to register + * @param basePackages the base packages to register + */ + protected final void registerHttpServices(GroupRegistry groupRegistry, + ImportHttpServices.GroupProvider groupProvider, ClientType clientType, Class[] types, + Class[] basePackageClasses, String[] basePackages) { + + if (groupProvider instanceof FixedGroupProvider fixedGroupProvider) { + String groupName = fixedGroupProvider.group(); + groupRegistry.forGroup(groupName, clientType) + .register(types) + .detectInBasePackages(basePackageClasses) + .detectInBasePackages(basePackages); + } + else { + MetadataReaderFactory metadataReaderFactory = (this.metadataReaderFactory != null) ? + this.metadataReaderFactory : new CachingMetadataReaderFactory(); + + Consumer register = metadata -> { + String group = groupProvider.group(metadata); + if (group != null) { + groupRegistry.forGroup(group, clientType).registerTypeNames(metadata.getClassName()); + } + }; + + Arrays.stream(types) + .map(Class::getName) + .map(ThrowingFunction.of(metadataReaderFactory::getMetadataReader)) + .map(MetadataReader::getAnnotationMetadata) + .forEach(register); + Arrays.stream(basePackageClasses) + .map(Class::getPackageName) + .flatMap(this::findHttpServices) + .map(this::getMetadata) + .forEach(register); + Arrays.stream(basePackages) + .flatMap(this::findHttpServices) + .map(this::getMetadata) + .forEach(register); + } + } + + private AnnotationMetadata getMetadata(BeanDefinition beanDefinition) { + Assert.state(beanDefinition instanceof AnnotatedBeanDefinition, + "AnnotatedBeanDefinition required when using 'groupProvider'"); + return ((AnnotatedBeanDefinition) beanDefinition).getMetadata(); + } + + private static record FixedGroupProvider(String group) implements ImportHttpServices.GroupProvider { - groupRegistry.forGroup(groupName, clientType) - .register(annotation.getClassArray("types")) - .detectInBasePackages(annotation.getStringArray("basePackages")) - .detectInBasePackages(annotation.getClassArray("basePackageClasses")); + @Override + public String group(AnnotationMetadata metadata) { + return this.group; + } } } diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java index 03d47f36c02b..fe99612145a0 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/ImportHttpServices.java @@ -23,8 +23,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +import org.jspecify.annotations.Nullable; + import org.springframework.context.annotation.Import; import org.springframework.core.annotation.AliasFor; +import org.springframework.core.type.AnnotationMetadata; import org.springframework.web.service.annotation.HttpExchange; /** @@ -39,12 +42,15 @@ * *

The HTTP Services for each group can be listed via {@link #types()}, or * detected via {@link #basePackageClasses()} or {@link #basePackages()}. + * If neither types or base packages are defined, detection will occur recursively + * beginning with the package of the class that declares this annotation. * *

An application can autowire HTTP Service proxy beans, or autowire the * {@link HttpServiceProxyRegistry} from which to obtain proxies. * * @author Olga Maciaszek-Sharma * @author Rossen Stoyanchev + * @author Phillip Webb * @since 7.0 * @see Container * @see AbstractHttpServiceRegistrar @@ -72,8 +78,17 @@ * The name of the HTTP Service group. *

If not specified, declared HTTP Services are grouped under the * {@link HttpServiceGroup#DEFAULT_GROUP_NAME}. + * @see #groupProvider() + */ + String group() default ""; + + /** + * Strategy used to provide the name of the HTTP Service group. + *

May be used as an alternative to {@link #group()} when the group name can + * be determined from the type. + * @see #group() */ - String group() default HttpServiceGroup.DEFAULT_GROUP_NAME; + Class groupProvider() default GroupProvider.class; /** * Detect HTTP Services in the packages of the specified classes, looking @@ -110,4 +125,19 @@ ImportHttpServices[] value(); } + /** + * Strategy interface to provide the group name for HTTP Service interface. + */ + @FunctionalInterface + interface GroupProvider { + + /** + * Provide the group name that should be used for the given HTTP Service interface + * metadata. + * @param metadata the HTTP Service interface metadata + * @return the provided group name or {@code null} if the HTTP Service client + * should not be registered + */ + @Nullable String group(AnnotationMetadata metadata); + } } diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceRegistrarTests.java index 35ef88180e42..ba474d83e9bc 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceRegistrarTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceRegistrarTests.java @@ -24,12 +24,15 @@ import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.ConstructorArgumentValues; import org.springframework.beans.factory.support.SimpleBeanDefinitionRegistry; import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.ClassUtils; import org.springframework.web.service.registry.HttpServiceGroup.ClientType; import org.springframework.web.service.registry.echo.EchoA; import org.springframework.web.service.registry.echo.EchoB; @@ -123,6 +126,18 @@ void noRegistrations() { assertBeanDefinitionCount(0); } + @Test + void registrarUsingFindHttpService() { + TestRegistrarUsingFindHttpServices registrar = new TestRegistrarUsingFindHttpServices(); + registrar.setEnvironment(new StandardEnvironment()); + registrar.setResourceLoader(new DefaultResourceLoader()); + registrar.registerBeanDefinitions(null, beanDefRegistry); + + assertRegistryBeanDef( + new TestGroup("EchoA", EchoA.class), + new TestGroup("EchoB", EchoB.class)); + } + @SuppressWarnings("unchecked") private void doRegister(Consumer... registrars) { @@ -192,6 +207,21 @@ protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata m } } + private static class TestRegistrarUsingFindHttpServices extends AbstractHttpServiceRegistrar { + + @Override + protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata importingClassMetadata) { + findHttpServices(EchoA.class.getPackageName()) + .map(definition -> ((AnnotatedBeanDefinition) definition).getMetadata()) + .forEach(metadata -> { + String className = metadata.getClassName(); + String shortName = ClassUtils.getShortName(className); + registry.forGroup(shortName).registerTypeNames(className); + }); + } + + } + private record TestGroup(String name, Set> httpServiceTypes, ClientType clientType) implements HttpServiceGroup { diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServiceRegistrarTests.java index 8959cb2f2de5..73ed44c47218 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServiceRegistrarTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServiceRegistrarTests.java @@ -19,6 +19,7 @@ import java.util.Map; import java.util.function.BiConsumer; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.springframework.aot.test.generate.TestGenerationContext; @@ -26,23 +27,30 @@ import org.springframework.context.annotation.AnnotationConfigApplicationContext; import org.springframework.context.aot.ApplicationContextAotGenerator; import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.test.tools.CompileWithForkedClassLoader; import org.springframework.core.test.tools.Compiled; import org.springframework.core.test.tools.TestCompiler; import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.ClassUtils; import org.springframework.web.service.registry.HttpServiceGroup.ClientType; +import org.springframework.web.service.registry.ImportHttpServices.GroupProvider; import org.springframework.web.service.registry.echo.EchoA; import org.springframework.web.service.registry.echo.EchoB; +import org.springframework.web.service.registry.echo.ScanConventionConfig; import org.springframework.web.service.registry.greeting.GreetingA; import org.springframework.web.service.registry.greeting.GreetingB; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatIllegalStateException; /** * Tests for {@link ImportHttpServiceRegistrar}. * * @author Rossen Stoyanchev * @author Stephane Nicoll + * @author Phillip Webb */ public class ImportHttpServiceRegistrarTests { @@ -53,7 +61,14 @@ public class ImportHttpServiceRegistrarTests { private final TestGroupRegistry groupRegistry = new TestGroupRegistry(); - private final ImportHttpServiceRegistrar registrar = new ImportHttpServiceRegistrar(); + private final ImportHttpServiceRegistrar registrar; + + + ImportHttpServiceRegistrarTests() { + this.registrar = new ImportHttpServiceRegistrar(); + this.registrar.setEnvironment(new StandardEnvironment()); + this.registrar.setResourceLoader(new DefaultResourceLoader()); + } @Test @@ -83,6 +98,12 @@ void basicScan() { TestGroup.ofPackageClasses(GREETING_GROUP, GreetingA.class)); } + @Test + void basicScanByConvention() { + doRegister(ScanConventionConfig.class); + assertGroups(TestGroup.ofPackageNames(ECHO_GROUP, ClientType.UNSPECIFIED, EchoA.class.getPackage().getName())); + } + @Test @CompileWithForkedClassLoader void basicScanWithAot() { @@ -105,6 +126,28 @@ void clientType() { TestGroup.ofListing(GREETING_GROUP, ClientType.WEB_CLIENT, GreetingA.class)); } + @Test + void providedGroupName() { + doRegister(ProvidedGroupNameConfig.class); + assertGroups( + TestGroup.ofListing(ECHO_GROUP, EchoA.class, EchoB.class), + TestGroup.ofListing(GREETING_GROUP, GreetingA.class, GreetingB.class)); + } + + @Test + void providedGroupNameWhenNull() { + doRegister(ProvidedGroupNameConfigWhenNull.class); + assertGroups( + TestGroup.ofListing(ECHO_GROUP, EchoB.class), + TestGroup.ofListing(GREETING_GROUP, GreetingB.class)); + } + + @Test + void providedGroupNameWhenGroupAndGroupProviderThrowsException() { + assertThatIllegalStateException().isThrownBy(() -> doRegister(InvalidProvidedGroupNameConfig.class)) + .withMessage("'group' cannot be mixed with 'groupProvider'"); + } + private void doRegister(Class configClass) { AnnotationMetadata metadata = AnnotationMetadata.introspect(configClass); this.registrar.registerHttpServices(this.groupRegistry, metadata); @@ -155,4 +198,34 @@ static class ScanConfig { @ImportHttpServices(clientType = ClientType.WEB_CLIENT, group = GREETING_GROUP, types = { GreetingA.class }) static class ClientTypeConfig { } + + @ImportHttpServices(groupProvider = FromClassGroupProvider.class) + static class ProvidedGroupNameConfig { + } + + @ImportHttpServices(groupProvider = FromClassGroupProviderWithoutAs.class) + static class ProvidedGroupNameConfigWhenNull { + } + + @ImportHttpServices(group = "test", groupProvider = FromClassGroupProvider.class) + static class InvalidProvidedGroupNameConfig { + } + + static class FromClassGroupProvider implements GroupProvider { + + @Override + public @Nullable String group(AnnotationMetadata metadata) { + String shortName = ClassUtils.getShortName(metadata.getClassName()); + return shortName.substring(0, shortName.length()-1).toLowerCase(); + } + } + + static class FromClassGroupProviderWithoutAs extends FromClassGroupProvider { + + @Override + public @Nullable String group(AnnotationMetadata metadata) { + return (metadata.getClassName().endsWith("A")) ? null : super.group(metadata); + } + } + } diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java index 97f14e463e98..1186cc32d938 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/TestGroup.java @@ -53,4 +53,10 @@ public static TestGroup ofPackageClasses(String name, ClientType clientType, Cla return group; } + public static TestGroup ofPackageNames(String name, ClientType clientType, String... packageNames) { + TestGroup group = new TestGroup(name, clientType); + group.packageNames().addAll(Arrays.asList(packageNames)); + return group; + } + } diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/echo/ScanConventionConfig.java b/spring-web/src/test/java/org/springframework/web/service/registry/echo/ScanConventionConfig.java new file mode 100644 index 000000000000..eb05d53b4160 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/registry/echo/ScanConventionConfig.java @@ -0,0 +1,24 @@ +/* + * Copyright 2002-present 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.web.service.registry.echo; + +import org.springframework.web.service.registry.ImportHttpServices; + +@ImportHttpServices(group = "echo") +public class ScanConventionConfig { + +}