From bda9b892b399d0b6b5d385b1d90f6011c09e8ec0 Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Thu, 18 Jan 2018 20:52:35 -0800 Subject: [PATCH] Add direct WebFlux and WebMvc endpoint support Add `@ControllerEndpoint` and `@RestControllerEndpoint` annotations that can be used to develop a Spring-only request mapped endpoint. Both Spring MVC and Spring WebFlux are supported. This feature is primarily for use when deeper Spring integration is required or when existing Spring Boot 1.5 projects want to migrate to Spring Boot 2.0 without re-writing existing endpoints. It comes at the expense of portability, since such endpoints will be missing from Jersey. Fixes gh-10257 --- .../web/WebEndpointAutoConfiguration.java | 21 +++ ...ndpointManagementContextConfiguration.java | 15 ++ ...ndpointManagementContextConfiguration.java | 15 ++ ...rollerEndpointWebFluxIntegrationTests.java | 94 ++++++++++ ...trollerEndpointWebMvcIntegrationTests.java | 134 ++++++++++++++ ...ebMvcEndpointExposureIntegrationTests.java | 19 +- .../AbstractDiscoveredEndpoint.java | 19 +- .../annotation/DiscoveredEndpoint.java | 6 + .../annotation/EndpointDiscoverer.java | 23 ++- .../jmx/annotation/DiscoveredJmxEndpoint.java | 6 +- .../jmx/annotation/JmxEndpointDiscoverer.java | 7 +- .../web/annotation/ControllerEndpoint.java | 72 ++++++++ .../ControllerEndpointDiscoverer.java | 104 +++++++++++ .../annotation/ControllerEndpointFilter.java | 34 ++++ .../ControllerEndpointsSupplier.java | 30 ++++ .../DiscoveredControllerEndpoint.java | 52 ++++++ .../web/annotation/DiscoveredWebEndpoint.java | 8 +- .../ExposableControllerEndpoint.java | 41 +++++ .../annotation/RestControllerEndpoint.java | 74 ++++++++ .../web/annotation/WebEndpointDiscoverer.java | 8 +- .../ControllerEndpointHandlerMapping.java | 124 +++++++++++++ .../AbstractWebMvcEndpointHandlerMapping.java | 21 --- .../ControllerEndpointHandlerMapping.java | 130 ++++++++++++++ .../SkipPathExtensionContentNegotiation.java | 43 +++++ .../annotation/EndpointDiscovererTests.java | 26 +-- .../ControllerEndpointDiscovererTests.java | 149 ++++++++++++++++ ...ndpointHandlerMappingIntegrationTests.java | 164 ++++++++++++++++++ ...ControllerEndpointHandlerMappingTests.java | 149 ++++++++++++++++ ...ndpointHandlerMappingIntegrationTests.java | 158 +++++++++++++++++ ...ControllerEndpointHandlerMappingTests.java | 141 +++++++++++++++ .../src/checkstyle/import-control.xml | 6 +- .../ExampleRestControllerEndpoint.java | 34 ++++ ...ctuatorCustomSecurityApplicationTests.java | 16 ++ 33 files changed, 1882 insertions(+), 61 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/ControllerEndpointWebFluxIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/integrationtest/ControllerEndpointWebMvcIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpoint.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscoverer.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointFilter.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointsSupplier.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/DiscoveredControllerEndpoint.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/ExposableControllerEndpoint.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RestControllerEndpoint.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMapping.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMapping.java create mode 100644 spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/servlet/SkipPathExtensionContentNegotiation.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/ControllerEndpointDiscovererTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/reactive/ControllerEndpointHandlerMappingTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingIntegrationTests.java create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/servlet/ControllerEndpointHandlerMappingTests.java create mode 100644 spring-boot-samples/spring-boot-sample-actuator-custom-security/src/main/java/sample/actuator/customsecurity/ExampleRestControllerEndpoint.java 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 a0b487a2ec..80ecda4572 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.ExposableWebEndpoint; 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 class WebEndpointAutoConfiguration { 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 class WebEndpointAutoConfiguration { 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 917d7eb9a6..d83ed9492d 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.autoconfigure.web.ManagementContextConfi 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 class WebFluxEndpointManagementContextConfiguration { 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 f356e26ffe..c7e74252c5 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.autoconfigure.web.ManagementContextConfi 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 class WebMvcEndpointManagementContextConfiguration { 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 0000000000..8f5e3411aa --- /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 0000000000..4ed7d83058 --- /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 858dc44bf7..f5176f4d7a 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.EndpointAutoConfi 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.http.HttpStatus; 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 class WebMvcEndpointExposureIntegrationTests { 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 class WebMvcEndpointExposureIntegrationTests { 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 class WebMvcEndpointExposureIntegrationTests { 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 class WebMvcEndpointExposureIntegrationTests { 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 @@ public class WebMvcEndpointExposureIntegrationTests { .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 b1e3036ec0..d3ad933614 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 abstract class AbstractDiscoveredEndpoint @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 2f7b09e2d7..673fb898bf 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 9897963cfa..94d08d5451 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 @@ public abstract class EndpointDiscoverer, O exten 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 @@ public abstract class EndpointDiscoverer, O exten 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 @@ public abstract class EndpointDiscoverer, O exten 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 @@ public abstract class EndpointDiscoverer, O exten /** * 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 086985c8f3..7b7b9fce09 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 @@ import org.springframework.boot.actuate.endpoint.jmx.JmxOperation; 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 4bc61741a0..264afdfcf5 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 class JmxEndpointDiscoverer } @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 0000000000..e9342307af --- /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 0000000000..80747421a1 --- /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 0000000000..833231d01e --- /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 0000000000..a625b43db0 --- /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 0000000000..da5b0b3518 --- /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 5563dd00db..cc997e1b6f 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 0000000000..40efe4265b --- /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 0000000000..ebf03026a5 --- /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 ff0d464dff..05226a11ce 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 class WebEndpointDiscoverer } @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 0000000000..2753ebcff0 --- /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 e7dae1a292..1127cfbdac 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.HttpStatus; 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 abstract class AbstractWebMvcEndpointHandlerMapping } - /** - * {@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 0000000000..c84a510743 --- /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 0000000000..4af0ae1100 --- /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 d790e83d94..8b83cfa5af 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 @@ public class EndpointDiscovererTests { } @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 @@ public class EndpointDiscovererTests { } @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 @@ public class EndpointDiscovererTests { 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 @@ public class EndpointDiscovererTests { static class SpecializedExposableEndpoint 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 0000000000..00eabebac7 --- /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 0000000000..e994b03da4 --- /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 0000000000..a1c554f973 --- /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 0000000000..6800099e43 --- /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 0000000000..111952b3dd --- /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 6b8c0d2092..78d3c74eaf 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 0000000000..9bd0ba5c0a --- /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 c608631b15..03fe0cdf34 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 class SampleActuatorCustomSecurityApplicationTests { 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()); }