Allow EndpointRequest to match additional paths

Add `toAdditionalPaths(...)` methods on the servlet and reactive
`EndpointRequest` classes to support matching of additional paths.

A new `AdditionalPathsMapper` interface provides the mappings between
endpoint IDs and any additional paths that they might use. The existing
`AutoConfiguredHealthEndpointGroups` class has been updated to implement
the interface.

Auto-configurations have also been updated so that additional health
endpoint paths (typically `/livez` and `/readyz`) are permitted
when using Spring Security without any custom configuration.

Fixes gh-40962
This commit is contained in:
Phillip Webb
2024-09-18 23:46:55 -07:00
parent f5b6514bef
commit d72a9d9eb5
26 changed files with 901 additions and 201 deletions

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2012-2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.boot.actuate.endpoint.web;
import java.util.List;
import org.springframework.boot.actuate.endpoint.EndpointId;
/**
* Strategy interface used to provide a mapping between an endpoint ID and any additional
* paths where it will be exposed.
*
* @author Phillip Webb
* @since 3.4.0
*/
@FunctionalInterface
public interface AdditionalPathsMapper {
/**
* Resolve the additional paths for the specified {@code endpointId} and web server
* namespace.
* @param endpointId the id of an endpoint
* @param webServerNamespace the web server namespace
* @return the additional paths of the endpoint or {@code null} if this mapper doesn't
* support the given endpoint ID.
*/
List<String> getAdditionalPaths(EndpointId endpointId, WebServerNamespace webServerNamespace);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2024 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.
@@ -16,6 +16,9 @@
package org.springframework.boot.actuate.endpoint.web;
import java.util.Collections;
import java.util.List;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
/**
@@ -30,11 +33,23 @@ import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
public interface PathMappedEndpoint {
/**
* Return the root path of the endpoint, relative to the context that exposes it. For
* example, a root path of {@code example} would be exposed under the URL
* "/{actuator-context}/example".
* Return the root path of the endpoint (relative to the context and base path) that
* exposes it. For example, a root path of {@code example} would be exposed under the
* URL "/{actuator-context}/example".
* @return the root path for the endpoint
* @see PathMappedEndpoints#getBasePath
*/
String getRootPath();
/**
* Return any additional paths (relative to the context) for the given
* {@link WebServerNamespace}.
* @param webServerNamespace the web server namespace
* @return a list of additional paths
* @since 3.4.0
*/
default List<String> getAdditionalPaths(WebServerNamespace webServerNamespace) {
return Collections.emptyList();
}
}

View File

@@ -20,12 +20,14 @@ import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.EndpointsSupplier;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
/**
* A collection of {@link PathMappedEndpoint path mapped endpoints}.
@@ -101,7 +103,7 @@ public class PathMappedEndpoints implements Iterable<PathMappedEndpoint> {
}
/**
* Return the root paths for each mapped endpoint.
* Return the root paths for each mapped endpoint (excluding additional paths).
* @return all root paths
*/
public Collection<String> getAllRootPaths() {
@@ -109,13 +111,36 @@ public class PathMappedEndpoints implements Iterable<PathMappedEndpoint> {
}
/**
* Return the full paths for each mapped endpoint.
* Return the full paths for each mapped endpoint (excluding additional paths).
* @return all root paths
*/
public Collection<String> getAllPaths() {
return stream().map(this::getPath).toList();
}
/**
* Return the additional paths for each mapped endpoint.
* @param webServerNamespace the web server namespace
* @param endpointId the endpoint ID
* @return all additional paths
* @since 3.4.0
*/
public Collection<String> getAdditionalPaths(WebServerNamespace webServerNamespace, EndpointId endpointId) {
return getAdditionalPaths(webServerNamespace, getEndpoint(endpointId)).toList();
}
private Stream<String> getAdditionalPaths(WebServerNamespace webServerNamespace, PathMappedEndpoint endpoint) {
List<String> additionalPaths = (endpoint != null) ? endpoint.getAdditionalPaths(webServerNamespace) : null;
if (CollectionUtils.isEmpty(additionalPaths)) {
return Stream.empty();
}
return additionalPaths.stream().map(this::getAdditionalPath);
}
private String getAdditionalPath(String path) {
return path.startsWith("/") ? path : "/" + path;
}
/**
* Return the {@link PathMappedEndpoint} with the given ID or {@code null} if the
* endpoint cannot be found.

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2024 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.
@@ -19,7 +19,8 @@ package org.springframework.boot.actuate.endpoint.web;
import org.springframework.util.StringUtils;
/**
* Enumeration of server namespaces.
* A web server namespace used for disambiguation when multiple web servers are running in
* the same application (for example a management context running on a different port).
*
* @author Phillip Webb
* @author Madhura Bhave
@@ -43,17 +44,14 @@ public final class WebServerNamespace {
this.value = value;
}
/**
* Return the value of the namespace.
* @return the value
*/
public String getValue() {
return this.value;
}
public static WebServerNamespace from(String value) {
if (StringUtils.hasText(value)) {
return new WebServerNamespace(value);
}
return SERVER;
}
@Override
public boolean equals(Object obj) {
if (this == obj) {
@@ -71,4 +69,22 @@ public final class WebServerNamespace {
return this.value.hashCode();
}
@Override
public String toString() {
return this.value;
}
/**
* Factory method to create a new {@link WebServerNamespace} from a value. If the
* value is empty or {@code null} then {@link #SERVER} is returned.
* @param value the namespace value or {@code null}
* @return the web server namespace
*/
public static WebServerNamespace from(String value) {
if (StringUtils.hasText(value)) {
return new WebServerNamespace(value);
}
return SERVER;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2019 the original author or authors.
* Copyright 2012-2024 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.
@@ -17,12 +17,16 @@
package org.springframework.boot.actuate.endpoint.web.annotation;
import java.util.Collection;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.annotation.AbstractDiscoveredEndpoint;
import org.springframework.boot.actuate.endpoint.annotation.EndpointDiscoverer;
import org.springframework.boot.actuate.endpoint.web.AdditionalPathsMapper;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
/**
* A discovered {@link ExposableWebEndpoint web endpoint}.
@@ -33,10 +37,14 @@ class DiscoveredWebEndpoint extends AbstractDiscoveredEndpoint<WebOperation> imp
private final String rootPath;
private Collection<AdditionalPathsMapper> additionalPathsMappers;
DiscoveredWebEndpoint(EndpointDiscoverer<?, ?> discoverer, Object endpointBean, EndpointId id, String rootPath,
boolean enabledByDefault, Collection<WebOperation> operations) {
boolean enabledByDefault, Collection<WebOperation> operations,
Collection<AdditionalPathsMapper> additionalPathsMappers) {
super(discoverer, endpointBean, id, enabledByDefault, operations);
this.rootPath = rootPath;
this.additionalPathsMappers = additionalPathsMappers;
}
@Override
@@ -44,4 +52,15 @@ class DiscoveredWebEndpoint extends AbstractDiscoveredEndpoint<WebOperation> imp
return this.rootPath;
}
@Override
public List<String> getAdditionalPaths(WebServerNamespace webServerNamespace) {
return this.additionalPathsMappers.stream()
.flatMap((mapper) -> getAdditionalPaths(webServerNamespace, mapper))
.toList();
}
private Stream<String> getAdditionalPaths(WebServerNamespace webServerNamespace, AdditionalPathsMapper mapper) {
return mapper.getAdditionalPaths(getEndpointId(), webServerNamespace).stream();
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2024 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.
@@ -17,6 +17,7 @@
package org.springframework.boot.actuate.endpoint.web.annotation;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.springframework.aot.hint.MemberCategory;
@@ -29,6 +30,7 @@ import org.springframework.boot.actuate.endpoint.annotation.EndpointDiscoverer;
import org.springframework.boot.actuate.endpoint.invoke.OperationInvoker;
import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.web.AdditionalPathsMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMapper;
@@ -51,6 +53,8 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer<ExposableWebEndpoi
private final List<PathMapper> endpointPathMappers;
private final List<AdditionalPathsMapper> additionalPathsMappers;
private final RequestPredicateFactory requestPredicateFactory;
/**
@@ -61,13 +65,37 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer<ExposableWebEndpoi
* @param endpointPathMappers the endpoint path mappers
* @param invokerAdvisors invoker advisors to apply
* @param filters filters to apply
* @deprecated since 3.4.0 for removal in 3.6.0 in favor of
* {@link #WebEndpointDiscoverer(ApplicationContext, ParameterValueMapper, EndpointMediaTypes, List, List, Collection, Collection)}
*/
@Deprecated(since = "3.4.0", forRemoval = true)
public WebEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper,
EndpointMediaTypes endpointMediaTypes, List<PathMapper> endpointPathMappers,
Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> filters) {
this(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, Collections.emptyList(),
invokerAdvisors, filters);
}
/**
* Create a new {@link WebEndpointDiscoverer} instance.
* @param applicationContext the source application context
* @param parameterValueMapper the parameter value mapper
* @param endpointMediaTypes the endpoint media types
* @param endpointPathMappers the endpoint path mappers
* @param additionalPathsMappers the
* @param invokerAdvisors invoker advisors to apply
* @param filters filters to apply
* @since 3.4.0
*/
public WebEndpointDiscoverer(ApplicationContext applicationContext, ParameterValueMapper parameterValueMapper,
EndpointMediaTypes endpointMediaTypes, List<PathMapper> endpointPathMappers,
List<AdditionalPathsMapper> additionalPathsMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
Collection<EndpointFilter<ExposableWebEndpoint>> filters) {
super(applicationContext, parameterValueMapper, invokerAdvisors, filters);
this.endpointPathMappers = endpointPathMappers;
this.endpointPathMappers = (endpointPathMappers != null) ? endpointPathMappers : Collections.emptyList();
this.additionalPathsMappers = (additionalPathsMappers != null) ? additionalPathsMappers
: Collections.emptyList();
this.requestPredicateFactory = new RequestPredicateFactory(endpointMediaTypes);
}
@@ -75,7 +103,8 @@ public class WebEndpointDiscoverer extends EndpointDiscoverer<ExposableWebEndpoi
protected ExposableWebEndpoint createEndpoint(Object endpointBean, EndpointId id, boolean enabledByDefault,
Collection<WebOperation> operations) {
String rootPath = PathMapper.getRootPath(this.endpointPathMappers, id);
return new DiscoveredWebEndpoint(this, endpointBean, id, rootPath, enabledByDefault, operations);
return new DiscoveredWebEndpoint(this, endpointBean, id, rootPath, enabledByDefault, operations,
this.additionalPathsMappers);
}
@Override

View File

@@ -129,19 +129,36 @@ class PathMappedEndpointsTests {
assertThat(mapped.getEndpoint(EndpointId.of("xx"))).isNull();
}
@Test
void getAdditionalPathsShouldReturnCanonicalAdditionalPaths() {
PathMappedEndpoints mapped = createTestMapped(null);
assertThat(mapped.getAdditionalPaths(WebServerNamespace.SERVER, EndpointId.of("e2"))).containsExactly("/a2",
"/A2");
assertThat(mapped.getAdditionalPaths(WebServerNamespace.MANAGEMENT, EndpointId.of("e2"))).isEmpty();
assertThat(mapped.getAdditionalPaths(WebServerNamespace.SERVER, EndpointId.of("e3"))).isEmpty();
}
private PathMappedEndpoints createTestMapped(String basePath) {
List<ExposableEndpoint<?>> endpoints = new ArrayList<>();
endpoints.add(mockEndpoint(EndpointId.of("e1")));
endpoints.add(mockEndpoint(EndpointId.of("e2"), "p2"));
endpoints.add(mockEndpoint(EndpointId.of("e2"), "p2", WebServerNamespace.SERVER, List.of("/a2", "A2")));
endpoints.add(mockEndpoint(EndpointId.of("e3"), "p3"));
endpoints.add(mockEndpoint(EndpointId.of("e4")));
return new PathMappedEndpoints(basePath, () -> endpoints);
}
private TestPathMappedEndpoint mockEndpoint(EndpointId id, String rootPath) {
return mockEndpoint(id, rootPath, null, null);
}
private TestPathMappedEndpoint mockEndpoint(EndpointId id, String rootPath, WebServerNamespace webServerNamespace,
List<String> additionalPaths) {
TestPathMappedEndpoint endpoint = mock(TestPathMappedEndpoint.class);
given(endpoint.getEndpointId()).willReturn(id);
given(endpoint.getRootPath()).willReturn(rootPath);
if (webServerNamespace != null && additionalPaths != null) {
given(endpoint.getAdditionalPaths(webServerNamespace)).willReturn(additionalPaths);
}
return endpoint;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2024 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.
@@ -53,4 +53,9 @@ class WebServerNamespaceTests {
assertThat(WebServerNamespace.from("value")).isNotEqualTo(WebServerNamespace.from("other"));
}
@Test
void toStringReturnsString() {
assertThat(WebServerNamespace.from("value")).hasToString("value");
}
}

View File

@@ -68,7 +68,8 @@ class BaseConfiguration {
ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
DefaultConversionService.getSharedInstance());
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes,
pathMappers.orderedStream().toList(), Collections.emptyList(), Collections.emptyList());
pathMappers.orderedStream().toList(), Collections.emptyList(), Collections.emptyList(),
Collections.emptyList());
}
@Bean

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@@ -43,12 +43,14 @@ import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServic
import org.springframework.boot.actuate.endpoint.invoker.cache.CachingOperationInvoker;
import org.springframework.boot.actuate.endpoint.invoker.cache.CachingOperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.jmx.annotation.JmxEndpoint;
import org.springframework.boot.actuate.endpoint.web.AdditionalPathsMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.PathMapper;
import org.springframework.boot.actuate.endpoint.web.WebEndpointHttpMethod;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer.WebEndpointDiscovererRuntimeHints;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
@@ -223,6 +225,23 @@ class WebEndpointDiscovererTests {
});
}
@Test
void getEndpointsWhenHasAdditionalPaths() {
AdditionalPathsMapper additionalPathsMapper = (id, webServerNamespace) -> {
if (!WebServerNamespace.SERVER.equals(webServerNamespace)) {
return Collections.emptyList();
}
return List.of("/test");
};
load((id) -> null, EndpointId::toString, additionalPathsMapper,
AdditionalOperationWebEndpointConfiguration.class, (discoverer) -> {
Map<EndpointId, ExposableWebEndpoint> endpoints = mapEndpoints(discoverer.getEndpoints());
ExposableWebEndpoint endpoint = endpoints.get(EndpointId.of("test"));
assertThat(endpoint.getAdditionalPaths(WebServerNamespace.SERVER)).containsExactly("/test");
assertThat(endpoint.getAdditionalPaths(WebServerNamespace.MANAGEMENT)).isEmpty();
});
}
@Test
void shouldRegisterHints() {
RuntimeHints runtimeHints = new RuntimeHints();
@@ -230,7 +249,6 @@ class WebEndpointDiscovererTests {
assertThat(RuntimeHintsPredicates.reflection()
.onType(WebEndpointFilter.class)
.withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(runtimeHints);
}
private void load(Class<?> configuration, Consumer<WebEndpointDiscoverer> consumer) {
@@ -239,6 +257,12 @@ class WebEndpointDiscovererTests {
private void load(Function<EndpointId, Long> timeToLive, PathMapper endpointPathMapper, Class<?> configuration,
Consumer<WebEndpointDiscoverer> consumer) {
load(timeToLive, endpointPathMapper, null, configuration, consumer);
}
private void load(Function<EndpointId, Long> timeToLive, PathMapper endpointPathMapper,
AdditionalPathsMapper additionalPathsMapper, Class<?> configuration,
Consumer<WebEndpointDiscoverer> consumer) {
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(configuration)) {
ConversionServiceParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
DefaultConversionService.getSharedInstance());
@@ -246,6 +270,7 @@ class WebEndpointDiscovererTests {
Collections.singletonList("application/json"));
WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(context, parameterMapper, mediaTypes,
Collections.singletonList(endpointPathMapper),
(additionalPathsMapper != null) ? Collections.singletonList(additionalPathsMapper) : null,
Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList());
consumer.accept(discoverer);
}

View File

@@ -243,7 +243,7 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation
EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT;
WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext,
new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(),
Collections.emptyList());
Collections.emptyList(), Collections.emptyList());
Collection<Resource> resources = new JerseyEndpointResourceFactory().createEndpointResources(
new EndpointMapping("/actuator"), discoverer.getEndpoints(), endpointMediaTypes,
new EndpointLinksResolver(discoverer.getEndpoints()), true);
@@ -288,8 +288,8 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation
WebFluxEndpointHandlerMapping webEndpointReactiveHandlerMapping() {
EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT;
WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext,
new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(),
Collections.emptyList());
new ConversionServiceParameterValueMapper(), endpointMediaTypes, Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
return new WebFluxEndpointHandlerMapping(new EndpointMapping("/actuator"), discoverer.getEndpoints(),
endpointMediaTypes, new CorsConfiguration(), new EndpointLinksResolver(discoverer.getEndpoints()),
true);
@@ -317,8 +317,8 @@ class WebEndpointTestInvocationContextProvider implements TestTemplateInvocation
WebMvcEndpointHandlerMapping webEndpointServletHandlerMapping() {
EndpointMediaTypes endpointMediaTypes = EndpointMediaTypes.DEFAULT;
WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(this.applicationContext,
new ConversionServiceParameterValueMapper(), endpointMediaTypes, null, Collections.emptyList(),
Collections.emptyList());
new ConversionServiceParameterValueMapper(), endpointMediaTypes, Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
return new WebMvcEndpointHandlerMapping(new EndpointMapping("/actuator"), discoverer.getEndpoints(),
endpointMediaTypes, new CorsConfiguration(), new EndpointLinksResolver(discoverer.getEndpoints()),
true);