diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java index 1441bd7bb7..f09686b299 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpoint.java @@ -16,12 +16,15 @@ package org.springframework.boot.actuate.context.properties; +import java.lang.reflect.Constructor; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.core.JsonGenerator; @@ -45,14 +48,19 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.beans.BeanUtils; import org.springframework.beans.BeansException; import org.springframework.boot.actuate.endpoint.Sanitizer; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationPropertiesBean; +import org.springframework.boot.context.properties.ConstructorBinding; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; +import org.springframework.core.KotlinDetector; +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.core.annotation.MergedAnnotations.SearchStrategy; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -302,15 +310,26 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext public List changeProperties(SerializationConfig config, BeanDescription beanDesc, List beanProperties) { List result = new ArrayList<>(); + Constructor bindConstructor = findBindConstructor(beanDesc.getType().getRawClass()); for (BeanPropertyWriter writer : beanProperties) { - boolean readable = isReadable(beanDesc, writer); - if (readable) { + if (isCandidate(beanDesc, writer, bindConstructor)) { result.add(writer); } } return result; } + private boolean isCandidate(BeanDescription beanDesc, BeanPropertyWriter writer, + Constructor bindConstructor) { + if (bindConstructor != null) { + return Arrays.stream(bindConstructor.getParameters()) + .anyMatch((parameter) -> parameter.getName().equals(writer.getName())); + } + else { + return isReadable(beanDesc, writer); + } + } + private boolean isReadable(BeanDescription beanDesc, BeanPropertyWriter writer) { Class parentType = beanDesc.getType().getRawClass(); Class type = writer.getType().getRawClass(); @@ -351,6 +370,34 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext return StringUtils.capitalize(propertyName); } + private Constructor findBindConstructor(Class type) { + boolean classConstructorBinding = MergedAnnotations + .from(type, SearchStrategy.TYPE_HIERARCHY_AND_ENCLOSING_CLASSES) + .isPresent(ConstructorBinding.class); + if (KotlinDetector.isKotlinPresent() && KotlinDetector.isKotlinType(type)) { + Constructor constructor = BeanUtils.findPrimaryConstructor(type); + if (constructor != null) { + return findBindConstructor(classConstructorBinding, constructor); + } + } + return findBindConstructor(classConstructorBinding, type.getDeclaredConstructors()); + } + + private Constructor findBindConstructor(boolean classConstructorBinding, Constructor... candidates) { + List> candidateConstructors = Arrays.stream(candidates) + .filter((constructor) -> constructor.getParameterCount() > 0).collect(Collectors.toList()); + List> flaggedConstructors = candidateConstructors.stream() + .filter((candidate) -> MergedAnnotations.from(candidate).isPresent(ConstructorBinding.class)) + .collect(Collectors.toList()); + if (flaggedConstructors.size() == 1) { + return flaggedConstructors.get(0); + } + if (classConstructorBinding && candidateConstructors.size() == 1) { + return candidateConstructors.get(0); + } + return null; + } + } /** diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointTests.java index 4c1de2f7d9..db9e661e0d 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointTests.java @@ -31,7 +31,9 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ConfigurationPropertiesBeanDescriptor; import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ContextConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConstructorBinding; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; import org.springframework.boot.test.context.assertj.AssertableApplicationContext; import org.springframework.boot.test.context.runner.ApplicationContextRunner; import org.springframework.boot.test.context.runner.ContextConsumer; @@ -40,6 +42,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.env.Environment; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.entry; /** * Tests for {@link ConfigurationPropertiesReportEndpoint}. @@ -55,11 +58,35 @@ class ConfigurationPropertiesReportEndpointTests { .withUserConfiguration(EndpointConfig.class); @Test - void descriptorDetectsRelevantProperties() { + void descriptorWithJavaBeanBindMethodDetectsRelevantProperties() { this.contextRunner.withUserConfiguration(TestPropertiesConfiguration.class).run(assertProperties("test", (properties) -> assertThat(properties).containsOnlyKeys("dbPassword", "myTestProperty", "duration"))); } + @Test + void descriptorWithValueObjectBindMethodDetectsRelevantProperties() { + this.contextRunner.withUserConfiguration(ImmutablePropertiesConfiguration.class).run(assertProperties( + "immutable", + (properties) -> assertThat(properties).containsOnlyKeys("dbPassword", "myTestProperty", "duration"))); + } + + @Test + void descriptorWithValueObjectBindMethodUseDedicatedConstructor() { + this.contextRunner.withUserConfiguration(MultiConstructorPropertiesConfiguration.class).run(assertProperties( + "multiconstructor", (properties) -> assertThat(properties).containsOnly(entry("name", "test")))); + } + + @Test + void descriptorWithValueObjectBindMethodHandleNestedType() { + this.contextRunner.withPropertyValues("immutablenested.nested.name=nested", "immutablenested.nested.counter=42") + .withUserConfiguration(ImmutableNestedPropertiesConfiguration.class) + .run(assertProperties("immutablenested", (properties) -> { + assertThat(properties).containsOnlyKeys("name", "nested"); + Map nested = (Map) properties.get("nested"); + assertThat(nested).containsOnly(entry("name", "nested"), entry("counter", 42)); + })); + } + @Test void descriptorDoesNotIncludePropertyWithNullValue() { this.contextRunner.withUserConfiguration(TestPropertiesConfiguration.class) @@ -236,6 +263,8 @@ class ConfigurationPropertiesReportEndpointTests { private Duration duration = Duration.ofSeconds(10); + private String ignored = "dummy"; + public String getDbPassword() { return this.dbPassword; } @@ -268,6 +297,146 @@ class ConfigurationPropertiesReportEndpointTests { this.duration = duration; } + public String getIgnored() { + return this.ignored; + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(ImmutableProperties.class) + static class ImmutablePropertiesConfiguration { + + } + + @ConfigurationProperties(prefix = "immutable") + @ConstructorBinding + public static class ImmutableProperties { + + private final String dbPassword; + + private final String myTestProperty; + + private final String nullValue; + + private final Duration duration; + + private final String ignored; + + ImmutableProperties(@DefaultValue("123456") String dbPassword, @DefaultValue("654321") String myTestProperty, + String nullValue, @DefaultValue("10s") Duration duration) { + this.dbPassword = dbPassword; + this.myTestProperty = myTestProperty; + this.nullValue = nullValue; + this.duration = duration; + this.ignored = "dummy"; + } + + public String getDbPassword() { + return this.dbPassword; + } + + public String getMyTestProperty() { + return this.myTestProperty; + } + + public String getNullValue() { + return this.nullValue; + } + + public Duration getDuration() { + return this.duration; + } + + public String getIgnored() { + return this.ignored; + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(MultiConstructorProperties.class) + static class MultiConstructorPropertiesConfiguration { + + } + + @ConfigurationProperties(prefix = "multiconstructor") + @ConstructorBinding + public static class MultiConstructorProperties { + + private final String name; + + private final int counter; + + MultiConstructorProperties(String name, int counter) { + this.name = name; + this.counter = counter; + } + + @ConstructorBinding + MultiConstructorProperties(@DefaultValue("test") String name) { + this.name = name; + this.counter = 42; + } + + public String getName() { + return this.name; + } + + public int getCounter() { + return this.counter; + } + + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(ImmutableNestedProperties.class) + static class ImmutableNestedPropertiesConfiguration { + + } + + @ConfigurationProperties("immutablenested") + @ConstructorBinding + public static class ImmutableNestedProperties { + + private final String name; + + private final Nested nested; + + ImmutableNestedProperties(@DefaultValue("parent") String name, Nested nested) { + this.name = name; + this.nested = nested; + } + + public String getName() { + return this.name; + } + + public Nested getNested() { + return this.nested; + } + + public static class Nested { + + private final String name; + + private final int counter; + + Nested(String name, int counter) { + this.name = name; + this.counter = counter; + } + + public String getName() { + return this.name; + } + + public int getCounter() { + return this.counter; + } + + } + } @Configuration(proxyBeanMethods = false)