diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java index a0b487a2eccc..80ecda45725b 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/WebEndpointAutoConfiguration.java @@ -36,6 +36,9 @@ import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; import org.springframework.boot.actuate.endpoint.web.PathMapper; import org.springframework.boot.actuate.endpoint.web.WebEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointDiscoverer; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.web.annotation.ExposableControllerEndpoint; import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -100,6 +103,16 @@ public WebEndpointDiscoverer webEndpointDiscoverer( filters.getIfAvailable(Collections::emptyList)); } + @Bean + @ConditionalOnMissingBean(ControllerEndpointsSupplier.class) + public ControllerEndpointDiscoverer controllerEndpointDiscoverer( + PathMapper webEndpointPathMapper, + ObjectProvider> invokerAdvisors, + ObjectProvider>> filters) { + return new ControllerEndpointDiscoverer(this.applicationContext, + webEndpointPathMapper, filters.getIfAvailable(Collections::emptyList)); + } + @Bean @ConditionalOnMissingBean public PathMappedEndpoints pathMappedEndpoints( @@ -117,4 +130,12 @@ public ExposeExcludePropertyEndpointFilter webIncludeExclu expose, exclude, "info", "health"); } + @Bean + public ExposeExcludePropertyEndpointFilter controllerIncludeExcludePropertyEndpointFilter() { + Set expose = this.properties.getExpose(); + Set exclude = this.properties.getExclude(); + return new ExposeExcludePropertyEndpointFilter<>( + ExposableControllerEndpoint.class, expose, exclude); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java index 917d7eb9a6bb..d83ed9492d8c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/reactive/WebFluxEndpointManagementContextConfiguration.java @@ -22,6 +22,8 @@ import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.WebEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.web.reactive.ControllerEndpointHandlerMapping; import org.springframework.boot.actuate.endpoint.web.reactive.WebFluxEndpointHandlerMapping; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -61,4 +63,17 @@ public WebFluxEndpointHandlerMapping webEndpointReactiveHandlerMapping( corsProperties.toCorsConfiguration()); } + @Bean + @ConditionalOnMissingBean + public ControllerEndpointHandlerMapping controllerEndpointHandlerMapping( + ControllerEndpointsSupplier controllerEndpointsSupplier, + EndpointMediaTypes endpointMediaTypes, CorsEndpointProperties corsProperties, + WebEndpointProperties webEndpointProperties) { + EndpointMapping endpointMapping = new EndpointMapping( + webEndpointProperties.getBasePath()); + return new ControllerEndpointHandlerMapping(endpointMapping, + controllerEndpointsSupplier.getEndpoints(), + corsProperties.toCorsConfiguration()); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java index f356e26ffe9a..c7e74252c51a 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/endpoint/web/servlet/WebMvcEndpointManagementContextConfiguration.java @@ -22,6 +22,8 @@ import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.WebEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.web.servlet.ControllerEndpointHandlerMapping; import org.springframework.boot.actuate.endpoint.web.servlet.WebMvcEndpointHandlerMapping; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -60,4 +62,17 @@ public WebMvcEndpointHandlerMapping webEndpointServletHandlerMapping( corsProperties.toCorsConfiguration()); } + @Bean + @ConditionalOnMissingBean + public ControllerEndpointHandlerMapping controllerEndpointHandlerMapping( + ControllerEndpointsSupplier controllerEndpointsSupplier, + CorsEndpointProperties corsProperties, + WebEndpointProperties webEndpointProperties) { + EndpointMapping endpointMapping = new EndpointMapping( + webEndpointProperties.getBasePath()); + return new ControllerEndpointHandlerMapping(endpointMapping, + controllerEndpointsSupplier.getEndpoints(), + corsProperties.toCorsConfiguration()); + } + } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/ControllerEndpointWebFluxIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/ControllerEndpointWebFluxIntegrationTests.java new file mode 100644 index 000000000000..8f5e3411aacc --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/ControllerEndpointWebFluxIntegrationTests.java @@ -0,0 +1,94 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.autoconfigure.integrationtest; + +import org.junit.After; +import org.junit.Test; + +import org.springframework.boot.actuate.autoconfigure.audit.AuditAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.beans.BeansEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.reactive.ReactiveManagementContextAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; +import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.boot.web.reactive.context.AnnotationConfigReactiveWebApplicationContext; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.test.context.TestSecurityContextHolder; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Integration tests for the Actuator's WebFlux {@link ControllerEndpoint controller + * endpoints}. + * + * @author Phillip Webb + */ +public class ControllerEndpointWebFluxIntegrationTests { + + private AnnotationConfigReactiveWebApplicationContext context; + + @After + public void close() { + TestSecurityContextHolder.clearContext(); + this.context.close(); + } + + @Test + public void endpointsCanBeAccessed() throws Exception { + TestSecurityContextHolder.getContext().setAuthentication( + new TestingAuthenticationToken("user", "N/A", "ROLE_ACTUATOR")); + this.context = new AnnotationConfigReactiveWebApplicationContext(); + this.context.register(DefaultConfiguration.class, ExampleController.class); + TestPropertyValues.of("management.endpoints.web.expose=*").applyTo(this.context); + this.context.refresh(); + WebTestClient webClient = WebTestClient.bindToApplicationContext(this.context) + .build(); + webClient.get().uri("/actuator/example").exchange().expectStatus().isOk(); + } + + @ImportAutoConfiguration({ JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, EndpointAutoConfiguration.class, + WebEndpointAutoConfiguration.class, + ReactiveManagementContextAutoConfiguration.class, + AuditAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class, + WebFluxAutoConfiguration.class, ManagementContextAutoConfiguration.class, + AuditAutoConfiguration.class, DispatcherServletAutoConfiguration.class, + BeansEndpointAutoConfiguration.class }) + static class DefaultConfiguration { + + } + + @RestControllerEndpoint(id = "example") + static class ExampleController { + + @GetMapping("/") + public String example() { + return "Example"; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/ControllerEndpointWebMvcIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/ControllerEndpointWebMvcIntegrationTests.java new file mode 100644 index 000000000000..4ed7d8305892 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/ControllerEndpointWebMvcIntegrationTests.java @@ -0,0 +1,134 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.autoconfigure.integrationtest; + +import org.junit.After; +import org.junit.Test; + +import org.springframework.boot.actuate.autoconfigure.audit.AuditAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.beans.BeansEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; +import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.test.context.TestSecurityContextHolder; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.test.web.servlet.setup.MockMvcConfigurer; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Integration tests for the Actuator's MVC {@link ControllerEndpoint controller + * endpoints}. + * + * @author Phillip Webb + * @author Andy Wilkinson + */ +public class ControllerEndpointWebMvcIntegrationTests { + + private AnnotationConfigWebApplicationContext context; + + @After + public void close() { + TestSecurityContextHolder.clearContext(); + this.context.close(); + } + + @Test + public void endpointsAreSecureByDefault() throws Exception { + this.context = new AnnotationConfigWebApplicationContext(); + this.context.register(SecureConfiguration.class, ExampleController.class); + MockMvc mockMvc = createSecureMockMvc(); + mockMvc.perform(get("/actuator/example").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + + @Test + public void endpointsCanBeAccessed() throws Exception { + TestSecurityContextHolder.getContext().setAuthentication( + new TestingAuthenticationToken("user", "N/A", "ROLE_ACTUATOR")); + this.context = new AnnotationConfigWebApplicationContext(); + this.context.register(SecureConfiguration.class, ExampleController.class); + TestPropertyValues.of("management.endpoints.web.base-path:/management", + "management.endpoints.web.expose=*").applyTo(this.context); + MockMvc mockMvc = createSecureMockMvc(); + mockMvc.perform(get("/management/example")).andExpect(status().isOk()); + } + + private MockMvc createSecureMockMvc() { + return doCreateMockMvc(springSecurity()); + } + + private MockMvc doCreateMockMvc(MockMvcConfigurer... configurers) { + this.context.setServletContext(new MockServletContext()); + this.context.refresh(); + DefaultMockMvcBuilder builder = MockMvcBuilders.webAppContextSetup(this.context); + for (MockMvcConfigurer configurer : configurers) { + builder.apply(configurer); + } + return builder.build(); + } + + @ImportAutoConfiguration({ JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, EndpointAutoConfiguration.class, + WebEndpointAutoConfiguration.class, + ServletManagementContextAutoConfiguration.class, AuditAutoConfiguration.class, + PropertyPlaceholderAutoConfiguration.class, WebMvcAutoConfiguration.class, + ManagementContextAutoConfiguration.class, AuditAutoConfiguration.class, + DispatcherServletAutoConfiguration.class, + BeansEndpointAutoConfiguration.class }) + static class DefaultConfiguration { + + } + + @Import(DefaultConfiguration.class) + @ImportAutoConfiguration({ SecurityAutoConfiguration.class }) + static class SecureConfiguration { + + } + + @RestControllerEndpoint(id = "example") + static class ExampleController { + + @GetMapping("/") + public String example() { + return "Example"; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointExposureIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointExposureIntegrationTests.java index 858dc44bf757..f5176f4d7ab0 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointExposureIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/WebMvcEndpointExposureIntegrationTests.java @@ -22,6 +22,7 @@ import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; import org.springframework.boot.actuate.autoconfigure.web.servlet.ServletManagementContextAutoConfiguration; +import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; @@ -34,6 +35,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.request; @@ -59,7 +61,8 @@ public class WebMvcEndpointExposureIntegrationTests { ManagementContextAutoConfiguration.class, ServletManagementContextAutoConfiguration.class)) .withConfiguration( - AutoConfigurations.of(EndpointAutoConfigurationClasses.ALL)); + AutoConfigurations.of(EndpointAutoConfigurationClasses.ALL)) + .withUserConfiguration(CustomMvcEndpoint.class); @Test public void webEndpointsAreDisabledByDefault() { @@ -68,6 +71,7 @@ public void webEndpointsAreDisabledByDefault() { assertThat(isExposed(mvc, HttpMethod.GET, "beans")).isFalse(); assertThat(isExposed(mvc, HttpMethod.GET, "conditions")).isFalse(); assertThat(isExposed(mvc, HttpMethod.GET, "configprops")).isFalse(); + assertThat(isExposed(mvc, HttpMethod.GET, "custommvc")).isFalse(); assertThat(isExposed(mvc, HttpMethod.GET, "env")).isFalse(); assertThat(isExposed(mvc, HttpMethod.GET, "health")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "info")).isTrue(); @@ -87,6 +91,7 @@ public void webEndpointsCanBeExposed() { assertThat(isExposed(mvc, HttpMethod.GET, "beans")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "conditions")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "configprops")).isTrue(); + assertThat(isExposed(mvc, HttpMethod.GET, "custommvc")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "env")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "health")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "info")).isTrue(); @@ -106,6 +111,7 @@ public void singleWebEndpointCanBeExposed() { assertThat(isExposed(mvc, HttpMethod.GET, "beans")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "conditions")).isFalse(); assertThat(isExposed(mvc, HttpMethod.GET, "configprops")).isFalse(); + assertThat(isExposed(mvc, HttpMethod.GET, "custommvc")).isFalse(); assertThat(isExposed(mvc, HttpMethod.GET, "env")).isFalse(); assertThat(isExposed(mvc, HttpMethod.GET, "health")).isFalse(); assertThat(isExposed(mvc, HttpMethod.GET, "info")).isFalse(); @@ -126,6 +132,7 @@ public void singleWebEndpointCanBeExcluded() { assertThat(isExposed(mvc, HttpMethod.GET, "beans")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "conditions")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "configprops")).isTrue(); + assertThat(isExposed(mvc, HttpMethod.GET, "custommvc")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "env")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "health")).isTrue(); assertThat(isExposed(mvc, HttpMethod.GET, "info")).isTrue(); @@ -151,4 +158,14 @@ private boolean isExposed(MockMvc mockMvc, HttpMethod method, String path) .format("Unexpected %s HTTP status for " + "endpoint %s", status, path)); } + @RestControllerEndpoint(id = "custommvc") + static class CustomMvcEndpoint { + + @GetMapping("/") + public String main() { + return "test"; + } + + } + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/AbstractDiscoveredEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/AbstractDiscoveredEndpoint.java index b1e3036ec077..d3ad93361470 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/AbstractDiscoveredEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/AbstractDiscoveredEndpoint.java @@ -37,19 +37,29 @@ public abstract class AbstractDiscoveredEndpoint private final EndpointDiscoverer discoverer; + private final Object endpointBean; + /** * Create a mew {@link AbstractDiscoveredEndpoint} instance. * @param discoverer the discoverer that discovered the endpoint + * @param endpointBean the primary source bean * @param id the ID of the endpoint * @param enabledByDefault if the endpoint is enabled by default * @param operations the endpoint operations */ - public AbstractDiscoveredEndpoint(EndpointDiscoverer discoverer, String id, - boolean enabledByDefault, Collection operations) { + public AbstractDiscoveredEndpoint(EndpointDiscoverer discoverer, + Object endpointBean, String id, boolean enabledByDefault, + Collection operations) { super(id, enabledByDefault, operations); Assert.notNull(discoverer, "Discoverer must not be null"); Assert.notNull(discoverer, "EndpointBean must not be null"); this.discoverer = discoverer; + this.endpointBean = endpointBean; + } + + @Override + public Object getEndpointBean() { + return this.endpointBean; } @Override @@ -59,8 +69,9 @@ public boolean wasDiscoveredBy(Class> discove @Override public String toString() { - ToStringCreator creator = new ToStringCreator(this).append("discoverer", - this.discoverer.getClass().getName()); + ToStringCreator creator = new ToStringCreator(this) + .append("discoverer", this.discoverer.getClass().getName()) + .append("endpointBean", this.endpointBean.getClass().getName()); appendFields(creator); return creator.toString(); } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/DiscoveredEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/DiscoveredEndpoint.java index 2f7b09e2d7cd..673fb898bf82 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/DiscoveredEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/DiscoveredEndpoint.java @@ -35,4 +35,10 @@ public interface DiscoveredEndpoint extends ExposableEndpoi */ boolean wasDiscoveredBy(Class> discoverer); + /** + * Return the source bean that was used to construct the {@link DiscoveredEndpoint}. + * @return the source endpoint bean + */ + Object getEndpointBean(); + } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java index 9897963cfa00..94d08d5451be 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscoverer.java @@ -209,7 +209,8 @@ private E convertToEndpoint(EndpointBean endpointBean) { List operations = indexed.values().stream().map(this::getLast) .filter(Objects::nonNull).collect(Collectors.collectingAndThen( Collectors.toList(), Collections::unmodifiableList)); - return createEndpoint(id, endpointBean.isEnabledByDefault(), operations); + return createEndpoint(endpointBean.getBean(), id, + endpointBean.isEnabledByDefault(), operations); } private void addOperations(MultiValueMap indexed, String id, @@ -265,7 +266,18 @@ protected boolean isExtensionExposed(Object extensionBean) { private boolean isEndpointExposed(EndpointBean endpointBean) { return isFilterMatch(endpointBean.getFilter(), endpointBean) - && !isEndpointFiltered(endpointBean); + && !isEndpointFiltered(endpointBean) + && isEndpointExposed(endpointBean.getBean()); + } + + /** + * Determine if an endpoint bean should be exposed. Subclasses can override this + * method to provide additional logic. + * @param extensionBean the extension bean + * @return {@code true} if the extension is exposed + */ + protected boolean isEndpointExposed(Object extensionBean) { + return true; } private boolean isEndpointFiltered(EndpointBean endpointBean) { @@ -320,7 +332,7 @@ private boolean isFilterMatch(EndpointFilter filter, E endpoint) { private E getFilterEndpoint(EndpointBean endpointBean) { E endpoint = this.filterEndpoints.get(endpointBean); if (endpoint == null) { - endpoint = createEndpoint(endpointBean.getId(), + endpoint = createEndpoint(endpointBean.getBean(), endpointBean.getId(), endpointBean.isEnabledByDefault(), Collections.emptySet()); this.filterEndpoints.put(endpointBean, endpoint); } @@ -335,13 +347,14 @@ protected Class getEndpointType() { /** * Factory method called to create the {@link ExposableEndpoint endpoint}. + * @param endpointBean the source endpoint bean * @param id the ID of the endpoint * @param enabledByDefault if the endpoint is enabled by default * @param operations the endpoint operations * @return a created endpoint (a {@link DiscoveredEndpoint} is recommended) */ - protected abstract E createEndpoint(String id, boolean enabledByDefault, - Collection operations); + protected abstract E createEndpoint(Object endpointBean, String id, + boolean enabledByDefault, Collection operations); /** * Factory method to create an {@link Operation endpoint operation}. diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/DiscoveredJmxEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/DiscoveredJmxEndpoint.java index 086985c8f3e8..7b7b9fce09c0 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/DiscoveredJmxEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/DiscoveredJmxEndpoint.java @@ -31,9 +31,9 @@ class DiscoveredJmxEndpoint extends AbstractDiscoveredEndpoint implements ExposableJmxEndpoint { - DiscoveredJmxEndpoint(EndpointDiscoverer discoverer, String id, - boolean enabledByDefault, Collection operations) { - super(discoverer, id, enabledByDefault, operations); + DiscoveredJmxEndpoint(EndpointDiscoverer discoverer, Object endpointBean, + String id, boolean enabledByDefault, Collection operations) { + super(discoverer, endpointBean, id, enabledByDefault, operations); } } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscoverer.java index 4bc61741a081..264afdfcf529 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscoverer.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/jmx/annotation/JmxEndpointDiscoverer.java @@ -54,9 +54,10 @@ public JmxEndpointDiscoverer(ApplicationContext applicationContext, } @Override - protected ExposableJmxEndpoint createEndpoint(String id, boolean enabledByDefault, - Collection operations) { - return new DiscoveredJmxEndpoint(this, id, enabledByDefault, operations); + protected ExposableJmxEndpoint createEndpoint(Object endpointBean, String id, + boolean enabledByDefault, Collection operations) { + return new DiscoveredJmxEndpoint(this, endpointBean, id, enabledByDefault, + operations); } @Override diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpoint.java new file mode 100644 index 000000000000..e9342307af44 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpoint.java @@ -0,0 +1,72 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.FilteredEndpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; +import org.springframework.core.annotation.AliasFor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; + +/** + * Identifies a type as being a rest endpoint that is only exposed over Spring MVC or + * Spring WebFlux. Mapped methods must be annotated with {@link GetMapping @GetMapping}, + * {@link PostMapping @PostMapping}, {@link DeleteMapping @DeleteMapping}, etc annotations + * rather than {@link ReadOperation @ReadOperation}, + * {@link WriteOperation @WriteOperation}, {@link DeleteOperation @DeleteOperation}. + *

+ * This annotation can be used when deeper Spring integration is required, but at the + * expense of portability. Most users should prefer the {@link Endpoint @Endpoint} or + * {@link WebEndpoint @WebEndpoint} annotation whenever possible. + * + * @author Phillip Webb + * @since 2.0.0 + * @see WebEndpoint + * @see ControllerEndpoint + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Endpoint +@FilteredEndpoint(ControllerEndpointFilter.class) +public @interface ControllerEndpoint { + + /** + * The id of the endpoint. + * @return the id + */ + @AliasFor(annotation = Endpoint.class) + String id(); + + /** + * If the endpoint should be enabled or disabled by default. + * @return {@code true} if the endpoint is enabled by default + */ + @AliasFor(annotation = Endpoint.class) + boolean enableByDefault() default true; + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscoverer.java new file mode 100644 index 000000000000..80747421a1c5 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscoverer.java @@ -0,0 +1,104 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.annotation; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.boot.actuate.endpoint.EndpointFilter; +import org.springframework.boot.actuate.endpoint.ExposableEndpoint; +import org.springframework.boot.actuate.endpoint.Operation; +import org.springframework.boot.actuate.endpoint.annotation.DiscoveredOperationMethod; +import org.springframework.boot.actuate.endpoint.annotation.EndpointDiscoverer; +import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker; +import org.springframework.boot.actuate.endpoint.invoke.OperationParameter; +import org.springframework.boot.actuate.endpoint.invoke.ParameterMappingException; +import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper; +import org.springframework.boot.actuate.endpoint.web.PathMapper; +import org.springframework.context.ApplicationContext; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.Assert; + +/** + * {@link EndpointDiscoverer} for {@link ExposableEndpoint controller endpoints}. + * + * @author Phillip Webb + * @since 2.0.0 + */ +public class ControllerEndpointDiscoverer + extends EndpointDiscoverer + implements ControllerEndpointsSupplier { + + private final PathMapper endpointPathMapper; + + /** + * Create a new {@link ControllerEndpointDiscoverer} instance. + * @param applicationContext the source application context + * @param endpointPathMapper the endpoint path mapper + * @param filters filters to apply + */ + public ControllerEndpointDiscoverer(ApplicationContext applicationContext, + PathMapper endpointPathMapper, + Collection> filters) { + super(applicationContext, new NoOpParameterValueMapper(), Collections.emptyList(), + filters); + Assert.notNull(endpointPathMapper, "EndpointPathMapper must not be null"); + this.endpointPathMapper = endpointPathMapper; + } + + @Override + protected boolean isEndpointExposed(Object endpointBean) { + Class type = endpointBean.getClass(); + return AnnotatedElementUtils.isAnnotated(type, ControllerEndpoint.class) + || AnnotatedElementUtils.isAnnotated(type, RestControllerEndpoint.class); + } + + @Override + protected ExposableControllerEndpoint createEndpoint(Object endpointBean, String id, + boolean enabledByDefault, Collection operations) { + String rootPath = this.endpointPathMapper.getRootPath(id); + return new DiscoveredControllerEndpoint(this, endpointBean, id, rootPath, + enabledByDefault); + } + + @Override + protected Operation createOperation(String endpointId, + DiscoveredOperationMethod operationMethod, OperationInvoker invoker) { + throw new IllegalStateException( + "ControllerEndpoints must not declare operations"); + } + + @Override + protected OperationKey createOperationKey(Operation operation) { + throw new IllegalStateException( + "ControllerEndpoints must not declare operations"); + } + + /** + * {@link ParameterValueMapper} that does nothing. + */ + private static class NoOpParameterValueMapper implements ParameterValueMapper { + + @Override + public Object mapParameterValue(OperationParameter parameter, Object value) + throws ParameterMappingException { + return value; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointFilter.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointFilter.java new file mode 100644 index 000000000000..833231d01eff --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointFilter.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.annotation; + +import org.springframework.boot.actuate.endpoint.EndpointFilter; +import org.springframework.boot.actuate.endpoint.annotation.DiscovererEndpointFilter; + +/** + * {@link EndpointFilter} for endpoints discovered by + * {@link ControllerEndpointDiscoverer}. + * + * @author Phillip Webb + */ +class ControllerEndpointFilter extends DiscovererEndpointFilter { + + ControllerEndpointFilter() { + super(ControllerEndpointDiscoverer.class); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointsSupplier.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointsSupplier.java new file mode 100644 index 000000000000..a625b43db047 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointsSupplier.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.annotation; + +import org.springframework.boot.actuate.endpoint.EndpointsSupplier; + +/** + * {@link EndpointsSupplier} for {@link ExposableControllerEndpoint controller endpoints}. + * + * @author Phillip Webb + * @since 2.0.0 + */ +public interface ControllerEndpointsSupplier + extends EndpointsSupplier { + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredControllerEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredControllerEndpoint.java new file mode 100644 index 000000000000..da5b0b351870 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredControllerEndpoint.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.annotation; + +import java.util.Collections; + +import org.springframework.boot.actuate.endpoint.ExposableEndpoint; +import org.springframework.boot.actuate.endpoint.Operation; +import org.springframework.boot.actuate.endpoint.annotation.AbstractDiscoveredEndpoint; +import org.springframework.boot.actuate.endpoint.annotation.EndpointDiscoverer; + +/** + * A discovered {@link ExposableEndpoint controller endpoint}. + * + * @author Phillip Webb + */ +class DiscoveredControllerEndpoint extends AbstractDiscoveredEndpoint + implements ExposableControllerEndpoint { + + private final String rootPath; + + DiscoveredControllerEndpoint(EndpointDiscoverer discoverer, Object endpointBean, + String id, String rootPath, boolean enabledByDefault) { + super(discoverer, endpointBean, id, enabledByDefault, Collections.emptyList()); + this.rootPath = rootPath; + } + + @Override + public Object getController() { + return getEndpointBean(); + } + + @Override + public String getRootPath() { + return this.rootPath; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredWebEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredWebEndpoint.java index 5563dd00db01..cc997e1b6f62 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredWebEndpoint.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredWebEndpoint.java @@ -33,9 +33,10 @@ class DiscoveredWebEndpoint extends AbstractDiscoveredEndpoint private final String rootPath; - DiscoveredWebEndpoint(EndpointDiscoverer discoverer, String id, String rootPath, - boolean enabledByDefault, Collection operations) { - super(discoverer, id, enabledByDefault, operations); + DiscoveredWebEndpoint(EndpointDiscoverer discoverer, Object endpointBean, + String id, String rootPath, boolean enabledByDefault, + Collection operations) { + super(discoverer, endpointBean, id, enabledByDefault, operations); this.rootPath = rootPath; } @@ -43,5 +44,4 @@ class DiscoveredWebEndpoint extends AbstractDiscoveredEndpoint public String getRootPath() { return this.rootPath; } - } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ExposableControllerEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ExposableControllerEndpoint.java new file mode 100644 index 000000000000..40efe4265be8 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ExposableControllerEndpoint.java @@ -0,0 +1,41 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.annotation; + +import org.springframework.boot.actuate.endpoint.ExposableEndpoint; +import org.springframework.boot.actuate.endpoint.Operation; +import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoint; +import org.springframework.web.bind.annotation.RequestMapping; + +/** + * Information describing an endpoint that can be exposed over Spring MVC or Spring + * WebFlux. Mappings should be discovered directly from {@link #getController()} and + * {@link #getOperations() operation} should always return an empty collection. + * + * @author Phillip Webb + * @since 2.0.0 + */ +public interface ExposableControllerEndpoint + extends ExposableEndpoint, PathMappedEndpoint { + + /** + * Return the source controller that contains {@link RequestMapping} methods. + * @return the source controller + */ + Object getController(); + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RestControllerEndpoint.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RestControllerEndpoint.java new file mode 100644 index 000000000000..ebf03026a564 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RestControllerEndpoint.java @@ -0,0 +1,74 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.FilteredEndpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; +import org.springframework.core.annotation.AliasFor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +/** + * Identifies a type as being an rest endpoint that is only exposed over Spring MVC or + * Spring WebFlux. Mapped methods must be annotated with {@link GetMapping @GetMapping}, + * {@link PostMapping @PostMapping}, {@link DeleteMapping @DeleteMapping}, etc annotations + * rather than {@link ReadOperation @ReadOperation}, + * {@link WriteOperation @WriteOperation}, {@link DeleteOperation @DeleteOperation}. + *

+ * This annotation can be used when deeper Spring integration is required, but at the + * expense of portability. Most users should prefer the {@link Endpoint @Endpoint} or + * {@link WebEndpoint @WebEndpoint} annotations whenever possible. + * + * @author Phillip Webb + * @since 2.0.0 + * @see WebEndpoint + * @see ControllerEndpoint + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Endpoint +@FilteredEndpoint(ControllerEndpointFilter.class) +@ResponseBody +public @interface RestControllerEndpoint { + + /** + * The id of the endpoint. + * @return the id + */ + @AliasFor(annotation = Endpoint.class) + String id(); + + /** + * If the endpoint should be enabled or disabled by default. + * @return {@code true} if the endpoint is enabled by default + */ + @AliasFor(annotation = Endpoint.class) + boolean enableByDefault() default true; + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/WebEndpointDiscoverer.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/WebEndpointDiscoverer.java index ff0d464dffba..05226a11ce53 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/WebEndpointDiscoverer.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/WebEndpointDiscoverer.java @@ -68,11 +68,11 @@ public WebEndpointDiscoverer(ApplicationContext applicationContext, } @Override - protected ExposableWebEndpoint createEndpoint(String id, boolean enabledByDefault, - Collection operations) { + protected ExposableWebEndpoint createEndpoint(Object endpointBean, String id, + boolean enabledByDefault, Collection operations) { String rootPath = this.endpointPathMapper.getRootPath(id); - return new DiscoveredWebEndpoint(this, id, rootPath, enabledByDefault, - operations); + return new DiscoveredWebEndpoint(this, endpointBean, id, rootPath, + enabledByDefault, operations); } @Override diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMapping.java new file mode 100644 index 000000000000..2753ebcff06e --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMapping.java @@ -0,0 +1,124 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.reactive; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; +import org.springframework.boot.actuate.endpoint.web.annotation.ExposableControllerEndpoint; +import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.util.Assert; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.result.condition.PatternsRequestCondition; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.pattern.PathPattern; + +/** + * {@link HandlerMapping} that exposes {@link ControllerEndpoint @ControllerEndpoint} and + * {@link RestControllerEndpoint @RestControllerEndpoint} annotated endpoints over Spring + * WebFlux. + * + * @author Phillip Webb + * @since 2.0.0 + */ +public class ControllerEndpointHandlerMapping extends RequestMappingHandlerMapping { + + private final EndpointMapping endpointMapping; + + private final CorsConfiguration corsConfiguration; + + private final Map handlers; + + /** + * Create a new {@link ControllerEndpointHandlerMapping} instance providing mappings + * for the specified endpoints. + * @param endpointMapping the base mapping for all endpoints + * @param endpoints the web endpoints operations + * @param corsConfiguration the CORS configuration for the endpoints or {@code null} + */ + public ControllerEndpointHandlerMapping(EndpointMapping endpointMapping, + Collection endpoints, + CorsConfiguration corsConfiguration) { + Assert.notNull(endpointMapping, "EndpointMapping must not be null"); + Assert.notNull(endpoints, "Endpoints must not be null"); + this.endpointMapping = endpointMapping; + this.handlers = getHandlers(endpoints); + this.corsConfiguration = corsConfiguration; + setOrder(-100); + } + + private Map getHandlers( + Collection endpoints) { + Map handlers = new LinkedHashMap<>(); + endpoints.stream() + .forEach((endpoint) -> handlers.put(endpoint.getController(), endpoint)); + return Collections.unmodifiableMap(handlers); + } + + @Override + protected void initHandlerMethods() { + this.handlers.keySet().forEach(this::detectHandlerMethods); + } + + @Override + protected void registerHandlerMethod(Object handler, Method method, + RequestMappingInfo mapping) { + ExposableControllerEndpoint endpoint = this.handlers.get(handler); + mapping = withEndpointMappedPatterns(endpoint, mapping); + super.registerHandlerMethod(handler, method, mapping); + } + + private RequestMappingInfo withEndpointMappedPatterns( + ExposableControllerEndpoint endpoint, RequestMappingInfo mapping) { + Set patterns = mapping.getPatternsCondition().getPatterns(); + PathPattern[] endpointMappedPatterns = patterns.stream() + .map((pattern) -> getEndpointMappedPattern(endpoint, pattern)) + .toArray(PathPattern[]::new); + return withNewPatterns(mapping, endpointMappedPatterns); + } + + private PathPattern getEndpointMappedPattern(ExposableControllerEndpoint endpoint, + PathPattern pattern) { + return getPathPatternParser().parse( + this.endpointMapping.createSubPath(endpoint.getRootPath() + pattern)); + } + + private RequestMappingInfo withNewPatterns(RequestMappingInfo mapping, + PathPattern[] patterns) { + PatternsRequestCondition patternsCondition = new PatternsRequestCondition( + patterns); + return new RequestMappingInfo(patternsCondition, mapping.getMethodsCondition(), + mapping.getParamsCondition(), mapping.getHeadersCondition(), + mapping.getConsumesCondition(), mapping.getProducesCondition(), + mapping.getCustomCondition()); + } + + @Override + protected CorsConfiguration initCorsConfiguration(Object handler, Method method, + RequestMappingInfo mapping) { + return this.corsConfiguration; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java index e7dae1a292dd..1127cfbdacac 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/AbstractWebMvcEndpointHandlerMapping.java @@ -42,13 +42,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; -import org.springframework.web.accept.PathExtensionContentNegotiationStrategy; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.servlet.HandlerMapping; -import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; import org.springframework.web.servlet.mvc.condition.ConsumesRequestCondition; import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition; import org.springframework.web.servlet.mvc.condition.ProducesRequestCondition; @@ -302,23 +300,4 @@ public Object handle(HttpServletRequest request, } - /** - * {@link HandlerInterceptorAdapter} to ensure that - * {@link PathExtensionContentNegotiationStrategy} is skipped for web endpoints. - */ - private static final class SkipPathExtensionContentNegotiation - extends HandlerInterceptorAdapter { - - private static final String SKIP_ATTRIBUTE = PathExtensionContentNegotiationStrategy.class - .getName() + ".SKIP"; - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, - Object handler) throws Exception { - request.setAttribute(SKIP_ATTRIBUTE, Boolean.TRUE); - return true; - } - - } - } diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMapping.java new file mode 100644 index 000000000000..c84a5107433e --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMapping.java @@ -0,0 +1,130 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.servlet; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; +import org.springframework.boot.actuate.endpoint.web.annotation.ExposableControllerEndpoint; +import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.util.Assert; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.HandlerMapping; +import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +/** + * {@link HandlerMapping} that exposes {@link ControllerEndpoint @ControllerEndpoint} and + * {@link RestControllerEndpoint @RestControllerEndpoint} annotated endpoints over Spring + * MVC. + * + * @author Phillip Webb + * @since 2.0.0 + */ +public class ControllerEndpointHandlerMapping extends RequestMappingHandlerMapping { + + private final EndpointMapping endpointMapping; + + private final CorsConfiguration corsConfiguration; + + private final Map handlers; + + /** + * Create a new {@link ControllerEndpointHandlerMapping} instance providing mappings + * for the specified endpoints. + * @param endpointMapping the base mapping for all endpoints + * @param endpoints the web endpoints operations + * @param corsConfiguration the CORS configuration for the endpoints or {@code null} + */ + public ControllerEndpointHandlerMapping(EndpointMapping endpointMapping, + Collection endpoints, + CorsConfiguration corsConfiguration) { + Assert.notNull(endpointMapping, "EndpointMapping must not be null"); + Assert.notNull(endpoints, "Endpoints must not be null"); + this.endpointMapping = endpointMapping; + this.handlers = getHandlers(endpoints); + this.corsConfiguration = corsConfiguration; + setOrder(-100); + setUseSuffixPatternMatch(false); + } + + private Map getHandlers( + Collection endpoints) { + Map handlers = new LinkedHashMap<>(); + endpoints.stream() + .forEach((endpoint) -> handlers.put(endpoint.getController(), endpoint)); + return Collections.unmodifiableMap(handlers); + } + + @Override + protected void initHandlerMethods() { + this.handlers.keySet().forEach(this::detectHandlerMethods); + } + + @Override + protected void registerHandlerMethod(Object handler, Method method, + RequestMappingInfo mapping) { + ExposableControllerEndpoint endpoint = this.handlers.get(handler); + mapping = withEndpointMappedPatterns(endpoint, mapping); + super.registerHandlerMethod(handler, method, mapping); + } + + private RequestMappingInfo withEndpointMappedPatterns( + ExposableControllerEndpoint endpoint, RequestMappingInfo mapping) { + Set patterns = mapping.getPatternsCondition().getPatterns(); + String[] endpointMappedPatterns = patterns.stream() + .map((pattern) -> getEndpointMappedPattern(endpoint, pattern)) + .toArray(String[]::new); + return withNewPatterns(mapping, endpointMappedPatterns); + } + + private String getEndpointMappedPattern(ExposableControllerEndpoint endpoint, + String pattern) { + return this.endpointMapping.createSubPath(endpoint.getRootPath() + pattern); + } + + private RequestMappingInfo withNewPatterns(RequestMappingInfo mapping, + String[] patterns) { + PatternsRequestCondition patternsCondition = new PatternsRequestCondition( + patterns, null, null, useSuffixPatternMatch(), useTrailingSlashMatch(), + null); + return new RequestMappingInfo(patternsCondition, mapping.getMethodsCondition(), + mapping.getParamsCondition(), mapping.getHeadersCondition(), + mapping.getConsumesCondition(), mapping.getProducesCondition(), + mapping.getCustomCondition()); + } + + @Override + protected CorsConfiguration initCorsConfiguration(Object handler, Method method, + RequestMappingInfo mapping) { + return this.corsConfiguration; + } + + @Override + protected void extendInterceptors(List interceptors) { + interceptors.add(new SkipPathExtensionContentNegotiation()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/SkipPathExtensionContentNegotiation.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/SkipPathExtensionContentNegotiation.java new file mode 100644 index 000000000000..4af0ae110086 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/SkipPathExtensionContentNegotiation.java @@ -0,0 +1,43 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.servlet; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.web.accept.PathExtensionContentNegotiationStrategy; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +/** + * {@link HandlerInterceptorAdapter} to ensure that + * {@link PathExtensionContentNegotiationStrategy} is skipped for web endpoints. + * + * @author Phillip Webb + */ +final class SkipPathExtensionContentNegotiation extends HandlerInterceptorAdapter { + + private static final String SKIP_ATTRIBUTE = PathExtensionContentNegotiationStrategy.class + .getName() + ".SKIP"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) throws Exception { + request.setAttribute(SKIP_ATTRIBUTE, Boolean.TRUE); + return true; + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscovererTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscovererTests.java index d790e83d94b6..8b83cfa5af82 100644 --- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscovererTests.java +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/annotation/EndpointDiscovererTests.java @@ -477,9 +477,10 @@ static class TestEndpointDiscoverer } @Override - protected TestExposableEndpoint createEndpoint(String id, + protected TestExposableEndpoint createEndpoint(Object endpointBean, String id, boolean enabledByDefault, Collection operations) { - return new TestExposableEndpoint(this, id, enabledByDefault, operations); + return new TestExposableEndpoint(this, endpointBean, id, enabledByDefault, + operations); } @Override @@ -510,10 +511,11 @@ static class SpecializedEndpointDiscoverer extends } @Override - protected SpecializedExposableEndpoint createEndpoint(String id, - boolean enabledByDefault, Collection operations) { - return new SpecializedExposableEndpoint(this, id, enabledByDefault, - operations); + protected SpecializedExposableEndpoint createEndpoint(Object endpointBean, + String id, boolean enabledByDefault, + Collection operations) { + return new SpecializedExposableEndpoint(this, endpointBean, id, + enabledByDefault, operations); } @Override @@ -532,10 +534,10 @@ protected OperationKey createOperationKey(SpecializedOperation operation) { static class TestExposableEndpoint extends AbstractDiscoveredEndpoint { - TestExposableEndpoint(EndpointDiscoverer discoverer, String id, - boolean enabledByDefault, + TestExposableEndpoint(EndpointDiscoverer discoverer, Object endpointBean, + String id, boolean enabledByDefault, Collection operations) { - super(discoverer, id, enabledByDefault, operations); + super(discoverer, endpointBean, id, enabledByDefault, operations); } } @@ -543,10 +545,10 @@ static class TestExposableEndpoint extends AbstractDiscoveredEndpoint { - SpecializedExposableEndpoint(EndpointDiscoverer discoverer, String id, - boolean enabledByDefault, + SpecializedExposableEndpoint(EndpointDiscoverer discoverer, + Object endpointBean, String id, boolean enabledByDefault, Collection operations) { - super(discoverer, id, enabledByDefault, operations); + super(discoverer, endpointBean, id, enabledByDefault, operations); } } diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscovererTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscovererTests.java new file mode 100644 index 000000000000..00eabebac787 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscovererTests.java @@ -0,0 +1,149 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.annotation; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.boot.actuate.endpoint.ExposableEndpoint; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.web.PathMapper; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ControllerEndpointDiscoverer}. + * + * @author Phillip Webb + */ +public class ControllerEndpointDiscovererTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + @Test + public void getEndpointsWhenNoEndpointBeansShouldReturnEmptyCollection() { + load(EmptyConfiguration.class, + (discoverer) -> assertThat(discoverer.getEndpoints()).isEmpty()); + } + + @Test + public void getEndpointsShouldIncludeControllerEndpoints() { + load(TestControllerEndpoint.class, (discoverer) -> { + Collection endpoints = discoverer.getEndpoints(); + assertThat(endpoints).hasSize(1); + ExposableControllerEndpoint endpoint = endpoints.iterator().next(); + assertThat(endpoint.getId()).isEqualTo("testcontroller"); + assertThat(endpoint.getController()) + .isInstanceOf(TestControllerEndpoint.class); + }); + } + + @Test + public void getEndpointsShouldIncludeRestControllerEndpoints() { + load(TestRestControllerEndpoint.class, (discoverer) -> { + Collection endpoints = discoverer.getEndpoints(); + assertThat(endpoints).hasSize(1); + ExposableControllerEndpoint endpoint = endpoints.iterator().next(); + assertThat(endpoint.getId()).isEqualTo("testrestcontroller"); + assertThat(endpoint.getController()) + .isInstanceOf(TestRestControllerEndpoint.class); + }); + } + + @Test + public void getEndpointsShouldNotDiscoverRegularEndpoints() { + load(WithRegularEndpointConfiguration.class, (discoverer) -> { + Collection endpoints = discoverer.getEndpoints(); + List ids = endpoints.stream().map(ExposableEndpoint::getId) + .collect(Collectors.toList()); + assertThat(ids).containsOnly("testcontroller", "testrestcontroller"); + }); + } + + @Test + public void getEndpointWhenEndpointHasOperationsShouldThrowException() { + load(TestControllerWithOperation.class, (discoverer) -> { + this.thrown.expect(IllegalStateException.class); + this.thrown.expectMessage("ControllerEndpoints must not declare operations"); + discoverer.getEndpoints(); + }); + } + + private void load(Class configuration, + Consumer consumer) { + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext( + configuration); + try { + ControllerEndpointDiscoverer discoverer = new ControllerEndpointDiscoverer( + context, PathMapper.useEndpointId(), Collections.emptyList()); + consumer.accept(discoverer); + } + finally { + context.close(); + } + } + + @Configuration + static class EmptyConfiguration { + + } + + @Configuration + @Import({ TestEndpoint.class, TestControllerEndpoint.class, + TestRestControllerEndpoint.class }) + static class WithRegularEndpointConfiguration { + + } + + @ControllerEndpoint(id = "testcontroller") + static class TestControllerEndpoint { + + } + + @RestControllerEndpoint(id = "testrestcontroller") + static class TestRestControllerEndpoint { + + } + + @Endpoint(id = "test") + static class TestEndpoint { + + } + + @ControllerEndpoint(id = "testcontroller") + static class TestControllerWithOperation { + + @ReadOperation + public String read() { + return "error"; + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingIntegrationTests.java new file mode 100644 index 000000000000..e994b03da492 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingIntegrationTests.java @@ -0,0 +1,164 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.reactive; + +import java.net.URI; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.function.Consumer; + +import org.junit.Test; + +import org.springframework.boot.actuate.endpoint.web.PathMapper; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointDiscoverer; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext; +import org.springframework.boot.test.context.runner.ContextConsumer; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; +import org.springframework.boot.web.reactive.context.AnnotationConfigReactiveWebServerApplicationContext; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; +import org.springframework.web.util.DefaultUriBuilderFactory; + +/** + * Integration tests for {@link ControllerEndpointHandlerMapping}. + * + * @author Phillip Webb + * @author Stephane Nicoll + */ +public class ControllerEndpointHandlerMappingIntegrationTests { + + public ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner( + AnnotationConfigReactiveWebServerApplicationContext::new) + .withUserConfiguration(EndpointConfiguration.class, + ExampleWebFluxEndpoint.class); + + @Test + public void get() { + this.contextRunner.run(withWebTestClient(webTestClient -> { + webTestClient.get().uri("/actuator/example/one").accept(MediaType.TEXT_PLAIN) + .exchange().expectStatus().isOk().expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class).isEqualTo("One"); + })); + } + + @Test + public void getWithUnacceptableContentType() { + this.contextRunner.run(withWebTestClient(webTestClient -> { + webTestClient.get().uri("/actuator/example/one") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isEqualTo(HttpStatus.NOT_ACCEPTABLE); + })); + } + + @Test + public void post() { + this.contextRunner.run(withWebTestClient(webTestClient -> { + webTestClient.post().uri("/actuator/example/two") + .syncBody(Collections.singletonMap("id", "test")).exchange() + .expectStatus().isCreated().expectHeader() + .valueEquals(HttpHeaders.LOCATION, "/example/test"); + })); + } + + private ContextConsumer withWebTestClient( + Consumer webClient) { + return (context) -> { + int port = ((AnnotationConfigReactiveWebServerApplicationContext) context + .getSourceApplicationContext()).getWebServer().getPort(); + WebTestClient webTestClient = createWebTestClient(port); + webClient.accept(webTestClient); + }; + } + + private WebTestClient createWebTestClient(int port) { + DefaultUriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory( + "http://localhost:" + port); + uriBuilderFactory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE); + return WebTestClient.bindToServer().uriBuilderFactory(uriBuilderFactory) + .responseTimeout(Duration.ofMinutes(2)).build(); + } + + @Configuration + @ImportAutoConfiguration({ JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, + WebFluxAutoConfiguration.class }) + static class EndpointConfiguration { + + @Bean + public NettyReactiveWebServerFactory netty() { + return new NettyReactiveWebServerFactory(0); + } + + @Bean + public HttpHandler httpHandler(ApplicationContext applicationContext) { + return WebHttpHandlerBuilder.applicationContext(applicationContext).build(); + } + + @Bean + public ControllerEndpointDiscoverer webEndpointDiscoverer( + ApplicationContext applicationContext) { + return new ControllerEndpointDiscoverer(applicationContext, + PathMapper.useEndpointId(), Collections.emptyList()); + } + + @Bean + public ControllerEndpointHandlerMapping webEndpointHandlerMapping( + ControllerEndpointsSupplier endpointsSupplier) { + return new ControllerEndpointHandlerMapping(new EndpointMapping("actuator"), + endpointsSupplier.getEndpoints(), null); + } + + } + + @RestControllerEndpoint(id = "example") + public static class ExampleWebFluxEndpoint { + + @GetMapping(path = "one", produces = MediaType.TEXT_PLAIN_VALUE) + public String one() { + return "One"; + } + + @PostMapping("/two") + public ResponseEntity two(@RequestBody Map content) { + return ResponseEntity.created(URI.create("/example/" + content.get("id"))) + .build(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingTests.java new file mode 100644 index 000000000000..a1c554f9739a --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingTests.java @@ -0,0 +1,149 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.reactive; + +import java.util.Arrays; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; +import org.springframework.boot.actuate.endpoint.web.annotation.ExposableControllerEndpoint; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.http.HttpMethod; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.server.MethodNotAllowedException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ControllerEndpointHandlerMapping}. + * + * @author Phillip Webb + * @author Stephane Nicoll + */ +public class ControllerEndpointHandlerMappingTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final StaticApplicationContext context = new StaticApplicationContext(); + + @Test + public void mappingWithNoPrefix() throws Exception { + ExposableControllerEndpoint first = firstEndpoint(); + ExposableControllerEndpoint second = secondEndpoint(); + ControllerEndpointHandlerMapping mapping = createMapping("", first, second); + assertThat(getHandler(mapping, HttpMethod.GET, "/first")) + .isEqualTo(handlerOf(first.getController(), "get")); + assertThat(getHandler(mapping, HttpMethod.POST, "/second")) + .isEqualTo(handlerOf(second.getController(), "save")); + assertThat(getHandler(mapping, HttpMethod.GET, "/third")).isNull(); + } + + @Test + public void mappingWithPrefix() throws Exception { + ExposableControllerEndpoint first = firstEndpoint(); + ExposableControllerEndpoint second = secondEndpoint(); + ControllerEndpointHandlerMapping mapping = createMapping("actuator", first, + second); + assertThat(getHandler(mapping, HttpMethod.GET, "/actuator/first")) + .isEqualTo(handlerOf(first.getController(), "get")); + assertThat(getHandler(mapping, HttpMethod.POST, "/actuator/second")) + .isEqualTo(handlerOf(second.getController(), "save")); + assertThat(getHandler(mapping, HttpMethod.GET, "/first")).isNull(); + assertThat(getHandler(mapping, HttpMethod.GET, "/second")).isNull(); + } + + @Test + public void mappingNarrowedToMethod() throws Exception { + ExposableControllerEndpoint first = firstEndpoint(); + ControllerEndpointHandlerMapping mapping = createMapping("actuator", first); + this.thrown.expect(MethodNotAllowedException.class); + getHandler(mapping, HttpMethod.POST, "/actuator/first"); + } + + private Object getHandler(ControllerEndpointHandlerMapping mapping, HttpMethod method, + String requestURI) { + return mapping.getHandler(exchange(method, requestURI)).block(); + } + + private ControllerEndpointHandlerMapping createMapping(String prefix, + ExposableControllerEndpoint... endpoints) { + ControllerEndpointHandlerMapping mapping = new ControllerEndpointHandlerMapping( + new EndpointMapping(prefix), Arrays.asList(endpoints), null); + mapping.setApplicationContext(this.context); + mapping.afterPropertiesSet(); + return mapping; + } + + private HandlerMethod handlerOf(Object source, String methodName) { + return new HandlerMethod(source, + ReflectionUtils.findMethod(source.getClass(), methodName)); + } + + private MockServerWebExchange exchange(HttpMethod method, String requestURI) { + return MockServerWebExchange + .from(MockServerHttpRequest.method(method, requestURI).build()); + } + + private ExposableControllerEndpoint firstEndpoint() { + return mockEndpoint("first", new FirstTestMvcEndpoint()); + } + + private ExposableControllerEndpoint secondEndpoint() { + return mockEndpoint("second", new SecondTestMvcEndpoint()); + } + + private ExposableControllerEndpoint mockEndpoint(String id, Object controller) { + ExposableControllerEndpoint endpoint = mock(ExposableControllerEndpoint.class); + given(endpoint.getId()).willReturn(id); + given(endpoint.getController()).willReturn(controller); + given(endpoint.getRootPath()).willReturn(id); + return endpoint; + } + + @ControllerEndpoint(id = "first") + private static class FirstTestMvcEndpoint { + + @GetMapping("/") + public String get() { + return "test"; + } + + } + + @ControllerEndpoint(id = "second") + private static class SecondTestMvcEndpoint { + + @PostMapping("/") + public void save() { + + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingIntegrationTests.java new file mode 100644 index 000000000000..6800099e4307 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingIntegrationTests.java @@ -0,0 +1,158 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.servlet; + +import java.net.URI; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.function.Consumer; + +import org.junit.Test; + +import org.springframework.boot.actuate.endpoint.web.PathMapper; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointDiscoverer; +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier; +import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration; +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext; +import org.springframework.boot.test.context.runner.ContextConsumer; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory; +import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.util.DefaultUriBuilderFactory; + +/** + * Integration tests for {@link ControllerEndpointHandlerMapping}. + * + * @author Phillip Webb + * @author Stephane Nicoll + */ +public class ControllerEndpointHandlerMappingIntegrationTests { + + public WebApplicationContextRunner contextRunner = new WebApplicationContextRunner( + AnnotationConfigServletWebServerApplicationContext::new) + .withUserConfiguration(EndpointConfiguration.class, + ExampleMvcEndpoint.class); + + @Test + public void get() { + this.contextRunner.run(withWebTestClient(webTestClient -> { + webTestClient.get().uri("/actuator/example/one").accept(MediaType.TEXT_PLAIN) + .exchange().expectStatus().isOk().expectHeader() + .contentTypeCompatibleWith(MediaType.TEXT_PLAIN) + .expectBody(String.class).isEqualTo("One"); + })); + } + + @Test + public void getWithUnacceptableContentType() { + this.contextRunner.run(withWebTestClient(webTestClient -> { + webTestClient.get().uri("/actuator/example/one") + .accept(MediaType.APPLICATION_JSON).exchange().expectStatus() + .isEqualTo(HttpStatus.NOT_ACCEPTABLE); + })); + } + + @Test + public void post() { + this.contextRunner.run(withWebTestClient(webTestClient -> { + webTestClient.post().uri("/actuator/example/two") + .syncBody(Collections.singletonMap("id", "test")).exchange() + .expectStatus().isCreated().expectHeader() + .valueEquals(HttpHeaders.LOCATION, "/example/test"); + })); + } + + private ContextConsumer withWebTestClient( + Consumer webClient) { + return (context) -> { + int port = ((AnnotationConfigServletWebServerApplicationContext) context + .getSourceApplicationContext()).getWebServer().getPort(); + WebTestClient webTestClient = createWebTestClient(port); + webClient.accept(webTestClient); + }; + } + + private WebTestClient createWebTestClient(int port) { + DefaultUriBuilderFactory uriBuilderFactory = new DefaultUriBuilderFactory( + "http://localhost:" + port); + uriBuilderFactory.setEncodingMode(DefaultUriBuilderFactory.EncodingMode.NONE); + return WebTestClient.bindToServer().uriBuilderFactory(uriBuilderFactory) + .responseTimeout(Duration.ofMinutes(2)).build(); + } + + @Configuration + @ImportAutoConfiguration({ JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, WebMvcAutoConfiguration.class, + DispatcherServletAutoConfiguration.class }) + static class EndpointConfiguration { + + @Bean + public TomcatServletWebServerFactory tomcat() { + return new TomcatServletWebServerFactory(0); + } + + @Bean + public ControllerEndpointDiscoverer webEndpointDiscoverer( + ApplicationContext applicationContext) { + return new ControllerEndpointDiscoverer(applicationContext, + PathMapper.useEndpointId(), Collections.emptyList()); + } + + @Bean + public ControllerEndpointHandlerMapping webEndpointHandlerMapping( + ControllerEndpointsSupplier endpointsSupplier) { + return new ControllerEndpointHandlerMapping(new EndpointMapping("actuator"), + endpointsSupplier.getEndpoints(), null); + } + + } + + @RestControllerEndpoint(id = "example") + public static class ExampleMvcEndpoint { + + @GetMapping(path = "one", produces = MediaType.TEXT_PLAIN_VALUE) + public String one() { + return "One"; + } + + @PostMapping("/two") + public ResponseEntity two(@RequestBody Map content) { + return ResponseEntity.created(URI.create("/example/" + content.get("id"))) + .build(); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingTests.java new file mode 100644 index 000000000000..111952b3dd4e --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingTests.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2018 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 + * + * http://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.endpoint.web.servlet; + +import java.util.Arrays; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint; +import org.springframework.boot.actuate.endpoint.web.annotation.ExposableControllerEndpoint; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.context.support.StaticApplicationContext; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.method.HandlerMethod; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ControllerEndpointHandlerMapping}. + * + * @author Phillip Webb + * @author Stephane Nicoll + */ +public class ControllerEndpointHandlerMappingTests { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final StaticApplicationContext context = new StaticApplicationContext(); + + @Test + public void mappingWithNoPrefix() throws Exception { + ExposableControllerEndpoint first = firstEndpoint(); + ExposableControllerEndpoint second = secondEndpoint(); + ControllerEndpointHandlerMapping mapping = createMapping("", first, second); + assertThat(mapping.getHandler(request("GET", "/first")).getHandler()) + .isEqualTo(handlerOf(first.getController(), "get")); + assertThat(mapping.getHandler(request("POST", "/second")).getHandler()) + .isEqualTo(handlerOf(second.getController(), "save")); + assertThat(mapping.getHandler(request("GET", "/third"))).isNull(); + } + + @Test + public void mappingWithPrefix() throws Exception { + ExposableControllerEndpoint first = firstEndpoint(); + ExposableControllerEndpoint second = secondEndpoint(); + ControllerEndpointHandlerMapping mapping = createMapping("actuator", first, + second); + assertThat(mapping.getHandler(request("GET", "/actuator/first")).getHandler()) + .isEqualTo(handlerOf(first.getController(), "get")); + assertThat(mapping.getHandler(request("POST", "/actuator/second")).getHandler()) + .isEqualTo(handlerOf(second.getController(), "save")); + assertThat(mapping.getHandler(request("GET", "/first"))).isNull(); + assertThat(mapping.getHandler(request("GET", "/second"))).isNull(); + } + + @Test + public void mappingNarrowedToMethod() throws Exception { + ExposableControllerEndpoint first = firstEndpoint(); + ControllerEndpointHandlerMapping mapping = createMapping("actuator", first); + this.thrown.expect(HttpRequestMethodNotSupportedException.class); + mapping.getHandler(request("POST", "/actuator/first")); + } + + private ControllerEndpointHandlerMapping createMapping(String prefix, + ExposableControllerEndpoint... endpoints) { + ControllerEndpointHandlerMapping mapping = new ControllerEndpointHandlerMapping( + new EndpointMapping(prefix), Arrays.asList(endpoints), null); + mapping.setApplicationContext(this.context); + mapping.afterPropertiesSet(); + return mapping; + } + + private HandlerMethod handlerOf(Object source, String methodName) { + return new HandlerMethod(source, + ReflectionUtils.findMethod(source.getClass(), methodName)); + } + + private MockHttpServletRequest request(String method, String requestURI) { + return new MockHttpServletRequest(method, requestURI); + } + + private ExposableControllerEndpoint firstEndpoint() { + return mockEndpoint("first", new FirstTestMvcEndpoint()); + } + + private ExposableControllerEndpoint secondEndpoint() { + return mockEndpoint("second", new SecondTestMvcEndpoint()); + } + + private ExposableControllerEndpoint mockEndpoint(String id, Object controller) { + ExposableControllerEndpoint endpoint = mock(ExposableControllerEndpoint.class); + given(endpoint.getId()).willReturn(id); + given(endpoint.getController()).willReturn(controller); + given(endpoint.getRootPath()).willReturn(id); + return endpoint; + } + + @ControllerEndpoint(id = "first") + private static class FirstTestMvcEndpoint { + + @GetMapping("/") + public String get() { + return "test"; + } + + } + + @ControllerEndpoint(id = "second") + private static class SecondTestMvcEndpoint { + + @PostMapping("/") + public void save() { + + } + + } + +} diff --git a/spring-boot-project/spring-boot-parent/src/checkstyle/import-control.xml b/spring-boot-project/spring-boot-parent/src/checkstyle/import-control.xml index 6b8c0d2092ac..78d3c74eaf7f 100644 --- a/spring-boot-project/spring-boot-parent/src/checkstyle/import-control.xml +++ b/spring-boot-project/spring-boot-parent/src/checkstyle/import-control.xml @@ -43,15 +43,13 @@ + + - - - - diff --git a/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/main/java/sample/actuator/customsecurity/ExampleRestControllerEndpoint.java b/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/main/java/sample/actuator/customsecurity/ExampleRestControllerEndpoint.java new file mode 100644 index 000000000000..9bd0ba5c0a2e --- /dev/null +++ b/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/main/java/sample/actuator/customsecurity/ExampleRestControllerEndpoint.java @@ -0,0 +1,34 @@ +/* + * Copyright 2012-2018 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 + * + * http://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 sample.actuator.customsecurity; + +import org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Component +@RestControllerEndpoint(id = "example") +public class ExampleRestControllerEndpoint { + + @GetMapping("/echo") + public ResponseEntity echo(@RequestParam("text") String text) { + return ResponseEntity.ok().header("echo", text).body(text); + } + +} diff --git a/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/test/java/sample/actuator/customsecurity/SampleActuatorCustomSecurityApplicationTests.java b/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/test/java/sample/actuator/customsecurity/SampleActuatorCustomSecurityApplicationTests.java index c608631b15d3..03fe0cdf3436 100644 --- a/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/test/java/sample/actuator/customsecurity/SampleActuatorCustomSecurityApplicationTests.java +++ b/spring-boot-samples/spring-boot-sample-actuator-custom-security/src/test/java/sample/actuator/customsecurity/SampleActuatorCustomSecurityApplicationTests.java @@ -109,6 +109,22 @@ public void actuatorCustomMvcSecureEndpointWithAnonymous() { assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); } + @Test + public void actuatorCustomMvcSecureEndpointWithUnauthorizedUser() { + ResponseEntity entity = userRestTemplate() + .getForEntity("/actuator/example/echo?text={t}", String.class, "test"); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + public void actuatorCustomMvcSecureEndpointWithAuthorizedUser() { + ResponseEntity entity = adminRestTemplate() + .getForEntity("/actuator/example/echo?text={t}", String.class, "test"); + assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(entity.getBody()).isEqualTo("test"); + assertThat(entity.getHeaders().getFirst("echo")).isEqualTo("test"); + } + private TestRestTemplate restTemplate() { return configure(new TestRestTemplate()); }