diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc index 6943f9ac0a3..8dea5be95df 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/actuator/endpoints.adoc @@ -926,7 +926,7 @@ management.endpoint.health.group.live.additional-path="server:/healthz" This would make the `live` health group available on the main server port at `/healthz`. The prefix is mandatory and must be either `server:` (represents the main server port) or `management:` (represents the management port, if configured.) -The path must be a single path segment. +The path can contain one or more path segments. diff --git a/module/spring-boot-actuator-autoconfigure/src/testFixtures/java/org/springframework/boot/actuate/autoconfigure/integrationtest/AbstractHealthEndpointAdditionalPathIntegrationTests.java b/module/spring-boot-actuator-autoconfigure/src/testFixtures/java/org/springframework/boot/actuate/autoconfigure/integrationtest/AbstractHealthEndpointAdditionalPathIntegrationTests.java index 9d67e2422a5..9b4ce5e4614 100644 --- a/module/spring-boot-actuator-autoconfigure/src/testFixtures/java/org/springframework/boot/actuate/autoconfigure/integrationtest/AbstractHealthEndpointAdditionalPathIntegrationTests.java +++ b/module/spring-boot-actuator-autoconfigure/src/testFixtures/java/org/springframework/boot/actuate/autoconfigure/integrationtest/AbstractHealthEndpointAdditionalPathIntegrationTests.java @@ -36,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatNoException; * @param the application context type * @param the assertions * @author Madhura Bhave + * @author Wan bin yu */ public abstract class AbstractHealthEndpointAdditionalPathIntegrationTests, C extends ConfigurableApplicationContext, A extends ApplicationContextAssertProvider> { @@ -54,6 +55,15 @@ public abstract class AbstractHealthEndpointAdditionalPathIntegrationTests testResponses(client, "/myBasePath/health"), "local.server.port")); + } + @Test void multipleGroupsAreAvailableAtAdditionalPaths() { this.runner @@ -66,6 +76,27 @@ public abstract class AbstractHealthEndpointAdditionalPathIntegrationTests testResponses(client, "/alpha", "/bravo"), "local.server.port")); } + @Test + void healthComponentIsAvailableWhenMultiSegmentAdditionalPathIsConfigured() { + this.runner + .withPropertyValues("management.endpoint.health.show-components=always", + "management.endpoint.health.group.live.include=diskSpace", + "management.endpoint.health.group.live.additional-path=server:/myBasePath/health", + "management.endpoint.health.group.live.show-components=always") + .run(withWebTestClient((client) -> { + testResponses(client, "/myBasePath/health"); + client.get() + .uri("/actuator/health/diskSpace") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isOk() + .expectBody() + .jsonPath("status") + .isEqualTo("UP"); + }, "local.server.port")); + } + @Test void groupIsAvailableAtAdditionalPathWithoutSlash() { this.runner diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/AdditionalHealthEndpointPath.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/AdditionalHealthEndpointPath.java index 8f83b870659..2e2f77b3fa8 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/AdditionalHealthEndpointPath.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/AdditionalHealthEndpointPath.java @@ -27,6 +27,7 @@ import org.springframework.util.StringUtils; * * @author Phillip Webb * @author Madhura Bhave + * @author Wan bin yu * @since 4.0.0 */ public final class AdditionalHealthEndpointPath { @@ -99,8 +100,8 @@ public final class AdditionalHealthEndpointPath { /** * Creates an {@link AdditionalHealthEndpointPath} from the given input. The input - * must contain a prefix and value separated by a `:`. The value must be limited to - * one path segment. For example, `server:/healthz`. + * must contain a prefix and value separated by a `:`. The value can contain one or + * more path segments. For example, `server:/healthz`. * @param value the value to parse * @return the new instance */ @@ -110,7 +111,6 @@ public final class AdditionalHealthEndpointPath { Assert.isTrue(values.length == 2, "'value' must contain a valid namespace and value separated by ':'."); Assert.isTrue(StringUtils.hasText(values[0]), "'value' must contain a valid namespace."); WebServerNamespace namespace = WebServerNamespace.from(values[0]); - validateValue(values[1]); return new AdditionalHealthEndpointPath(namespace, values[1]); } @@ -124,13 +124,7 @@ public final class AdditionalHealthEndpointPath { public static AdditionalHealthEndpointPath of(WebServerNamespace webServerNamespace, String value) { Assert.notNull(webServerNamespace, "'webServerNamespace' must not be null."); Assert.notNull(value, "'value' must not be null."); - validateValue(value); return new AdditionalHealthEndpointPath(webServerNamespace, value); } - private static void validateValue(String value) { - Assert.isTrue(StringUtils.countOccurrencesOf(value, "/") <= 1 && value.indexOf("/") <= 0, - "'value' must contain only one segment."); - } - } diff --git a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/HealthEndpointSupport.java b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/HealthEndpointSupport.java index 1674b74efd5..4bc4f2f17be 100644 --- a/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/HealthEndpointSupport.java +++ b/module/spring-boot-health/src/main/java/org/springframework/boot/health/actuate/endpoint/HealthEndpointSupport.java @@ -44,6 +44,7 @@ import org.springframework.util.StringUtils; * @param the descriptor type * @author Phillip Webb * @author Scott Frederick + * @author Wan bin yu */ abstract class HealthEndpointSupport { @@ -75,19 +76,29 @@ abstract class HealthEndpointSupport { @Nullable Result getResult(ApiVersion apiVersion, @Nullable WebServerNamespace serverNamespace, SecurityContext securityContext, boolean showAll, String... path) { - HealthEndpointGroup group = (path.length > 0) ? getGroup(serverNamespace, path) : null; - if (group != null) { - return getResult(apiVersion, group, securityContext, showAll, path, 1); + GroupMatch groupMatch = (path.length > 0) ? getGroup(serverNamespace, path) : null; + if (groupMatch != null) { + return getResult(apiVersion, groupMatch.group(), securityContext, showAll, path, groupMatch.pathOffset()); } return getResult(apiVersion, this.groups.getPrimary(), securityContext, showAll, path, 0); } - private @Nullable HealthEndpointGroup getGroup(@Nullable WebServerNamespace serverNamespace, String... path) { - if (this.groups.get(path[0]) != null) { - return this.groups.get(path[0]); + private @Nullable GroupMatch getGroup(@Nullable WebServerNamespace serverNamespace, String... path) { + HealthEndpointGroup group = this.groups.get(path[0]); + if (group != null) { + return new GroupMatch(group, 1); } if (serverNamespace != null) { - return this.groups.get(AdditionalHealthEndpointPath.of(serverNamespace, path[0])); + StringBuilder additionalPath = new StringBuilder(); + GroupMatch groupMatch = null; + for (int i = 0; i < path.length; i++) { + additionalPath.append((i != 0) ? "/" : "").append(path[i]); + group = this.groups.get(AdditionalHealthEndpointPath.of(serverNamespace, additionalPath.toString())); + if (group != null) { + groupMatch = new GroupMatch(group, i + 1); + } + } + return groupMatch; } return null; } @@ -210,4 +221,8 @@ abstract class HealthEndpointSupport { } + private record GroupMatch(HealthEndpointGroup group, int pathOffset) { + + } + } diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/AdditionalHealthEndpointPathTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/AdditionalHealthEndpointPathTests.java index 363e66ce0de..0804d0705bd 100644 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/AdditionalHealthEndpointPathTests.java +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/AdditionalHealthEndpointPathTests.java @@ -66,15 +66,17 @@ class AdditionalHealthEndpointPathTests { } @Test - void fromPathWithMultipleSegmentsShouldThrowException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> AdditionalHealthEndpointPath.from("server:/my-path/my-sub-path")); + void fromPathWithMultipleSegmentsShouldCreatePath() { + AdditionalHealthEndpointPath path = AdditionalHealthEndpointPath.from("server:/my-path/my-sub-path"); + assertThat(path.getValue()).isEqualTo("/my-path/my-sub-path"); + assertThat(path.getNamespace()).isEqualTo(WebServerNamespace.SERVER); } @Test - void fromPathWithMultipleSegmentsNotStartingWithSlashShouldThrowException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> AdditionalHealthEndpointPath.from("server:my-path/my-sub-path")); + void fromPathWithMultipleSegmentsNotStartingWithSlashShouldCreatePath() { + AdditionalHealthEndpointPath path = AdditionalHealthEndpointPath.from("server:my-path/my-sub-path"); + assertThat(path.getValue()).isEqualTo("my-path/my-sub-path"); + assertThat(path.getNamespace()).isEqualTo(WebServerNamespace.SERVER); } @Test @@ -109,9 +111,11 @@ class AdditionalHealthEndpointPathTests { } @Test - void ofWithMultipleSegmentValueShouldThrowException() { - assertThatIllegalArgumentException() - .isThrownBy(() -> AdditionalHealthEndpointPath.of(WebServerNamespace.SERVER, "/my-path/my-subpath")); + void ofWithMultipleSegmentValueShouldCreatePath() { + AdditionalHealthEndpointPath additionalPath = AdditionalHealthEndpointPath.of(WebServerNamespace.SERVER, + "/my-path/my-subpath"); + assertThat(additionalPath.getValue()).isEqualTo("/my-path/my-subpath"); + assertThat(additionalPath.getNamespace()).isEqualTo(WebServerNamespace.SERVER); } @Test diff --git a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/HealthEndpointSupportTests.java b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/HealthEndpointSupportTests.java index 9fd265457d4..d7fa095c978 100644 --- a/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/HealthEndpointSupportTests.java +++ b/module/spring-boot-health/src/test/java/org/springframework/boot/health/actuate/endpoint/HealthEndpointSupportTests.java @@ -353,6 +353,20 @@ abstract class HealthEndpointSupportTests, assertThat(descriptor.getComponents()).containsKey("test"); } + @Test + void getResultWhenGroupHasMultiSegmentAdditionalPath() { + R registry = createRegistry("test", createContributor(this.up)); + TestHealthEndpointGroup testGroup = new TestHealthEndpointGroup((name) -> name.startsWith("test")); + testGroup.setAdditionalPath(AdditionalHealthEndpointPath.from("server:/myBasePath/health")); + HealthEndpointGroups groups = HealthEndpointGroups.of(this.primaryGroup, Map.of("testGroup", testGroup)); + E endpoint = create(registry, groups); + Result result = endpoint.getResult(ApiVersion.V3, WebServerNamespace.SERVER, SecurityContext.NONE, false, + "myBasePath", "health"); + assertThat(result).isNotNull(); + CompositeHealthDescriptor descriptor = (CompositeHealthDescriptor) getDescriptor(result); + assertThat(descriptor.getComponents()).containsKey("test"); + } + @Test void getResultWhenGroupHasAdditionalPathAndShowComponentsFalse() { R registry = createRegistry("test", createContributor(this.up)); diff --git a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/actuate/endpoint/web/AbstractWebMvcEndpointHandlerMapping.java b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/actuate/endpoint/web/AbstractWebMvcEndpointHandlerMapping.java index 19db92781a0..60b642d5444 100644 --- a/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/actuate/endpoint/web/AbstractWebMvcEndpointHandlerMapping.java +++ b/module/spring-boot-webmvc/src/main/java/org/springframework/boot/webmvc/actuate/endpoint/web/AbstractWebMvcEndpointHandlerMapping.java @@ -76,6 +76,9 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.servlet.mvc.method.RequestMappingInfo; import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping; +import org.springframework.web.util.ServletRequestPathUtils; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; /** * A custom {@link HandlerMapping} that makes {@link ExposableWebEndpoint web endpoints} @@ -85,6 +88,7 @@ import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMappi * @author Madhura Bhave * @author Phillip Webb * @author Brian Clozel + * @author Wan bin yu * @since 4.0.0 */ @ImportRuntimeHints(AbstractWebMvcEndpointHandlerMappingRuntimeHints.class) @@ -192,7 +196,7 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin protected void registerMapping(ExposableWebEndpoint endpoint, WebOperationRequestPredicate predicate, WebOperation operation, String path) { ServletWebOperation servletWebOperation = wrapServletWebOperation(endpoint, operation, - new ServletWebOperationAdapter(operation)); + new ServletWebOperationAdapter(operation, getPatternParser())); registerMapping(createRequestMappingInfo(predicate, path), new OperationHandler(servletWebOperation), this.handleMethod); } @@ -333,8 +337,11 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin private final WebOperation operation; - ServletWebOperationAdapter(WebOperation operation) { + private final @Nullable PathPatternParser patternParser; + + ServletWebOperationAdapter(WebOperation operation, @Nullable PathPatternParser patternParser) { this.operation = operation; + this.patternParser = patternParser; } @Override @@ -390,24 +397,25 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin } private Object getRemainingPathSegments(HttpServletRequest request) { - String[] pathTokens = tokenize(request, HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, true); - String[] patternTokens = tokenize(request, HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, false); - int numberOfRemainingPathSegments = pathTokens.length - patternTokens.length + 1; - Assert.state(numberOfRemainingPathSegments >= 0, "Unable to extract remaining path segments"); - String[] remainingPathSegments = new String[numberOfRemainingPathSegments]; - System.arraycopy(pathTokens, patternTokens.length - 1, remainingPathSegments, 0, - numberOfRemainingPathSegments); - return remainingPathSegments; + Assert.state(this.patternParser != null, "'patternParser' must not be null"); + String pattern = (String) request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + Assert.state(pattern != null, "'pattern' must not be null"); + PathPattern pathPattern = this.patternParser.parse(pattern); + if (pathPattern.hasPatternSyntax()) { + String remainingSegments = pathPattern + .extractPathWithinPattern( + ServletRequestPathUtils.getParsedRequestPath(request).pathWithinApplication()) + .value(); + return tokenizePathSegments(remainingSegments); + } + return tokenizePathSegments(pathPattern.toString()); } - private String[] tokenize(HttpServletRequest request, String attributeName, boolean decode) { - String value = (String) request.getAttribute(attributeName); + private String[] tokenizePathSegments(String value) { String[] segments = StringUtils.tokenizeToStringArray(value, PATH_SEPARATOR, false, true); - if (decode) { - for (int i = 0; i < segments.length; i++) { - if (segments[i].contains("%")) { - segments[i] = StringUtils.uriDecode(segments[i], StandardCharsets.UTF_8); - } + for (int i = 0; i < segments.length; i++) { + if (segments[i].contains("%")) { + segments[i] = StringUtils.uriDecode(segments[i], StandardCharsets.UTF_8); } } return segments;