diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanDefinitionPostProcessor.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanDefinitionPostProcessor.java new file mode 100644 index 000000000000..8750da5ae916 --- /dev/null +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanDefinitionPostProcessor.java @@ -0,0 +1,99 @@ +/* + * Copyright 2012-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.boot.context.properties; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; +import org.springframework.core.Ordered; +import org.springframework.core.PriorityOrdered; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; +import org.springframework.util.ClassUtils; + +/** + * {@link BeanDefinitionRegistryPostProcessor} that ensures any bean registered + * programmatically (for example, via {@code BeanRegistrar}) that is annotated with + * {@link ConfigurationProperties @ConfigurationProperties} is enriched with the correct + * {@link BindMethodAttribute} and, for constructor-bound types, an + * {@code instanceSupplier}. Enrichment is delegated to + * {@link ConfigurationPropertiesBeanRegistrar#enrichBeanDefinition}. + * + * @author Ujjawal Tyagi + * @since 4.0.0 + */ +final class ConfigurationPropertiesBeanDefinitionPostProcessor + implements BeanDefinitionRegistryPostProcessor, PriorityOrdered { + + static final String BEAN_NAME = ConfigurationPropertiesBeanDefinitionPostProcessor.class.getName(); + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + } + + @Override + public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException { + ConfigurableListableBeanFactory beanFactory = (registry instanceof ConfigurableListableBeanFactory clbf) + ? clbf : null; + @Nullable ClassLoader classLoader = (beanFactory != null) ? beanFactory.getBeanClassLoader() + : ClassUtils.getDefaultClassLoader(); + for (String beanName : registry.getBeanDefinitionNames()) { + BeanDefinition definition = registry.getBeanDefinition(beanName); + if (BindMethodAttribute.get(definition) != null) { + continue; // already enriched (e.g. via @EnableConfigurationProperties or scan) + } + Class type = resolveClass(definition, classLoader); + if (type == null) { + continue; + } + if (!MergedAnnotations.from(type, SearchStrategy.TYPE_HIERARCHY) + .isPresent(ConfigurationProperties.class)) { + continue; + } + if (beanFactory != null) { + ConfigurationPropertiesBeanRegistrar.enrichBeanDefinition(beanName, definition, type, beanFactory); + } + } + } + + private @Nullable Class resolveClass(BeanDefinition definition, @Nullable ClassLoader classLoader) { + if (definition instanceof AbstractBeanDefinition abstractDef && abstractDef.hasBeanClass()) { + return abstractDef.getBeanClass(); + } + String className = definition.getBeanClassName(); + if (className != null) { + try { + return ClassUtils.forName(className, classLoader); + } + catch (Throwable ex) { + // Ignore + } + } + return null; + } + +} diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrar.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrar.java index dd7a030690ef..c115b250db59 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrar.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanRegistrar.java @@ -20,7 +20,9 @@ import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.ListableBeanFactory; import org.springframework.beans.factory.annotation.AnnotatedGenericBeanDefinition; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.BeanDefinitionHolder; +import org.springframework.beans.factory.support.AbstractBeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionReaderUtils; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.boot.context.properties.bind.BindMethod; @@ -119,4 +121,27 @@ static BeanDefinitionHolder applyScopedProxyMode(ScopeMetadata metadata, BeanDef return definition; } + /** + * Enrich an already-registered {@link BeanDefinition} for a + * {@link ConfigurationProperties @ConfigurationProperties} class by stamping the + * {@link BindMethodAttribute} and, for {@link BindMethod#VALUE_OBJECT} types, setting + * an {@code instanceSupplier} that performs constructor-based binding via + * {@link ConstructorBound}. This avoids the need to create a new bean definition and + * is intended for beans registered programmatically (for example, via + * {@code BeanRegistrar}) that bypass the standard + * {@link ConfigurationPropertiesBeanRegistrar} path. + * @param beanName the name of the bean + * @param definition the existing bean definition to enrich + * @param type the configuration properties class + * @param beanFactory the bean factory, used to resolve the binder for VALUE_OBJECT types + */ + static void enrichBeanDefinition(String beanName, BeanDefinition definition, Class type, + BeanFactory beanFactory) { + BindMethod bindMethod = ConfigurationPropertiesBean.deduceBindMethod(type); + BindMethodAttribute.set(definition, bindMethod); + if (bindMethod == BindMethod.VALUE_OBJECT && definition instanceof AbstractBeanDefinition abstractDefinition) { + abstractDefinition.setInstanceSupplier(() -> ConstructorBound.from(beanFactory, beanName, type)); + } + } + } diff --git a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBindingPostProcessor.java b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBindingPostProcessor.java index fcd11a939929..f3c02c01dda6 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBindingPostProcessor.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBindingPostProcessor.java @@ -120,6 +120,13 @@ public static void register(BeanDefinitionRegistry registry) { definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); registry.registerBeanDefinition(BEAN_NAME, definition); } + if (!registry.containsBeanDefinition(ConfigurationPropertiesBeanDefinitionPostProcessor.BEAN_NAME)) { + BeanDefinition definition = BeanDefinitionBuilder + .rootBeanDefinition(ConfigurationPropertiesBeanDefinitionPostProcessor.class) + .getBeanDefinition(); + definition.setRole(BeanDefinition.ROLE_INFRASTRUCTURE); + registry.registerBeanDefinition(ConfigurationPropertiesBeanDefinitionPostProcessor.BEAN_NAME, definition); + } ConfigurationPropertiesBinder.register(registry); } diff --git a/core/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanDefinitionPostProcessorTests.java b/core/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanDefinitionPostProcessorTests.java new file mode 100644 index 000000000000..22f5c46c29af --- /dev/null +++ b/core/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesBeanDefinitionPostProcessorTests.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-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.boot.context.properties; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.context.properties.bind.BindMethod; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.test.context.support.TestPropertySourceUtils; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ConfigurationPropertiesBeanDefinitionPostProcessor}. + * + * @author Ujjawal Tyagi + */ +class ConfigurationPropertiesBeanDefinitionPostProcessorTests { + + @Test + void postProcessorStampsBindMethodAttributeOnJavaBeanProperties() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + BeanDefinition definition = BeanDefinitionBuilder + .genericBeanDefinition(JavaBeanProperties.class) + .getBeanDefinition(); + beanFactory.registerBeanDefinition("javaBean", definition); + + new ConfigurationPropertiesBeanDefinitionPostProcessor().postProcessBeanDefinitionRegistry(beanFactory); + + BeanDefinition processed = beanFactory.getBeanDefinition("javaBean"); + assertThat(processed.getAttribute(BindMethod.class.getName())).isEqualTo(BindMethod.JAVA_BEAN); + } + + @Test + void postProcessorStampsBindMethodAttributeAndInstanceSupplierOnValueObjectProperties() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + BeanDefinition definition = BeanDefinitionBuilder + .genericBeanDefinition(ValueObjectProperties.class) + .getBeanDefinition(); + beanFactory.registerBeanDefinition("valueObject", definition); + + new ConfigurationPropertiesBeanDefinitionPostProcessor().postProcessBeanDefinitionRegistry(beanFactory); + + BeanDefinition processed = beanFactory.getBeanDefinition("valueObject"); + assertThat(processed.getAttribute(BindMethod.class.getName())).isEqualTo(BindMethod.VALUE_OBJECT); + assertThat(((AbstractBeanDefinition) processed).getInstanceSupplier()).isNotNull(); + } + + @Test + void postProcessorSkipsBeansAlreadyHavingBindMethodAttribute() { + DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory(); + BeanDefinition definition = BeanDefinitionBuilder + .genericBeanDefinition(JavaBeanProperties.class) + .getBeanDefinition(); + // Pre-stamp with VALUE_OBJECT to simulate a bean already processed by the registrar + BindMethodAttribute.set(definition, BindMethod.VALUE_OBJECT); + beanFactory.registerBeanDefinition("alreadyProcessed", definition); + + new ConfigurationPropertiesBeanDefinitionPostProcessor().postProcessBeanDefinitionRegistry(beanFactory); + + // Must not override the pre-existing attribute + assertThat(definition.getAttribute(BindMethod.class.getName())).isEqualTo(BindMethod.VALUE_OBJECT); + } + + @Test + void endToEndJavaBeanPropertiesBoundWhenRegisteredProgrammatically() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + TestPropertySourceUtils.addInlinedPropertiesToEnvironment(context, "test.java-prop=hello"); + ConfigurationPropertiesBindingPostProcessor.register(context); + context.registerBeanDefinition("javaBean", + BeanDefinitionBuilder.genericBeanDefinition(JavaBeanProperties.class).getBeanDefinition()); + context.refresh(); + + JavaBeanProperties bean = context.getBean(JavaBeanProperties.class); + assertThat(bean.getJavaProp()).isEqualTo("hello"); + } + } + + @Test + void endToEndValueObjectPropertiesBoundWhenRegisteredProgrammatically() { + try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { + TestPropertySourceUtils.addInlinedPropertiesToEnvironment(context, "test.value-prop=world"); + ConfigurationPropertiesBindingPostProcessor.register(context); + context.registerBeanDefinition("valueObject", + BeanDefinitionBuilder.genericBeanDefinition(ValueObjectProperties.class).getBeanDefinition()); + context.refresh(); + + ValueObjectProperties bean = context.getBean(ValueObjectProperties.class); + assertThat(bean.getValueProp()).isEqualTo("world"); + } + } + + @ConfigurationProperties("test") + static class JavaBeanProperties { + + private String javaProp = ""; + + public String getJavaProp() { + return this.javaProp; + } + + public void setJavaProp(String javaProp) { + this.javaProp = javaProp; + } + + } + + @ConfigurationProperties("test") + static class ValueObjectProperties { + + private final String valueProp; + + ValueObjectProperties(String valueProp) { + this.valueProp = valueProp; + } + + public String getValueProp() { + return this.valueProp; + } + + } + +} diff --git a/core/spring-boot/src/test/kotlin/org/springframework/boot/context/properties/KotlinConfigurationPropertiesTests.kt b/core/spring-boot/src/test/kotlin/org/springframework/boot/context/properties/KotlinConfigurationPropertiesTests.kt index 199e730a3684..116933f3cb7d 100644 --- a/core/spring-boot/src/test/kotlin/org/springframework/boot/context/properties/KotlinConfigurationPropertiesTests.kt +++ b/core/spring-boot/src/test/kotlin/org/springframework/boot/context/properties/KotlinConfigurationPropertiesTests.kt @@ -18,6 +18,7 @@ package org.springframework.boot.context.properties import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test +import org.springframework.beans.factory.support.BeanDefinitionBuilder import org.springframework.beans.factory.support.BeanDefinitionRegistry import org.springframework.beans.factory.support.RootBeanDefinition import org.springframework.context.annotation.AnnotationConfigApplicationContext @@ -136,4 +137,22 @@ class KotlinConfigurationPropertiesTests { var bar: String = "" } + @Test + fun `programmatically registered Kotlin data class configuration properties can be bound`() { + AnnotationConfigApplicationContext().use { context -> + ConfigurationPropertiesBindingPostProcessor.register(context) + TestPropertySourceUtils.addInlinedPropertiesToEnvironment(context, "my-prefix.my-property=hello") + val beanDefinition = BeanDefinitionBuilder.genericBeanDefinition(MyProperties::class.java).getBeanDefinition() + context.registerBeanDefinition("myProperties", beanDefinition) + context.refresh() + val myProperties = context.getBean(MyProperties::class.java) + assertThat(myProperties.myProperty).isEqualTo("hello") + } + } + + @ConfigurationProperties("my-prefix") + data class MyProperties( + val myProperty: String = "" + ) + }