diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc index cc48291dfb0..dd999046f06 100644 --- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc @@ -881,7 +881,6 @@ The example in the previous section can be rewritten in an immutable fashion as private final List roles; - @ConstructorBinding public Security(String username, String password, @DefaultValue("USER") List roles) { this.username = username; @@ -903,16 +902,16 @@ The example in the previous section can be rewritten in an immutable fashion as In this setup, the `@ImmutableConfigurationProperties` annotation is used to indicate that constructor binding should be used. This means that the binder will expect to find a constructor with the parameters that you wish to have bound. -Nested classes that also require constructor binding (such as `Security` in the example above) should use the `@ConstructorBinding` annotation. +Nested members of a `@ImmutableConfigurationProperties` class (such as `Security` in the example above) will also be bound via their constructor. Default values can be specified using `@DefaultValue` and the same conversion service will be applied to coerce the `String` value to the target type of a missing property. -TIP: You can also use `@ConstructorBinding` on the actual constructor that should be bound. -This is required if you have more than one constructor for your class. - NOTE: To use constructor binding the class must be enabled using `@EnableConfigurationProperties` or configuration property scanning. You cannot use constructor binding with beans that are created by the regular Spring mechanisms (e.g. `@Component` beans, beans created via `@Bean` methods or beans loaded using `@Import`) +TIP: `@ImmutableConfigurationProperties` is actually a meta-annotation composed of `@ConfigurationProperties` and `@ConstructorBinding`. +If you have more than one constructor for your class you can also use `@ConstructorBinding` directly on actual constructor that should be bound. + [[boot-features-external-config-enabling]] diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java index d12fb716b22..872f8743813 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java @@ -23,6 +23,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import javax.lang.model.element.ExecutableElement; +import javax.lang.model.element.NestingKind; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeMirror; @@ -169,13 +170,24 @@ class PropertyDescriptorResolver { } static ConfigurationPropertiesTypeElement of(TypeElement type, MetadataGenerationEnvironment env) { - boolean constructorBoundType = env.hasConstructorBindingAnnotation(type); + boolean constructorBoundType = isConstructorBoundType(type, env); List constructors = ElementFilter.constructorsIn(type.getEnclosedElements()); List boundConstructors = constructors.stream() .filter(env::hasConstructorBindingAnnotation).collect(Collectors.toList()); return new ConfigurationPropertiesTypeElement(type, constructorBoundType, constructors, boundConstructors); } + private static boolean isConstructorBoundType(TypeElement type, MetadataGenerationEnvironment env) { + if (env.hasConstructorBindingAnnotation(type)) { + return true; + } + if (type.getNestingKind() == NestingKind.MEMBER) { + return isConstructorBoundType((TypeElement) type.getEnclosingElement(), env); + } + return false; + + } + } } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/DeducedImmutablePropertiesMetadataGenerationTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/DeducedImmutablePropertiesMetadataGenerationTests.java new file mode 100644 index 00000000000..84bc1f22ae1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/DeducedImmutablePropertiesMetadataGenerationTests.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2019 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.configurationprocessor; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; +import org.springframework.boot.configurationprocessor.metadata.Metadata; +import org.springframework.boot.configurationsample.immutable.DeducedImmutableClassProperties; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Metadata generation tests for immutable properties deduced because they're nested. + * + * @author Phillip Webb + */ +class DeducedImmutablePropertiesMetadataGenerationTests extends AbstractMetadataGenerationTests { + + @Test + void immutableSimpleProperties() { + ConfigurationMetadata metadata = compile(DeducedImmutableClassProperties.class); + assertThat(metadata).has(Metadata.withGroup("test").fromSource(DeducedImmutableClassProperties.class)); + assertThat(metadata).has(Metadata.withProperty("test.nested.name", String.class)); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/immutable/DeducedImmutableClassProperties.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/immutable/DeducedImmutableClassProperties.java new file mode 100644 index 00000000000..e5c6de698f1 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/immutable/DeducedImmutableClassProperties.java @@ -0,0 +1,55 @@ +/* + * Copyright 2012-2019 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.configurationsample.immutable; + +import org.springframework.boot.configurationsample.ConfigurationProperties; +import org.springframework.boot.configurationsample.ConstructorBinding; + +/** + * Inner properties, in immutable format. + * + * @author Phillip Webb + */ +@ConfigurationProperties("test") +@ConstructorBinding +public class DeducedImmutableClassProperties { + + private final Nested nested; + + public DeducedImmutableClassProperties(Nested nested) { + this.nested = nested; + } + + public Nested getNested() { + return this.nested; + } + + public static class Nested { + + private String name; + + public Nested(String name) { + this.name = name; + } + + public String getName() { + return this.name; + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java index 9e0230fe4f7..f35d4921189 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesBean.java @@ -265,13 +265,17 @@ public final class ConfigurationPropertiesBean { VALUE_OBJECT; static BindMethod forClass(Class type) { - if (MergedAnnotations.from(type, SearchStrategy.TYPE_HIERARCHY).isPresent(ConstructorBinding.class) - || findBindConstructor(type) != null) { + if (isConstructorBindingType(type) || findBindConstructor(type) != null) { return VALUE_OBJECT; } return JAVA_BEAN; } + private static boolean isConstructorBindingType(Class type) { + return MergedAnnotations.from(type, SearchStrategy.TYPE_HIERARCHY_AND_ENCLOSING_CLASSES) + .isPresent(ConstructorBinding.class); + } + static Constructor findBindConstructor(Class type) { if (KotlinDetector.isKotlinPresent() && KotlinDetector.isKotlinType(type)) { Constructor constructor = BeanUtils.findPrimaryConstructor(type); diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java index 86e9f46b1ad..30fde52d13f 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/ConfigurationPropertiesTests.java @@ -836,6 +836,19 @@ class ConfigurationPropertiesTests { assertThat(nested.getAge()).isEqualTo(0); } + @Test // gh-18481 + void loadWhenBindingToNestedConstructorPropertiesWithDeducedNestedShouldBind() { + MutablePropertySources sources = this.context.getEnvironment().getPropertySources(); + Map source = new HashMap<>(); + source.put("test.name", "spring"); + source.put("test.nested.age", "5"); + sources.addLast(new MapPropertySource("test", source)); + load(DeducedNestedConstructorPropertiesConfiguration.class); + DeducedNestedConstructorProperties bean = this.context.getBean(DeducedNestedConstructorProperties.class); + assertThat(bean.getName()).isEqualTo("spring"); + assertThat(bean.getNested().getAge()).isEqualTo(5); + } + private AnnotationConfigApplicationContext load(Class configuration, String... inlinedProperties) { return load(new Class[] { configuration }, inlinedProperties); } @@ -2014,4 +2027,46 @@ class ConfigurationPropertiesTests { } + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(DeducedNestedConstructorProperties.class) + static class DeducedNestedConstructorPropertiesConfiguration { + + } + + @ImmutableConfigurationProperties("test") + static class DeducedNestedConstructorProperties { + + private final String name; + + private final Nested nested; + + DeducedNestedConstructorProperties(String name, Nested nested) { + this.name = name; + this.nested = nested; + } + + String getName() { + return this.name; + } + + Nested getNested() { + return this.nested; + } + + static class Nested { + + private final int age; + + Nested(int age) { + this.age = age; + } + + int getAge() { + return this.age; + } + + } + + } + }