From 7012addfefe190663faada8651495da1f26a31fc Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 8 Sep 2025 20:55:44 -0700 Subject: [PATCH 1/4] Make AbstractHttpServiceRegistrar.findHttpServices(...) protected Make the `AbstractHttpServiceRegistrar.findHttpServices(...)` a protected method so that subclasses can use it to find bean definitions. The method was made `private` in commit 736383e6cb, but is still useful even with the removal of `@HttpClientService`. See gh-35447 --- .../AbstractHttpServiceRegistrar.java | 2 +- .../registry/HttpServiceRegistrarTests.java | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) 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/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 { From 14096f8c443317aa84945fdd34a8b1a21c0636c9 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 8 Sep 2025 20:55:56 -0700 Subject: [PATCH 2/4] Support convention based `@ImportHttpServices` imports Update `@ImportHttpServices` to support convention based imports when neither types or base packages are specified. This update allows `@ImportHttpServices` to work in a similar way to `@ComponentScan` where the annotation alone is enough to find HTTP interface clients. For example: package com.example; @Configuration @ImportHttpServices(group = "test") static class MyConfig { // this config scans for interface clients in `com.example` } See gh-35447 --- .../registry/ImportHttpServiceRegistrar.java | 22 ++++++++++++----- .../service/registry/ImportHttpServices.java | 2 ++ .../ImportHttpServiceRegistrarTests.java | 7 ++++++ .../web/service/registry/TestGroup.java | 6 +++++ .../registry/echo/ScanConventionConfig.java | 24 +++++++++++++++++++ 5 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/echo/ScanConventionConfig.java 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..e87cd67c7457 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 @@ -18,6 +18,8 @@ import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.type.AnnotationMetadata; +import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; /** * Built-in implementation of {@link AbstractHttpServiceRegistrar} that uses @@ -37,23 +39,31 @@ 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"); 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()) }; + } groupRegistry.forGroup(groupName, clientType) - .register(annotation.getClassArray("types")) - .detectInBasePackages(annotation.getStringArray("basePackages")) - .detectInBasePackages(annotation.getClassArray("basePackageClasses")); + .register(types) + .detectInBasePackages(basePackageClasses) + .detectInBasePackages(basePackages); } } 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..10bf92c73259 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 @@ -39,6 +39,8 @@ * *

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. 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..e85dc4a6fef9 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 @@ -33,6 +33,7 @@ import org.springframework.web.service.registry.HttpServiceGroup.ClientType; 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; @@ -83,6 +84,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() { 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 { + +} From 1e714269aab13fc86cfbe43b887821e02fb0328d Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 8 Sep 2025 21:20:51 -0700 Subject: [PATCH 3/4] Add `ImportHttpServices.GroupProvider` support Update `@ImportHttpServices` to allow the group to either be specified using the `group` attribute or a `groupProvider` implementation. Using a `groupProvider` allows the group name to be deduced from the class metadata. For example, users could use a custom annotation to hold the group name. Groups must either be specified using the `group` attribute *or* the `groupProvider` attribute. Attempting to set both will cause an `IllegalStateException` to be thrown. See gh-35447 --- .../registry/ImportHttpServiceRegistrar.java | 94 ++++++++++++++++++- .../service/registry/ImportHttpServices.java | 30 +++++- .../ImportHttpServiceRegistrarTests.java | 68 +++++++++++++- 3 files changed, 185 insertions(+), 7 deletions(-) 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 e87cd67c7457..4babffb87b36 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,10 +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 @@ -33,6 +49,14 @@ */ 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) { @@ -50,7 +74,7 @@ protected void registerHttpServices(GroupRegistry registry, AnnotationMetadata m 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"); @@ -60,10 +84,70 @@ private void processImportAnnotation(MergedAnnotation annotation, GroupRegist basePackages = new String[] { ClassUtils.getPackageName(metadata.getClassName()) }; } - groupRegistry.forGroup(groupName, clientType) - .register(types) - .detectInBasePackages(basePackageClasses) - .detectInBasePackages(basePackages); + 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); + } + + private 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 { + + @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 10bf92c73259..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; /** @@ -47,6 +50,7 @@ * * @author Olga Maciaszek-Sharma * @author Rossen Stoyanchev + * @author Phillip Webb * @since 7.0 * @see Container * @see AbstractHttpServiceRegistrar @@ -74,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 @@ -112,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/ImportHttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/ImportHttpServiceRegistrarTests.java index e85dc4a6fef9..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,11 +27,15 @@ 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; @@ -38,12 +43,14 @@ 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 { @@ -54,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 @@ -112,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); @@ -162,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); + } + } + } From f3b31925ac3c99d771073017de4ed6fd6e5ac2a4 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Mon, 8 Sep 2025 21:26:10 -0700 Subject: [PATCH 4/4] Make `ImportHttpServiceRegistrar` public Make `ImportHttpServiceRegistrar` a public class and also allow subclasses to call `registerHttpServices(...)`. This update allows Spring Boot to reuse `ImportHttpServiceRegistrar` for convention based auto-configuration. See gh-35447 --- .../service/registry/ImportHttpServiceRegistrar.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 4babffb87b36..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 @@ -47,7 +47,7 @@ * @author Olga Maciaszek-Sharma * @since 7.0 */ -class ImportHttpServiceRegistrar extends AbstractHttpServiceRegistrar { +public class ImportHttpServiceRegistrar extends AbstractHttpServiceRegistrar { private @Nullable MetadataReaderFactory metadataReaderFactory; @@ -97,7 +97,15 @@ private ImportHttpServices.GroupProvider getGroupProvider(MergedAnnotation an return (ImportHttpServices.GroupProvider) BeanUtils.instantiateClass(groupProvider); } - private void registerHttpServices(GroupRegistry groupRegistry, + /** + * 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) {