From 88e773ae2467d8bc9a90a866ac548f5dac059f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Mon, 14 Apr 2025 10:04:23 +0200 Subject: [PATCH] Add AOT support for Registry of HTTP Interface Proxies This commit adds AOT support for restoring the state of the HttpServiceProxyRegistry. This generates code for the groupsMetadata as well as for the creation of the client proxies. Closes gh-34750 --- .../AbstractHttpServiceRegistrar.java | 54 +++-- .../web/service/registry/GroupsMetadata.java | 28 ++- .../registry/GroupsMetadataValueDelegate.java | 94 +++++++++ ...viceProxyBeanRegistrationAotProcessor.java | 94 +++++++++ .../resources/META-INF/spring/aot.factories | 6 +- .../AnnotationHttpServiceRegistrarTests.java | 76 ++++++- .../GroupsMetadataValueDelegateTests.java | 199 ++++++++++++++++++ ...iceProxyRegistrationAotProcessorTests.java | 140 ++++++++++++ .../registry/HttpServiceRegistrarTests.java | 2 +- 9 files changed, 656 insertions(+), 37 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/web/service/registry/GroupsMetadataValueDelegate.java create mode 100644 spring-web/src/main/java/org/springframework/web/service/registry/HttpServiceProxyBeanRegistrationAotProcessor.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/GroupsMetadataValueDelegateTests.java create mode 100644 spring-web/src/test/java/org/springframework/web/service/registry/HttpServiceProxyRegistrationAotProcessorTests.java 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();