diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/configprops.adoc b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/configprops.adoc index a8de612ab6..a60f4c72e0 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/configprops.adoc +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/docs/asciidoc/endpoints/configprops.adoc @@ -6,15 +6,15 @@ The `configprops` endpoint provides information about the application's `@Config [[configprops-retrieving]] -== Retrieving the @ConfigurationProperties Bean +== Retrieving All @ConfigurationProperties Beans -To retrieve the `@ConfigurationProperties` beans, make a `GET` request to `/actuator/configprops`, as shown in the following curl-based example: +To retrieve all of the `@ConfigurationProperties` beans, make a `GET` request to `/actuator/configprops`, as shown in the following curl-based example: -include::{snippets}/configprops/curl-request.adoc[] +include::{snippets}/configprops/all/curl-request.adoc[] The resulting response is similar to the following: -include::{snippets}/configprops/http-response.adoc[] +include::{snippets}/configprops/all/http-response.adoc[] @@ -26,3 +26,26 @@ The following table describes the structure of the response: [cols="2,1,3"] include::{snippets}/configprops/response-fields.adoc[] + + +[[configprops-retrieving-by-prefix]] +== Retrieving @ConfigurationProperties Beans By Prefix + +To retrieve the `@ConfigurationProperties` beans mapped under a certain prefix, make a `GET` request to `/actuator/configprops/{prefix}`, as shown in the following curl-based example: + +include::{snippets}/configprops/prefixed/curl-request.adoc[] + +The resulting response is similar to the following: + +include::{snippets}/configprops/prefixed/http-response.adoc[] + +NOTE: The `{prefix}` does not need to be exact, a more general prefix will return all beans mapped under that prefix stem. + +[[configprops-retrieving-by-prefix-response-structure]] +=== Response Structure + +The response contains details of the application's `@ConfigurationProperties` beans. +The following table describes the structure of the response: + +[cols="2,1,3"] +include::{snippets}/configprops/prefixed/response-fields.adoc[] \ No newline at end of file diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfiguration.java index d9fe6c792f..8d1217a6cc 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/context/properties/ConfigurationPropertiesReportEndpointAutoConfiguration.java @@ -18,7 +18,9 @@ package org.springframework.boot.actuate.autoconfigure.context.properties; import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint; import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint; +import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpointWebExtension; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; @@ -30,6 +32,7 @@ import org.springframework.context.annotation.Configuration; * * @author Phillip Webb * @author Stephane Nicoll + * @author Chris Bono * @since 2.0.0 */ @Configuration(proxyBeanMethods = false) @@ -49,4 +52,12 @@ public class ConfigurationPropertiesReportEndpointAutoConfiguration { return endpoint; } + @Bean + @ConditionalOnMissingBean + @ConditionalOnBean(ConfigurationPropertiesReportEndpoint.class) + public ConfigurationPropertiesReportEndpointWebExtension configurationPropertiesReportEndpointWebExtension( + ConfigurationPropertiesReportEndpoint configurationPropertiesReportEndpoint) { + return new ConfigurationPropertiesReportEndpointWebExtension(configurationPropertiesReportEndpoint); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ConfigurationPropertiesReportEndpointDocumentationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ConfigurationPropertiesReportEndpointDocumentationTests.java index a7059faca8..e8a351ab41 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ConfigurationPropertiesReportEndpointDocumentationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/documentation/ConfigurationPropertiesReportEndpointDocumentationTests.java @@ -36,13 +36,31 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. * {@link ConfigurationPropertiesReportEndpoint}. * * @author Andy Wilkinson + * @author Chris Bono */ class ConfigurationPropertiesReportEndpointDocumentationTests extends MockMvcEndpointDocumentationTests { @Test void configProps() throws Exception { this.mockMvc.perform(get("/actuator/configprops")).andExpect(status().isOk()) - .andDo(MockMvcRestDocumentation.document("configprops", + .andDo(MockMvcRestDocumentation.document("configprops/all", + preprocessResponse(limit("contexts", getApplicationContext().getId(), "beans")), + responseFields(fieldWithPath("contexts").description("Application contexts keyed by id."), + fieldWithPath("contexts.*.beans.*") + .description("`@ConfigurationProperties` beans keyed by bean name."), + fieldWithPath("contexts.*.beans.*.prefix") + .description("Prefix applied to the names of the bean's properties."), + subsectionWithPath("contexts.*.beans.*.properties") + .description("Properties of the bean as name-value pairs."), + subsectionWithPath("contexts.*.beans.*.inputs").description( + "Origin and value of the configuration property used when binding to this bean."), + parentIdField()))); + } + + @Test + void configPropsFilterByPrefix() throws Exception { + this.mockMvc.perform(get("/actuator/configprops/spring.resources")).andExpect(status().isOk()) + .andDo(MockMvcRestDocumentation.document("configprops/prefixed", preprocessResponse(limit("contexts", getApplicationContext().getId(), "beans")), responseFields(fieldWithPath("contexts").description("Application contexts keyed by id."), fieldWithPath("contexts.*.beans.*") 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 57a12e331b..6e83badf13 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 @@ -26,6 +26,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Predicate; import java.util.stream.Collectors; import com.fasterxml.jackson.annotation.JsonInclude.Include; @@ -55,6 +56,7 @@ 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.actuate.endpoint.annotation.Selector; import org.springframework.boot.context.properties.BoundConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationPropertiesBean; @@ -90,6 +92,7 @@ import org.springframework.util.StringUtils; * @author Stephane Nicoll * @author Madhura Bhave * @author Andy Wilkinson + * @author Chris Bono * @since 2.0.0 */ @Endpoint(id = "configprops") @@ -114,15 +117,21 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext @ReadOperation public ApplicationConfigurationProperties configurationProperties() { - return extract(this.context); + return extract(this.context, (bean) -> true); } - private ApplicationConfigurationProperties extract(ApplicationContext context) { + @ReadOperation + public ApplicationConfigurationProperties configurationProperties(@Selector String prefix) { + return extract(this.context, (bean) -> bean.getAnnotation().prefix().startsWith(prefix)); + } + + private ApplicationConfigurationProperties extract(ApplicationContext context, + Predicate beanFilterPredicate) { ObjectMapper mapper = getObjectMapper(); Map contexts = new HashMap<>(); ApplicationContext target = context; while (target != null) { - contexts.put(target.getId(), describeBeans(mapper, target)); + contexts.put(target.getId(), describeBeans(mapper, target, beanFilterPredicate)); target = target.getParent(); } return new ApplicationConfigurationProperties(contexts); @@ -169,10 +178,14 @@ public class ConfigurationPropertiesReportEndpoint implements ApplicationContext mapper.setSerializerFactory(factory); } - private ContextConfigurationProperties describeBeans(ObjectMapper mapper, ApplicationContext context) { + private ContextConfigurationProperties describeBeans(ObjectMapper mapper, ApplicationContext context, + Predicate beanFilterPredicate) { Map beans = ConfigurationPropertiesBean.getAll(context); - Map descriptors = new HashMap<>(); - beans.forEach((beanName, bean) -> descriptors.put(beanName, describeBean(mapper, bean))); + + Map descriptors = beans.values().stream() + .filter(beanFilterPredicate::test) + .collect(Collectors.toMap((bean) -> bean.getName(), (bean) -> describeBean(mapper, bean))); + return new ContextConfigurationProperties(descriptors, (context.getParent() != null) ? context.getParent().getId() : null); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointWebExtension.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointWebExtension.java new file mode 100644 index 0000000000..198db67da6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointWebExtension.java @@ -0,0 +1,51 @@ +/* + * Copyright 2012-2020 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.actuate.context.properties; + +import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ApplicationConfigurationProperties; +import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ContextConfigurationProperties; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.Selector; +import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; +import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension; + +/** + * {@link EndpointWebExtension @EndpointWebExtension} for the + * {@link ConfigurationPropertiesReportEndpoint}. + * + * @author Chris Bono + * @since 2.4 + */ +@EndpointWebExtension(endpoint = ConfigurationPropertiesReportEndpoint.class) +public class ConfigurationPropertiesReportEndpointWebExtension { + + private final ConfigurationPropertiesReportEndpoint delegate; + + public ConfigurationPropertiesReportEndpointWebExtension(ConfigurationPropertiesReportEndpoint delegate) { + this.delegate = delegate; + } + + @ReadOperation + public WebEndpointResponse configurationProperties(@Selector String prefix) { + ApplicationConfigurationProperties configurationProperties = this.delegate.configurationProperties(prefix); + boolean foundMatchingBeans = configurationProperties.getContexts().values().stream() + .map(ContextConfigurationProperties::getBeans).anyMatch((beans) -> !beans.isEmpty()); + return (foundMatchingBeans) ? new WebEndpointResponse<>(configurationProperties, WebEndpointResponse.STATUS_OK) + : new WebEndpointResponse<>(WebEndpointResponse.STATUS_NOT_FOUND); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointFilteringTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointFilteringTests.java new file mode 100644 index 0000000000..7c7728f81c --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointFilteringTests.java @@ -0,0 +1,133 @@ +/* + * 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.actuate.context.properties; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ApplicationConfigurationProperties; +import org.springframework.boot.actuate.context.properties.ConfigurationPropertiesReportEndpoint.ContextConfigurationProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ConfigurationPropertiesReportEndpoint} when filtering by prefix. + * + * @author Chris Bono + */ +class ConfigurationPropertiesReportEndpointFilteringTests { + + @Test + void filterByPrefixSingleMatch() { + ApplicationContextRunner contextRunner = new ApplicationContextRunner().withUserConfiguration(Config.class) + .withPropertyValues("foo.primary.name:foo1", "foo.secondary.name:foo2", "only.bar.name:solo1"); + contextRunner.run((context) -> { + ConfigurationPropertiesReportEndpoint endpoint = context + .getBean(ConfigurationPropertiesReportEndpoint.class); + ApplicationConfigurationProperties applicationProperties = endpoint.configurationProperties("only.bar"); + assertThat(applicationProperties.getContexts()).containsOnlyKeys(context.getId()); + ContextConfigurationProperties contextProperties = applicationProperties.getContexts().get(context.getId()); + assertThat(contextProperties.getBeans().values()).hasSize(1).first().hasFieldOrPropertyWithValue("prefix", + "only.bar"); + }); + } + + @Test + void filterByPrefixMultipleMatches() { + ApplicationContextRunner contextRunner = new ApplicationContextRunner().withUserConfiguration(Config.class) + .withPropertyValues("foo.primary.name:foo1", "foo.secondary.name:foo2", "only.bar.name:solo1"); + contextRunner.run((context) -> { + ConfigurationPropertiesReportEndpoint endpoint = context + .getBean(ConfigurationPropertiesReportEndpoint.class); + ApplicationConfigurationProperties applicationProperties = endpoint.configurationProperties("foo."); + assertThat(applicationProperties.getContexts()).containsOnlyKeys(context.getId()); + ContextConfigurationProperties contextProperties = applicationProperties.getContexts().get(context.getId()); + assertThat(contextProperties.getBeans()).containsOnlyKeys("primaryFoo", "secondaryFoo"); + }); + } + + @Test + void filterByPrefixNoMatches() { + ApplicationContextRunner contextRunner = new ApplicationContextRunner().withUserConfiguration(Config.class) + .withPropertyValues("foo.primary.name:foo1", "foo.secondary.name:foo2", "only.bar.name:solo1"); + contextRunner.run((context) -> { + ConfigurationPropertiesReportEndpoint endpoint = context + .getBean(ConfigurationPropertiesReportEndpoint.class); + ApplicationConfigurationProperties applicationProperties = endpoint.configurationProperties("foo.third"); + assertThat(applicationProperties.getContexts()).containsOnlyKeys(context.getId()); + ContextConfigurationProperties contextProperties = applicationProperties.getContexts().get(context.getId()); + assertThat(contextProperties.getBeans()).isEmpty(); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(Bar.class) + static class Config { + + @Bean + ConfigurationPropertiesReportEndpoint endpoint() { + return new ConfigurationPropertiesReportEndpoint(); + } + + @Bean + @ConfigurationProperties(prefix = "foo.primary") + Foo primaryFoo() { + return new Foo(); + } + + @Bean + @ConfigurationProperties(prefix = "foo.secondary") + Foo secondaryFoo() { + return new Foo(); + } + + } + + public static class Foo { + + private String name = "5150"; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + + @ConfigurationProperties(prefix = "only.bar") + public static class Bar { + + private String name = "123456"; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointWebIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointWebIntegrationTests.java new file mode 100644 index 0000000000..f5b3ae2057 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/context/properties/ConfigurationPropertiesReportEndpointWebIntegrationTests.java @@ -0,0 +1,131 @@ +/* + * Copyright 2012-2020 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.actuate.context.properties; + +import org.junit.jupiter.api.BeforeEach; + +import org.springframework.boot.actuate.endpoint.web.test.WebEndpointTest; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.web.reactive.server.WebTestClient; + +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.hasSize; + +/** + * Integration tests for {@link ConfigurationPropertiesReportEndpoint} exposed by Jersey, + * Spring MVC, and WebFlux. + * + * @author Chris Bono + */ +class ConfigurationPropertiesReportEndpointWebIntegrationTests { + + private WebTestClient client; + + @BeforeEach + void prepareEnvironment(ConfigurableApplicationContext context, WebTestClient client) { + TestPropertyValues.of("com.foo.name=fooz", "com.bar.name=barz").applyTo(context); + this.client = client; + } + + @WebEndpointTest + void noFilters() { + this.client.get().uri("/actuator/configprops").exchange().expectStatus().isOk().expectBody() + .jsonPath("$..beans[*]").value(hasSize(greaterThanOrEqualTo(2))).jsonPath("$..beans['fooDotCom']") + .exists().jsonPath("$..beans['barDotCom']").exists(); + } + + @WebEndpointTest + void filterByExactPrefix() { + this.client.get().uri("/actuator/configprops/com.foo").exchange().expectStatus().isOk().expectBody() + .jsonPath("$..beans[*]").value(hasSize(1)).jsonPath("$..beans['fooDotCom']").exists(); + } + + @WebEndpointTest + void filterByGeneralPrefix() { + this.client.get().uri("/actuator/configprops/com.").exchange().expectStatus().isOk().expectBody() + .jsonPath("$..beans[*]").value(hasSize(2)).jsonPath("$..beans['fooDotCom']").exists() + .jsonPath("$..beans['barDotCom']").exists(); + } + + @WebEndpointTest + void filterByNonExistentPrefix() { + this.client.get().uri("/actuator/configprops/com.zoo").exchange().expectStatus().isNotFound(); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties + static class TestConfiguration { + + @Bean + ConfigurationPropertiesReportEndpoint endpoint() { + return new ConfigurationPropertiesReportEndpoint(); + } + + @Bean + ConfigurationPropertiesReportEndpointWebExtension endpointWebExtension( + ConfigurationPropertiesReportEndpoint endpoint) { + return new ConfigurationPropertiesReportEndpointWebExtension(endpoint); + } + + @Bean + @ConfigurationProperties(prefix = "com.foo") + Foo fooDotCom() { + return new Foo(); + } + + @Bean + @ConfigurationProperties(prefix = "com.bar") + Bar barDotCom() { + return new Bar(); + } + + } + + public static class Foo { + + private String name = "5150"; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + + public static class Bar { + + private String name = "6160"; + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + } + +}