Support multi-segment additional health paths

Signed-off-by: Wan bin yu <3431359639@qq.com>

See gh-51470
This commit is contained in:
Wan bin yu
2026-09-16 10:22:31 +01:00
committed by Andy Wilkinson
parent 1f3209130d
commit 3aa96f2229
7 changed files with 109 additions and 43 deletions
@@ -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.
@@ -36,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatNoException;
* @param <C> the application context type
* @param <A> the assertions
* @author Madhura Bhave
* @author Wan bin yu
*/
public abstract class AbstractHealthEndpointAdditionalPathIntegrationTests<T extends AbstractApplicationContextRunner<T, C, A>, C extends ConfigurableApplicationContext, A extends ApplicationContextAssertProvider<A, C>> {
@@ -54,6 +55,15 @@ public abstract class AbstractHealthEndpointAdditionalPathIntegrationTests<T ext
.run(withWebTestClient(this::testResponse, "local.server.port"));
}
@Test
void groupIsAvailableAtAdditionalPathWithMultipleSegments() {
this.runner
.withPropertyValues("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"), "local.server.port"));
}
@Test
void multipleGroupsAreAvailableAtAdditionalPaths() {
this.runner
@@ -66,6 +76,27 @@ public abstract class AbstractHealthEndpointAdditionalPathIntegrationTests<T ext
.run(withWebTestClient((client) -> 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
@@ -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.");
}
}
@@ -44,6 +44,7 @@ import org.springframework.util.StringUtils;
* @param <D> the descriptor type
* @author Phillip Webb
* @author Scott Frederick
* @author Wan bin yu
*/
abstract class HealthEndpointSupport<H, D> {
@@ -75,19 +76,29 @@ abstract class HealthEndpointSupport<H, D> {
@Nullable Result<D> 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<H, D> {
}
private record GroupMatch(HealthEndpointGroup group, int pathOffset) {
}
}
@@ -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
@@ -353,6 +353,20 @@ abstract class HealthEndpointSupportTests<E extends HealthEndpointSupport<H, D>,
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<D> 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));
@@ -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;