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 02c0101b6c..1683df7fbf 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 @@ -26,7 +26,7 @@ import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.ConstructorArgumentValues; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.BeanNameGenerator; -import org.springframework.beans.factory.support.GenericBeanDefinition; +import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.context.EnvironmentAware; import org.springframework.context.ResourceLoaderAware; import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; @@ -38,7 +38,6 @@ import org.springframework.core.type.MethodMetadata; import org.springframework.core.type.classreading.MetadataReader; import org.springframework.core.type.filter.AnnotationTypeFilter; import org.springframework.util.Assert; -import org.springframework.util.StringUtils; import org.springframework.web.service.annotation.HttpExchange; /** @@ -69,6 +68,7 @@ import org.springframework.web.service.annotation.HttpExchange; * @author Rossen Stoyanchev * @author Phillip Webb * @author Olga Maciaszek-Sharma + * @author Stephane Nicoll * @since 7.0 * @see ImportHttpServices * @see HttpServiceProxyRegistryFactoryBean @@ -76,6 +76,13 @@ import org.springframework.web.service.annotation.HttpExchange; public abstract class AbstractHttpServiceRegistrar implements ImportBeanDefinitionRegistrar, EnvironmentAware, ResourceLoaderAware, BeanFactoryAware { + /** + * The bean name of the {@link HttpServiceProxyRegistry}. + */ + public static final String HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME = "httpServiceProxyRegistry"; + + static final String HTTP_SERVICE_GROUP_NAME_ATTRIBUTE = "httpServiceGroupName"; + private HttpServiceGroup.ClientType defaultClientType = HttpServiceGroup.ClientType.UNSPECIFIED; private @Nullable Environment environment; @@ -127,33 +134,36 @@ public abstract class AbstractHttpServiceRegistrar implements registerHttpServices(new DefaultGroupRegistry(), metadata); - String proxyRegistryBeanName = StringUtils.uncapitalize(HttpServiceProxyRegistry.class.getSimpleName()); - GenericBeanDefinition proxyRegistryBeanDef; - - if (!beanRegistry.containsBeanDefinition(proxyRegistryBeanName)) { - proxyRegistryBeanDef = new GenericBeanDefinition(); - proxyRegistryBeanDef.setBeanClass(HttpServiceProxyRegistryFactoryBean.class); - ConstructorArgumentValues args = proxyRegistryBeanDef.getConstructorArgumentValues(); - args.addIndexedArgumentValue(0, new GroupsMetadata()); - beanRegistry.registerBeanDefinition(proxyRegistryBeanName, proxyRegistryBeanDef); - } - else { - proxyRegistryBeanDef = (GenericBeanDefinition) beanRegistry.getBeanDefinition(proxyRegistryBeanName); - } + RootBeanDefinition proxyRegistryBeanDef = createOrGetRegistry(beanRegistry); mergeGroups(proxyRegistryBeanDef); this.groupsMetadata.forEachRegistration((groupName, types) -> types.forEach(type -> { - GenericBeanDefinition proxyBeanDef = new GenericBeanDefinition(); + RootBeanDefinition proxyBeanDef = new RootBeanDefinition(); proxyBeanDef.setBeanClassName(type); + proxyBeanDef.setAttribute(HTTP_SERVICE_GROUP_NAME_ATTRIBUTE, groupName); + proxyBeanDef.setInstanceSupplier(() -> getProxyInstance(groupName, type)); String beanName = (groupName + "#" + type); - proxyBeanDef.setInstanceSupplier(() -> getProxyInstance(proxyRegistryBeanName, groupName, type)); if (!beanRegistry.containsBeanDefinition(beanName)) { beanRegistry.registerBeanDefinition(beanName, proxyBeanDef); } })); } + private RootBeanDefinition createOrGetRegistry(BeanDefinitionRegistry beanRegistry) { + if (!beanRegistry.containsBeanDefinition(HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME)) { + RootBeanDefinition proxyRegistryBeanDef = new RootBeanDefinition(); + proxyRegistryBeanDef.setBeanClass(HttpServiceProxyRegistryFactoryBean.class); + ConstructorArgumentValues args = proxyRegistryBeanDef.getConstructorArgumentValues(); + args.addIndexedArgumentValue(0, new GroupsMetadata()); + beanRegistry.registerBeanDefinition(HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME, proxyRegistryBeanDef); + return proxyRegistryBeanDef; + } + else { + return (RootBeanDefinition) beanRegistry.getBeanDefinition(HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME); + } + } + /** * This method is called before any bean definition registrations are made. * Subclasses must implement it to register the HTTP Services for which bean @@ -175,7 +185,7 @@ public abstract class AbstractHttpServiceRegistrar implements return this.scanner; } - private void mergeGroups(GenericBeanDefinition proxyRegistryBeanDef) { + private void mergeGroups(RootBeanDefinition proxyRegistryBeanDef) { ConstructorArgumentValues args = proxyRegistryBeanDef.getConstructorArgumentValues(); ConstructorArgumentValues.ValueHolder valueHolder = args.getArgumentValue(0, GroupsMetadata.class); Assert.state(valueHolder != null, "Expected GroupsMetadata constructor argument at index 0"); @@ -184,12 +194,10 @@ public abstract class AbstractHttpServiceRegistrar implements target.mergeWith(this.groupsMetadata); } - private Object getProxyInstance(String registryBeanName, String groupName, String httpServiceType) { + private Object getProxyInstance(String groupName, String httpServiceType) { Assert.state(this.beanFactory != null, "BeanFactory has not been set"); - HttpServiceProxyRegistry registry = this.beanFactory.getBean(registryBeanName, HttpServiceProxyRegistry.class); - Object proxy = registry.getClient(groupName, GroupsMetadata.loadClass(httpServiceType)); - Assert.notNull(proxy, "No proxy for HTTP Service [" + httpServiceType + "]"); - return proxy; + HttpServiceProxyRegistry registry = this.beanFactory.getBean(HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME, HttpServiceProxyRegistry.class); + return registry.getClient(groupName, GroupsMetadata.loadClass(httpServiceType)); } diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java b/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java index 72b3955376..d3cad9c0ae 100644 --- a/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java +++ b/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadata.java @@ -17,12 +17,14 @@ package org.springframework.web.service.registry; import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import java.util.function.BiConsumer; import java.util.stream.Collectors; +import java.util.stream.Stream; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -37,8 +39,16 @@ import org.springframework.util.ClassUtils; */ final class GroupsMetadata { - private final Map groupMap = new LinkedHashMap<>(); + private final Map groupMap; + public GroupsMetadata() { + this(Collections.emptyList()); + } + + GroupsMetadata(Iterable registrations) { + this.groupMap = new LinkedHashMap<>(); + registrations.forEach(registration -> this.groupMap.put(registration.name(), registration)); + } /** * Create a registration for the given group name, or return an existing @@ -85,6 +95,13 @@ final class GroupsMetadata { } } + /** + * Return the raw {@link DefaultRegistration registrations}. + */ + Stream registrations() { + return this.groupMap.values().stream(); + } + /** * Registration metadata for an {@link HttpServiceGroup}. @@ -102,17 +119,22 @@ final class GroupsMetadata { /** * Default implementation of {@link Registration}. */ - private static class DefaultRegistration implements Registration { + static class DefaultRegistration implements Registration { private final String name; private HttpServiceGroup.ClientType clientType; - private final Set typeNames = new LinkedHashSet<>(); + private final Set typeNames; DefaultRegistration(String name, HttpServiceGroup.ClientType clientType) { + this(name, clientType, new LinkedHashSet<>()); + } + + DefaultRegistration(String name, HttpServiceGroup.ClientType clientType, Set typeNames) { this.name = name; this.clientType = clientType; + this.typeNames = typeNames; } @Override diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadataValueDelegate.java b/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadataValueDelegate.java new file mode 100644 index 0000000000..12c5546da4 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadataValueDelegate.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2025 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; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.stream.Collectors; + +import javax.lang.model.element.Modifier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.generate.MethodReference.ArgumentCodeGenerator; +import org.springframework.aot.generate.ValueCodeGenerator; +import org.springframework.javapoet.CodeBlock; +import org.springframework.web.service.registry.GroupsMetadata.DefaultRegistration; + +/** + * {@link ValueCodeGenerator.Delegate} for {@link GroupsMetadata}. + * + * @author Stephane Nicoll + * @since 7.0 + */ +final class GroupsMetadataValueDelegate implements ValueCodeGenerator.Delegate { + + @Override + public @Nullable CodeBlock generateCode(ValueCodeGenerator valueCodeGenerator, Object value) { + if (value instanceof DefaultRegistration registration) { + return generateRegistrationCode(valueCodeGenerator, registration); + } + if (value instanceof GroupsMetadata groupsMetadata) { + return generateGroupsMetadataCode(valueCodeGenerator, groupsMetadata); + } + return null; + } + + public CodeBlock generateRegistrationCode(ValueCodeGenerator + valueCodeGenerator, DefaultRegistration value) { + CodeBlock.Builder code = CodeBlock.builder(); + code.add("new $T($S, $L, $L)", DefaultRegistration.class, value.name(), + valueCodeGenerator.generateCode(value.clientType()), + !value.httpServiceTypeNames().isEmpty() ? + valueCodeGenerator.generateCode(value.httpServiceTypeNames()) : + CodeBlock.of("new $T()", LinkedHashSet.class)); + return code.build(); + } + + private CodeBlock generateGroupsMetadataCode(ValueCodeGenerator valueCodeGenerator, GroupsMetadata groupsMetadata) { + Collection registrations = groupsMetadata.registrations() + .collect(Collectors.toCollection(ArrayList::new)); + if (valueCodeGenerator.getGeneratedMethods() != null) { + return valueCodeGenerator.getGeneratedMethods().add("getGroupsMetadata", method -> method + .addJavadoc("Create the {@link $T}.", GroupsMetadata.class) + .addModifiers(Modifier.PRIVATE, Modifier.STATIC) + .returns(GroupsMetadata.class) + .addCode(generateGroupsMetadataMethod(valueCodeGenerator, registrations))).toMethodReference().toInvokeCodeBlock(ArgumentCodeGenerator.none()); + } + else { + return CodeBlock.of("new $T($L)", GroupsMetadata.class, valueCodeGenerator.generateCode(registrations)); + } + } + + private CodeBlock generateGroupsMetadataMethod( + ValueCodeGenerator valueCodeGenerator, Collection registrations) { + + CodeBlock.Builder code = CodeBlock.builder(); + String registrationsVariable = "registrations"; + code.addStatement("$T<$T> $L = new $T<>()", List.class, DefaultRegistration.class, + registrationsVariable, ArrayList.class); + registrations.forEach(registration -> + code.addStatement("$L.add($L)", registrationsVariable, + valueCodeGenerator.generateCode(registration)) + ); + code.addStatement("return new $T($L)", GroupsMetadata.class, registrationsVariable); + return code.build(); + } + +} diff --git a/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyBeanRegistrationAotProcessor.java b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyBeanRegistrationAotProcessor.java new file mode 100644 index 0000000000..8df751f666 --- /dev/null +++ b/spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyBeanRegistrationAotProcessor.java @@ -0,0 +1,94 @@ +/* + * Copyright 2002-2025 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; + +import javax.lang.model.element.Modifier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.aot.generate.GeneratedMethod; +import org.springframework.aot.generate.GenerationContext; +import org.springframework.beans.factory.aot.BeanRegistrationAotContribution; +import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; +import org.springframework.beans.factory.aot.BeanRegistrationCode; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragments; +import org.springframework.beans.factory.aot.BeanRegistrationCodeFragmentsDecorator; +import org.springframework.beans.factory.support.InstanceSupplier; +import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.javapoet.ClassName; +import org.springframework.javapoet.CodeBlock; + +import static org.springframework.web.service.registry.AbstractHttpServiceRegistrar.HTTP_SERVICE_GROUP_NAME_ATTRIBUTE; +import static org.springframework.web.service.registry.AbstractHttpServiceRegistrar.HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME; + +/** + * {@link BeanRegistrationAotProcessor} for HTTP service proxy support. + * + * @author Stephane Nicoll + * @see AbstractHttpServiceRegistrar + */ +final class HttpServiceProxyBeanRegistrationAotProcessor implements BeanRegistrationAotProcessor { + + @Override + public @Nullable BeanRegistrationAotContribution processAheadOfTime(RegisteredBean registeredBean) { + Object value = registeredBean.getMergedBeanDefinition().getAttribute(HTTP_SERVICE_GROUP_NAME_ATTRIBUTE); + if (value instanceof String groupName) { + return BeanRegistrationAotContribution.withCustomCodeFragments(codeFragments -> + new HttpServiceProxyRegistrationCodeFragments(codeFragments, groupName, registeredBean.getBeanClass())); + } + return null; + } + + private static class HttpServiceProxyRegistrationCodeFragments extends BeanRegistrationCodeFragmentsDecorator { + + private static final String REGISTERED_BEAN_PARAMETER = "registeredBean"; + + private final String groupName; + + private final Class clientType; + + HttpServiceProxyRegistrationCodeFragments(BeanRegistrationCodeFragments delegate, + String groupName, Class clientType) { + super(delegate); + this.groupName = groupName; + this.clientType = clientType; + } + + @Override + public ClassName getTarget(RegisteredBean registeredBean) { + return ClassName.get(registeredBean.getBeanClass()); + } + + @Override + public CodeBlock generateInstanceSupplierCode(GenerationContext generationContext, BeanRegistrationCode beanRegistrationCode, boolean allowDirectSupplierShortcut) { + GeneratedMethod generatedMethod = beanRegistrationCode.getMethods() + .add("getHttpServiceProxy", method -> { + method.addJavadoc("Create the HTTP service proxy for {@link $T} and group {@code $L}.", + this.clientType, this.groupName); + method.addModifiers(Modifier.PRIVATE, Modifier.STATIC); + method.addParameter(RegisteredBean.class, REGISTERED_BEAN_PARAMETER); + method.returns(Object.class); + method.addStatement("return $L.getBeanFactory().getBean($S, $T.class).getClient($S, $T.class)", + REGISTERED_BEAN_PARAMETER, HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME, + HttpServiceProxyRegistry.class, this.groupName, this.clientType); + }); + return CodeBlock.of("$T.of($L)", InstanceSupplier.class, generatedMethod.toMethodReference().toCodeBlock()); + } + + } + +} diff --git a/spring-web/src/main/resources/META-INF/spring/aot.factories b/spring-web/src/main/resources/META-INF/spring/aot.factories index 983a3ff5a0..8e6fd9fb26 100644 --- a/spring-web/src/main/resources/META-INF/spring/aot.factories +++ b/spring-web/src/main/resources/META-INF/spring/aot.factories @@ -6,4 +6,8 @@ org.springframework.http.converter.json.ProblemDetailRuntimeHints,\ org.springframework.web.util.WebUtilRuntimeHints org.springframework.beans.factory.aot.BeanRegistrationAotProcessor=\ -org.springframework.web.service.annotation.HttpExchangeBeanRegistrationAotProcessor \ No newline at end of file +org.springframework.web.service.annotation.HttpExchangeBeanRegistrationAotProcessor,\ +org.springframework.web.service.registry.HttpServiceProxyBeanRegistrationAotProcessor + +org.springframework.aot.generate.ValueCodeGenerator$Delegate=\ +org.springframework.web.service.registry.GroupsMetadataValueDelegate \ No newline at end of file diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrarTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrarTests.java index fa2663e060..c9b429f7fd 100644 --- a/spring-web/src/test/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrarTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/registry/AnnotationHttpServiceRegistrarTests.java @@ -21,20 +21,32 @@ import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; +import java.util.function.BiConsumer; import org.junit.jupiter.api.Test; +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.aot.ApplicationContextAotGenerator; +import org.springframework.context.support.GenericApplicationContext; +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.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.greeting.GreetingA; +import org.springframework.web.service.registry.greeting.GreetingB; import static org.assertj.core.api.Assertions.assertThat; /** - * Unit tests for {@link AnnotationHttpServiceRegistrar}. + * Tests for {@link AnnotationHttpServiceRegistrar}. + * * @author Rossen Stoyanchev + * @author Stephane Nicoll */ public class AnnotationHttpServiceRegistrarTests { @@ -54,6 +66,19 @@ public class AnnotationHttpServiceRegistrarTests { assertGroups(StubGroup.ofListing(ECHO_GROUP, EchoA.class, EchoB.class)); } + @Test + @CompileWithForkedClassLoader + void basicListingWithAot() { + GenericApplicationContext applicationContext = new AnnotationConfigApplicationContext(); + applicationContext.registerBean(ListingConfig.class); + compile(applicationContext, (initializer, compiled) -> { + GenericApplicationContext freshApplicationContext = toFreshApplicationContext(initializer); + HttpServiceProxyRegistry registry = freshApplicationContext.getBean(HttpServiceProxyRegistry.class); + assertThat(registry.getGroupNames()).containsOnly(ECHO_GROUP); + assertThat(registry.getClientTypesInGroup(ECHO_GROUP)).containsOnly(EchoA.class, EchoB.class); + }); + } + @Test void basicScan() { doRegister(ScanConfig.class); @@ -62,6 +87,20 @@ public class AnnotationHttpServiceRegistrarTests { StubGroup.ofPackageClasses(GREETING_GROUP, GreetingA.class)); } + @Test + @CompileWithForkedClassLoader + void basicScanWithAot() { + GenericApplicationContext applicationContext = new AnnotationConfigApplicationContext(); + applicationContext.registerBean(ScanConfig.class); + compile(applicationContext, (initializer, compiled) -> { + GenericApplicationContext freshApplicationContext = toFreshApplicationContext(initializer); + HttpServiceProxyRegistry registry = freshApplicationContext.getBean(HttpServiceProxyRegistry.class); + assertThat(registry.getGroupNames()).containsOnly(ECHO_GROUP, GREETING_GROUP); + assertThat(registry.getClientTypesInGroup(ECHO_GROUP)).containsOnly(EchoA.class, EchoB.class); + assertThat(registry.getClientTypesInGroup(GREETING_GROUP)).containsOnly(GreetingA.class, GreetingB.class); + }); + } + @Test void clientType() { doRegister(ClientTypeConfig.class); @@ -75,6 +114,25 @@ public class AnnotationHttpServiceRegistrarTests { this.registrar.registerHttpServices(this.groupRegistry, metadata); } + @SuppressWarnings("unchecked") + private void compile(GenericApplicationContext applicationContext, + BiConsumer, Compiled> result) { + ApplicationContextAotGenerator generator = new ApplicationContextAotGenerator(); + TestGenerationContext generationContext = new TestGenerationContext(); + generator.processAheadOfTime(applicationContext, generationContext); + generationContext.writeGeneratedContent(); + TestCompiler.forSystem().with(generationContext).compile(compiled -> + result.accept(compiled.getInstance(ApplicationContextInitializer.class), compiled)); + } + + private GenericApplicationContext toFreshApplicationContext( + ApplicationContextInitializer initializer) { + GenericApplicationContext freshApplicationContext = new GenericApplicationContext(); + initializer.initialize(freshApplicationContext); + freshApplicationContext.refresh(); + return freshApplicationContext; + } + private void assertGroups(StubGroup... expectedGroups) { Map groupMap = this.groupRegistry.groupMap(); assertThat(groupMap.size()).isEqualTo(expectedGroups.length); @@ -88,18 +146,18 @@ public class AnnotationHttpServiceRegistrarTests { } - @ImportHttpServices(group = ECHO_GROUP, types = {EchoA.class, EchoB.class}) - private static class ListingConfig { + @ImportHttpServices(group = ECHO_GROUP, types = { EchoA.class, EchoB.class }) + static class ListingConfig { } - @ImportHttpServices(group = ECHO_GROUP, basePackageClasses = {EchoA.class}) - @ImportHttpServices(group = GREETING_GROUP, basePackageClasses = {GreetingA.class}) - private static class ScanConfig { + @ImportHttpServices(group = ECHO_GROUP, basePackageClasses = { EchoA.class }) + @ImportHttpServices(group = GREETING_GROUP, basePackageClasses = { GreetingA.class }) + static class ScanConfig { } - @ImportHttpServices(clientType = ClientType.WEB_CLIENT, group = ECHO_GROUP, types = {EchoA.class}) - @ImportHttpServices(clientType = ClientType.WEB_CLIENT, group = GREETING_GROUP, types = {GreetingA.class}) - private static class ClientTypeConfig { + @ImportHttpServices(clientType = ClientType.WEB_CLIENT, group = ECHO_GROUP, types = { EchoA.class }) + @ImportHttpServices(clientType = ClientType.WEB_CLIENT, group = GREETING_GROUP, types = { GreetingA.class }) + static class ClientTypeConfig { } diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/GroupsMetadataValueDelegateTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/GroupsMetadataValueDelegateTests.java new file mode 100644 index 0000000000..944964a469 --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/registry/GroupsMetadataValueDelegateTests.java @@ -0,0 +1,199 @@ +/* + * Copyright 2002-2025 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; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +import javax.lang.model.element.Modifier; + +import org.junit.jupiter.api.Test; + +import org.springframework.aot.generate.GeneratedClass; +import org.springframework.aot.generate.ValueCodeGenerator; +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.factory.aot.BeanDefinitionPropertyValueCodeGeneratorDelegates; +import org.springframework.beans.testfixture.beans.factory.aot.DeferredTypeBuilder; +import org.springframework.core.test.tools.CompileWithForkedClassLoader; +import org.springframework.core.test.tools.Compiled; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.javapoet.CodeBlock; +import org.springframework.javapoet.MethodSpec; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.service.registry.GroupsMetadata.DefaultRegistration; +import org.springframework.web.service.registry.GroupsMetadata.Registration; +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.greeting.GreetingA; +import org.springframework.web.service.registry.greeting.GreetingB; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link GroupsMetadataValueDelegate}. + * + * @author Stephane Nicoll + */ +@CompileWithForkedClassLoader +class GroupsMetadataValueDelegateTests { + + @Test + void generateRegistrationWithOnlyName() { + DefaultRegistration registration = new DefaultRegistration("test", ClientType.UNSPECIFIED); + compile(registration, (instance, compiled) -> assertThat(instance) + .isInstanceOfSatisfying(Registration.class, hasRegistration("test", ClientType.UNSPECIFIED))); + } + + @Test + void generateRegistrationWitNoHttpServiceTypeName() { + DefaultRegistration registration = new DefaultRegistration("test", ClientType.REST_CLIENT); + compile(registration, (instance, compiled) -> assertThat(instance) + .isInstanceOfSatisfying(Registration.class, hasRegistration("test", ClientType.REST_CLIENT))); + } + + @Test + void generateRegistrationWitOneHttpServiceTypeName() { + DefaultRegistration registration = new DefaultRegistration("test", ClientType.WEB_CLIENT, + httpServiceTypeNames("com.example.MyClient")); + compile(registration, (instance, compiled) -> assertThat(instance) + .isInstanceOfSatisfying(Registration.class, hasRegistration( + "test", ClientType.WEB_CLIENT, "com.example.MyClient"))); + } + + @Test + void generateRegistrationWitHttpServiceTypeNames() { + DefaultRegistration registration = new DefaultRegistration("test", ClientType.WEB_CLIENT, + httpServiceTypeNames("com.example.MyClient", "com.example.another.TestClient")); + compile(registration, (instance, compiled) -> assertThat(instance) + .isInstanceOfSatisfying(Registration.class, hasRegistration( + "test", ClientType.WEB_CLIENT, "com.example.MyClient", "com.example.another.TestClient"))); + } + + @Test + void generateGroupsMetadataEmpty() { + compile(new GroupsMetadata(), (instance, compiled) -> assertThat(instance) + .isInstanceOfSatisfying(GroupsMetadata.class, metadata -> assertThat(metadata.groups()).isEmpty())); + } + + @Test + void generateGroupsMetadataSingleGroup() { + GroupsMetadata groupsMetadata = new GroupsMetadata(); + groupsMetadata.getOrCreateGroup("test-group", ClientType.REST_CLIENT).httpServiceTypeNames().add(EchoA.class.getName()); + compile(groupsMetadata, (instance, compiled) -> assertThat(instance) + .isInstanceOfSatisfying(GroupsMetadata.class, metadata -> assertThat(metadata.groups()) + .singleElement().satisfies(hasHttpServiceGroup("test-group", ClientType.REST_CLIENT, EchoA.class)))); + } + + @Test + void generateGroupsMetadataMultipleGroupsSimple() { + GroupsMetadata groupsMetadata = new GroupsMetadata(); + groupsMetadata.getOrCreateGroup("test-group", ClientType.UNSPECIFIED).httpServiceTypeNames() + .addAll(List.of(EchoA.class.getName(), EchoB.class.getName())); + groupsMetadata.getOrCreateGroup("another-group", ClientType.WEB_CLIENT).httpServiceTypeNames() + .addAll(List.of(GreetingA.class.getName(), GreetingB.class.getName())); + + Function valueCodeGeneratorFactory = generatedClass -> + ValueCodeGenerator.withDefaults().add(List.of(new GroupsMetadataValueDelegate())); + compile(valueCodeGeneratorFactory, groupsMetadata, (instance, compiled) -> assertThat(instance) + .isInstanceOfSatisfying(GroupsMetadata.class, metadata -> assertThat(metadata.groups()) + .satisfiesOnlyOnce(hasHttpServiceGroup("test-group", ClientType.REST_CLIENT, EchoA.class, EchoB.class)) + .satisfiesOnlyOnce(hasHttpServiceGroup("another-group", ClientType.WEB_CLIENT, GreetingA.class, GreetingB.class)) + .hasSize(2))); + } + + @Test + void generateGroupsMetadataMultipleGroups() { + GroupsMetadata groupsMetadata = new GroupsMetadata(); + groupsMetadata.getOrCreateGroup("test-group", ClientType.UNSPECIFIED).httpServiceTypeNames() + .addAll(List.of(EchoA.class.getName(), EchoB.class.getName())); + groupsMetadata.getOrCreateGroup("another-group", ClientType.WEB_CLIENT).httpServiceTypeNames() + .addAll(List.of(GreetingA.class.getName(), GreetingB.class.getName())); + + compile(groupsMetadata, (instance, compiled) -> assertThat(instance) + .isInstanceOfSatisfying(GroupsMetadata.class, metadata -> assertThat(metadata.groups()) + .satisfiesOnlyOnce(hasHttpServiceGroup("test-group", ClientType.REST_CLIENT, EchoA.class, EchoB.class)) + .satisfiesOnlyOnce(hasHttpServiceGroup("another-group", ClientType.WEB_CLIENT, GreetingA.class, GreetingB.class)) + .hasSize(2))); + } + + private LinkedHashSet httpServiceTypeNames(String... names) { + return new LinkedHashSet<>(Arrays.asList(names)); + } + + private Consumer hasRegistration(String name, ClientType clientType, String... httpServiceTypeNames) { + return registration -> { + assertThat(registration.name()).isEqualTo(name); + assertThat(registration.clientType()).isEqualTo(clientType); + assertThat(registration.httpServiceTypeNames()).isInstanceOf(LinkedHashSet.class) + .containsExactly(httpServiceTypeNames); + }; + } + + private Consumer hasHttpServiceGroup(String name, ClientType clientType, Class... httpServiceTypeNames) { + return group -> { + assertThat(group.name()).isEqualTo(name); + assertThat(group.clientType()).isEqualTo(clientType); + assertThat(group.httpServiceTypes()).containsOnly(httpServiceTypeNames); + }; + } + + + private void compile(Function valueCodeGeneratorFactory, + Object value, BiConsumer result) { + TestGenerationContext generationContext = new TestGenerationContext(); + DeferredTypeBuilder typeBuilder = new DeferredTypeBuilder(); + GeneratedClass generatedClass = generationContext.getGeneratedClasses().addForFeatureComponent("TestCode", GroupsMetadata.class, typeBuilder); + ValueCodeGenerator valueCodeGenerator = valueCodeGeneratorFactory.apply(generatedClass); + CodeBlock generatedCode = valueCodeGenerator.generateCode(value); + typeBuilder.set(type -> { + type.addModifiers(Modifier.PUBLIC); + type.addMethod(MethodSpec.methodBuilder("get").addModifiers(Modifier.PUBLIC, Modifier.STATIC) + .returns(Object.class).addStatement("return $L", generatedCode).build()); + }); + generationContext.writeGeneratedContent(); + TestCompiler.forSystem().with(generationContext).compile(compiled -> + result.accept(getGeneratedCodeReturnValue(compiled, generatedClass), compiled)); + } + + private void compile(Object value, BiConsumer result) { + compile(this::createValueCodeGenerator, value, result); + } + + private ValueCodeGenerator createValueCodeGenerator(GeneratedClass generatedClass) { + return BeanDefinitionPropertyValueCodeGeneratorDelegates.createValueCodeGenerator( + generatedClass.getMethods(), List.of(new GroupsMetadataValueDelegate())); + } + + + private static Object getGeneratedCodeReturnValue(Compiled compiled, GeneratedClass generatedClass) { + try { + Object instance = compiled.getInstance(Object.class, generatedClass.getName().reflectionName()); + Method get = ReflectionUtils.findMethod(instance.getClass(), "get"); + return get.invoke(null); + } + catch (Exception ex) { + throw new RuntimeException("Failed to invoke generated code '%s':".formatted(generatedClass.getName()), ex); + } + } + +} diff --git a/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceProxyRegistrationAotProcessorTests.java b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceProxyRegistrationAotProcessorTests.java new file mode 100644 index 0000000000..432a9747fe --- /dev/null +++ b/spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceProxyRegistrationAotProcessorTests.java @@ -0,0 +1,140 @@ +/* + * Copyright 2002-2025 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; + +import java.util.function.BiConsumer; + +import org.junit.jupiter.api.Test; + +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.factory.aot.AotServices; +import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.RegisteredBean; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.aot.ApplicationContextAotGenerator; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.test.tools.CompileWithForkedClassLoader; +import org.springframework.core.test.tools.Compiled; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.web.service.registry.HttpServiceGroup.ClientType; +import org.springframework.web.service.registry.echo.EchoA; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link HttpServiceProxyBeanRegistrationAotProcessor}. + * + * @author Stephane Nicoll + */ +class HttpServiceProxyRegistrationAotProcessorTests { + + @Test + void httpServiceProxyBeanRegistrationAotProcessorIsRegistered() { + assertThat(AotServices.factories().load(BeanRegistrationAotProcessor.class)) + .anyMatch(HttpServiceProxyBeanRegistrationAotProcessor.class::isInstance); + } + + @Test + void getAotContributionWhenBeanHasNoGroup() { + assertThat(hasContribution(new RootBeanDefinition(EchoA.class))).isFalse(); + } + + @Test + void getAotContributionWhenBeanHasGroup() { + RootBeanDefinition beanDefinition = new RootBeanDefinition(EchoA.class); + beanDefinition.setAttribute(AbstractHttpServiceRegistrar.HTTP_SERVICE_GROUP_NAME_ATTRIBUTE, "echo"); + assertThat(hasContribution(beanDefinition)).isTrue(); + } + + private boolean hasContribution(RootBeanDefinition beanDefinition) { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + beanFactory.registerBeanDefinition("test", beanDefinition); + RegisteredBean registeredBean = RegisteredBean.of(beanFactory, "test"); + return new HttpServiceProxyBeanRegistrationAotProcessor().processAheadOfTime(registeredBean) != null; + } + + @Test + @CompileWithForkedClassLoader + void processHttpServiceProxyWhenSingleClientType() { + GroupsMetadata groupsMetadata = new GroupsMetadata(); + groupsMetadata.getOrCreateGroup("echo", ClientType.UNSPECIFIED) + .httpServiceTypeNames().add(EchoA.class.getName()); + DefaultListableBeanFactory beanFactory = prepareBeanFactory(groupsMetadata); + RootBeanDefinition beanDefinition = new RootBeanDefinition(EchoA.class); + beanDefinition.setAttribute(AbstractHttpServiceRegistrar.HTTP_SERVICE_GROUP_NAME_ATTRIBUTE, "echo"); + beanFactory.registerBeanDefinition("echoA", beanDefinition); + compile(beanFactory, (initializer, compiled) -> { + GenericApplicationContext freshApplicationContext = toFreshApplicationContext(initializer); + HttpServiceProxyRegistry registry = freshApplicationContext.getBean(HttpServiceProxyRegistry.class); + assertThat(registry.getClient("echo", EchoA.class)).isSameAs(freshApplicationContext.getBean(EchoA.class)); + }); + } + + @Test + @CompileWithForkedClassLoader + void processHttpServiceProxyWhenSameClientTypeInDifferentGroups() { + GroupsMetadata groupsMetadata = new GroupsMetadata(); + groupsMetadata.getOrCreateGroup("echo", ClientType.UNSPECIFIED) + .httpServiceTypeNames().add(EchoA.class.getName()); + groupsMetadata.getOrCreateGroup("echo2", ClientType.UNSPECIFIED) + .httpServiceTypeNames().add(EchoA.class.getName()); + DefaultListableBeanFactory beanFactory = prepareBeanFactory(groupsMetadata); + RootBeanDefinition beanDefinition = new RootBeanDefinition(EchoA.class); + beanDefinition.setAttribute(AbstractHttpServiceRegistrar.HTTP_SERVICE_GROUP_NAME_ATTRIBUTE, "echo"); + beanFactory.registerBeanDefinition("echoA", beanDefinition); + RootBeanDefinition beanDefinition2 = new RootBeanDefinition(EchoA.class); + beanDefinition2.setAttribute(AbstractHttpServiceRegistrar.HTTP_SERVICE_GROUP_NAME_ATTRIBUTE, "echo2"); + beanFactory.registerBeanDefinition("echoA2", beanDefinition2); + compile(beanFactory, (initializer, compiled) -> { + GenericApplicationContext freshApplicationContext = toFreshApplicationContext(initializer); + HttpServiceProxyRegistry registry = freshApplicationContext.getBean(HttpServiceProxyRegistry.class); + assertThat(registry.getClient("echo", EchoA.class)).isSameAs(freshApplicationContext.getBean("echoA", EchoA.class)); + assertThat(registry.getClient("echo2", EchoA.class)).isSameAs(freshApplicationContext.getBean("echoA2", EchoA.class)); + }); + } + + private DefaultListableBeanFactory prepareBeanFactory(GroupsMetadata metadata) { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + RootBeanDefinition beanDefinition = new RootBeanDefinition(HttpServiceProxyRegistryFactoryBean.class); + beanDefinition.getConstructorArgumentValues().addIndexedArgumentValue(0, metadata); + beanFactory.registerBeanDefinition(AbstractHttpServiceRegistrar.HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME, beanDefinition); + return beanFactory; + } + + + @SuppressWarnings("unchecked") + private void compile(DefaultListableBeanFactory beanFactory, + BiConsumer, Compiled> result) { + ApplicationContextAotGenerator generator = new ApplicationContextAotGenerator(); + TestGenerationContext generationContext = new TestGenerationContext(); + generator.processAheadOfTime(new GenericApplicationContext(beanFactory), generationContext); + generationContext.writeGeneratedContent(); + TestCompiler.forSystem().with(generationContext).compile(compiled -> + result.accept(compiled.getInstance(ApplicationContextInitializer.class), compiled)); + } + + private GenericApplicationContext toFreshApplicationContext( + ApplicationContextInitializer initializer) { + GenericApplicationContext freshApplicationContext = new GenericApplicationContext(); + initializer.initialize(freshApplicationContext); + freshApplicationContext.refresh(); + return freshApplicationContext; + } + +} 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 fa6c2bcc4f..2b208dd045 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 @@ -149,7 +149,7 @@ public class HttpServiceRegistrarTests { } private Map groupMap() { - BeanDefinition beanDef = this.beanDefRegistry.getBeanDefinition("httpServiceProxyRegistry"); + BeanDefinition beanDef = this.beanDefRegistry.getBeanDefinition(AbstractHttpServiceRegistrar.HTTP_SERVICE_PROXY_REGISTRY_BEAN_NAME); assertThat(beanDef.getBeanClassName()).isEqualTo(HttpServiceProxyRegistryFactoryBean.class.getName()); ConstructorArgumentValues args = beanDef.getConstructorArgumentValues();