diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/Selector.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/Selector.java
index 16fe09c4f13..1176be1dad2 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/Selector.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/annotation/Selector.java
@@ -23,8 +23,11 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
- * A {@code Selector} can be used on a parameter of an {@link Endpoint @Endpoint} method
+ * A {@code @Selector} can be used on a parameter of an {@link Endpoint @Endpoint} method
* to indicate that the parameter is used to select a subset of the endpoint's data.
+ *
+ * A {@code @Selector} may change the way that the endpoint is exposed to the user. For
+ * example, HTTP mapped endpoints will map select parameters to path variables.
*
* @author Andy Wilkinson
* @since 2.0.0
@@ -34,4 +37,31 @@ import java.lang.annotation.Target;
@Documented
public @interface Selector {
+ /**
+ * The match type that should be used for the selection.
+ * @return the match type
+ * @since 2.2.0
+ */
+ Match match() default Match.SINGLE;
+
+ /**
+ * Match types that can be used with the {@code @Selector}.
+ */
+ enum Match {
+
+ /**
+ * Capture a single item. For example, in the case of a web application a single
+ * path segment. The parameter value be converted from a {@code String} source.
+ */
+ SINGLE,
+
+ /**
+ * Capture all remaining times. For example, in the case of a web application all
+ * remaining path segments. The parameter value be converted from a
+ * {@code String[]} source.
+ */
+ ALL_REMAINING
+
+ }
+
}
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/WebOperationRequestPredicate.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/WebOperationRequestPredicate.java
index 50cf14dc6b1..28b3cad89e8 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/WebOperationRequestPredicate.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/WebOperationRequestPredicate.java
@@ -18,6 +18,7 @@ package org.springframework.boot.actuate.endpoint.web;
import java.util.Collection;
import java.util.Collections;
+import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.util.CollectionUtils;
@@ -31,10 +32,14 @@ import org.springframework.util.StringUtils;
*/
public final class WebOperationRequestPredicate {
- private static final Pattern PATH_VAR_PATTERN = Pattern.compile("\\{.*?}");
+ private static final Pattern PATH_VAR_PATTERN = Pattern.compile("(\\{\\*?).+?}");
+
+ private static final Pattern ALL_REMAINING_PATH_SEGMENTS_VAR_PATTERN = Pattern.compile("^.*\\{\\*(.+?)}$");
private final String path;
+ private final String matchAllRemainingPathSegmentsVariable;
+
private final String canonicalPath;
private final WebEndpointHttpMethod httpMethod;
@@ -53,12 +58,23 @@ public final class WebOperationRequestPredicate {
public WebOperationRequestPredicate(String path, WebEndpointHttpMethod httpMethod, Collection consumes,
Collection produces) {
this.path = path;
- this.canonicalPath = PATH_VAR_PATTERN.matcher(path).replaceAll("{*}");
+ this.canonicalPath = extractCanonicalPath(path);
+ this.matchAllRemainingPathSegmentsVariable = extractMatchAllRemainingPathSegmentsVariable(path);
this.httpMethod = httpMethod;
this.consumes = consumes;
this.produces = produces;
}
+ private String extractCanonicalPath(String path) {
+ Matcher matcher = PATH_VAR_PATTERN.matcher(path);
+ return matcher.replaceAll("$1*}");
+ }
+
+ private String extractMatchAllRemainingPathSegmentsVariable(String path) {
+ Matcher matcher = ALL_REMAINING_PATH_SEGMENTS_VAR_PATTERN.matcher(path);
+ return matcher.matches() ? matcher.group(1) : null;
+ }
+
/**
* Returns the path for the operation.
* @return the path
@@ -67,6 +83,16 @@ public final class WebOperationRequestPredicate {
return this.path;
}
+ /**
+ * Returns the name of the variable used to catch all remaining path segments
+ * {@code null}.
+ * @return the variable name
+ * @since 2.2.0
+ */
+ public String getMatchAllRemainingPathSegmentsVariable() {
+ return this.matchAllRemainingPathSegmentsVariable;
+ }
+
/**
* Returns the HTTP method for the operation.
* @return the HTTP method
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RequestPredicateFactory.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RequestPredicateFactory.java
index 23bd5cf6738..e4521148c32 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RequestPredicateFactory.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/annotation/RequestPredicateFactory.java
@@ -18,14 +18,15 @@ package org.springframework.boot.actuate.endpoint.web.annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
-import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.springframework.boot.actuate.endpoint.OperationType;
import org.springframework.boot.actuate.endpoint.annotation.DiscoveredOperationMethod;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
+import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.WebEndpointHttpMethod;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
@@ -52,24 +53,49 @@ class RequestPredicateFactory {
WebOperationRequestPredicate getRequestPredicate(String rootPath, DiscoveredOperationMethod operationMethod) {
Method method = operationMethod.getMethod();
- String path = getPath(rootPath, method);
+ Parameter[] selectorParameters = Arrays.stream(method.getParameters()).filter(this::hasSelector)
+ .toArray(Parameter[]::new);
+ Parameter allRemainingPathSegmentsParameter = getAllRemainingPathSegmentsParameter(selectorParameters);
+ String path = getPath(rootPath, selectorParameters, allRemainingPathSegmentsParameter != null);
WebEndpointHttpMethod httpMethod = determineHttpMethod(operationMethod.getOperationType());
Collection consumes = getConsumes(httpMethod, method);
Collection produces = getProduces(operationMethod, method);
return new WebOperationRequestPredicate(path, httpMethod, consumes, produces);
}
- private String getPath(String rootPath, Method method) {
- return rootPath + Stream.of(method.getParameters()).filter(this::hasSelector).map(this::slashName)
- .collect(Collectors.joining());
+ private Parameter getAllRemainingPathSegmentsParameter(Parameter[] selectorParameters) {
+ Parameter trailingPathsParameter = null;
+ for (int i = 0; i < selectorParameters.length; i++) {
+ Parameter selectorParameter = selectorParameters[i];
+ Selector selector = selectorParameter.getAnnotation(Selector.class);
+ if (selector.match() == Match.ALL_REMAINING) {
+ Assert.state(trailingPathsParameter == null,
+ "@Selector annotation with Match.ALL_REMAINING must be unique");
+ trailingPathsParameter = selectorParameter;
+ }
+ }
+ if (trailingPathsParameter != null) {
+ Assert.state(trailingPathsParameter == selectorParameters[selectorParameters.length - 1],
+ "@Selector annotation with Match.ALL_REMAINING must be the last parameter");
+ }
+ return trailingPathsParameter;
}
- private boolean hasSelector(Parameter parameter) {
- return parameter.getAnnotation(Selector.class) != null;
+ private String getPath(String rootPath, Parameter[] selectorParameters, boolean matchRemainingPathSegments) {
+ StringBuilder path = new StringBuilder(rootPath);
+ for (int i = 0; i < selectorParameters.length; i++) {
+ path.append("/{");
+ if (i == selectorParameters.length - 1 && matchRemainingPathSegments) {
+ path.append("*");
+ }
+ path.append(selectorParameters[i].getName());
+ path.append("}");
+ }
+ return path.toString();
}
- private String slashName(Parameter parameter) {
- return "/{" + parameter.getName() + "}";
+ private boolean hasSelector(Parameter parameter) {
+ return parameter.getAnnotation(Selector.class) != null;
}
private Collection getConsumes(WebEndpointHttpMethod httpMethod, Method method) {
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java
index ed6f61df95f..bb6009e125e 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/jersey/JerseyEndpointResourceFactory.java
@@ -18,6 +18,7 @@ package org.springframework.boot.actuate.endpoint.web.jersey;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.ArrayList;
import java.util.Collection;
@@ -50,6 +51,7 @@ import org.springframework.boot.actuate.endpoint.web.Link;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
+import org.springframework.util.AntPathMatcher;
import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
@@ -90,7 +92,13 @@ public class JerseyEndpointResourceFactory {
private Resource createResource(EndpointMapping endpointMapping, WebOperation operation) {
WebOperationRequestPredicate requestPredicate = operation.getRequestPredicate();
- Builder resourceBuilder = Resource.builder().path(endpointMapping.createSubPath(requestPredicate.getPath()));
+ String path = requestPredicate.getPath();
+ String matchAllRemainingPathSegmentsVariable = requestPredicate.getMatchAllRemainingPathSegmentsVariable();
+ if (matchAllRemainingPathSegmentsVariable != null) {
+ path = path.replace("{*" + matchAllRemainingPathSegmentsVariable + "}",
+ "{" + matchAllRemainingPathSegmentsVariable + ": .*}");
+ }
+ Builder resourceBuilder = Resource.builder().path(endpointMapping.createSubPath(path));
resourceBuilder.addMethod(requestPredicate.getHttpMethod().name())
.consumes(StringUtils.toStringArray(requestPredicate.getConsumes()))
.produces(StringUtils.toStringArray(requestPredicate.getProduces()))
@@ -111,6 +119,8 @@ public class JerseyEndpointResourceFactory {
*/
private static final class OperationInflector implements Inflector {
+ private static final String PATH_SEPARATOR = AntPathMatcher.DEFAULT_PATH_SEPARATOR;
+
private static final List> BODY_CONVERTERS;
static {
@@ -159,7 +169,24 @@ public class JerseyEndpointResourceFactory {
}
private Map extractPathParameters(ContainerRequestContext requestContext) {
- return extract(requestContext.getUriInfo().getPathParameters());
+ Map pathParameters = extract(requestContext.getUriInfo().getPathParameters());
+ String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate()
+ .getMatchAllRemainingPathSegmentsVariable();
+ if (matchAllRemainingPathSegmentsVariable != null) {
+ String remainingPathSegments = (String) pathParameters.get(matchAllRemainingPathSegmentsVariable);
+ pathParameters.put(matchAllRemainingPathSegmentsVariable, tokenizePathSegments(remainingPathSegments));
+ }
+ return pathParameters;
+ }
+
+ private String[] tokenizePathSegments(String path) {
+ String[] segments = StringUtils.tokenizeToStringArray(path, PATH_SEPARATOR, false, true);
+ for (int i = 0; i < segments.length; i++) {
+ if (segments[i].contains("%")) {
+ segments[i] = StringUtils.uriDecode(segments[i], StandardCharsets.UTF_8);
+ }
+ }
+ return segments;
}
private Map extractQueryParameters(ContainerRequestContext requestContext) {
diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java
index 62965ecf090..b29f1d90443 100644
--- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java
+++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java
@@ -17,6 +17,7 @@
package org.springframework.boot.actuate.endpoint.web.reactive;
import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.Collection;
import java.util.Collections;
@@ -47,6 +48,7 @@ import org.springframework.security.access.SecurityConfig;
import org.springframework.security.access.vote.RoleVoter;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
+import org.springframework.util.AntPathMatcher;
import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
@@ -264,15 +266,17 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi
*/
private static final class ReactiveWebOperationAdapter implements ReactiveWebOperation {
- private final OperationInvoker invoker;
+ private static final String PATH_SEPARATOR = AntPathMatcher.DEFAULT_PATH_SEPARATOR;
+
+ private final WebOperation operation;
- private final String operationId;
+ private final OperationInvoker invoker;
private final Supplier> securityContextSupplier;
private ReactiveWebOperationAdapter(WebOperation operation) {
+ this.operation = operation;
this.invoker = getInvoker(operation);
- this.operationId = operation.getId();
this.securityContextSupplier = getSecurityContextSupplier();
}
@@ -305,12 +309,28 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi
@Override
public Mono> handle(ServerWebExchange exchange, Map body) {
Map arguments = getArguments(exchange, body);
+ String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate()
+ .getMatchAllRemainingPathSegmentsVariable();
+ if (matchAllRemainingPathSegmentsVariable != null) {
+ arguments.put(matchAllRemainingPathSegmentsVariable,
+ tokenizePathSegments((String) arguments.get(matchAllRemainingPathSegmentsVariable)));
+ }
return this.securityContextSupplier.get()
.map((securityContext) -> new InvocationContext(securityContext, arguments))
.flatMap((invocationContext) -> handleResult((Publisher>) this.invoker.invoke(invocationContext),
exchange.getRequest().getMethod()));
}
+ private String[] tokenizePathSegments(String path) {
+ String[] segments = StringUtils.tokenizeToStringArray(path, PATH_SEPARATOR, false, true);
+ for (int i = 0; i < segments.length; i++) {
+ if (segments[i].contains("%")) {
+ segments[i] = StringUtils.uriDecode(segments[i], StandardCharsets.UTF_8);
+ }
+ }
+ return segments;
+ }
+
private Map getArguments(ServerWebExchange exchange, Map body) {
Map arguments = new LinkedHashMap<>();
arguments.putAll(getTemplateVariables(exchange));
@@ -345,7 +365,7 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi
@Override
public String toString() {
- return "Actuator web endpoint '" + this.operationId + "'";
+ return "Actuator web endpoint '" + this.operation.getId() + "'";
}
}
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 8c4783b2d0d..c26c211c67a 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
@@ -17,6 +17,7 @@
package org.springframework.boot.actuate.endpoint.web.servlet;
import java.lang.reflect.Method;
+import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.util.Arrays;
import java.util.Collection;
@@ -42,6 +43,8 @@ import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicat
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
@@ -162,9 +165,15 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
}
private void registerMappingForOperation(ExposableWebEndpoint endpoint, WebOperation operation) {
+ WebOperationRequestPredicate predicate = operation.getRequestPredicate();
+ String path = predicate.getPath();
+ String matchAllRemainingPathSegmentsVariable = predicate.getMatchAllRemainingPathSegmentsVariable();
+ if (matchAllRemainingPathSegmentsVariable != null) {
+ path = path.replace("{*" + matchAllRemainingPathSegmentsVariable + "}", "**");
+ }
ServletWebOperation servletWebOperation = wrapServletWebOperation(endpoint, operation,
new ServletWebOperationAdapter(operation));
- registerMapping(createRequestMappingInfo(operation), new OperationHandler(servletWebOperation),
+ registerMapping(createRequestMappingInfo(predicate, path), new OperationHandler(servletWebOperation),
this.handleMethod);
}
@@ -181,9 +190,8 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
return servletWebOperation;
}
- private RequestMappingInfo createRequestMappingInfo(WebOperation operation) {
- WebOperationRequestPredicate predicate = operation.getRequestPredicate();
- PatternsRequestCondition patterns = patternsRequestConditionForPattern(predicate.getPath());
+ private RequestMappingInfo createRequestMappingInfo(WebOperationRequestPredicate predicate, String path) {
+ PatternsRequestCondition patterns = patternsRequestConditionForPattern(path);
RequestMethodsRequestCondition methods = new RequestMethodsRequestCondition(
RequestMethod.valueOf(predicate.getHttpMethod().name()));
ConsumesRequestCondition consumes = new ConsumesRequestCondition(
@@ -275,6 +283,8 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
*/
private class ServletWebOperationAdapter implements ServletWebOperation {
+ private static final String PATH_SEPARATOR = AntPathMatcher.DEFAULT_PATH_SEPARATOR;
+
private final WebOperation operation;
ServletWebOperationAdapter(WebOperation operation) {
@@ -302,6 +312,11 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
private Map getArguments(HttpServletRequest request, Map body) {
Map arguments = new LinkedHashMap<>();
arguments.putAll(getTemplateVariables(request));
+ String matchAllRemainingPathSegmentsVariable = this.operation.getRequestPredicate()
+ .getMatchAllRemainingPathSegmentsVariable();
+ if (matchAllRemainingPathSegmentsVariable != null) {
+ arguments.put(matchAllRemainingPathSegmentsVariable, getRemainingPathSegments(request));
+ }
if (body != null && HttpMethod.POST.name().equals(request.getMethod())) {
arguments.putAll(body);
}
@@ -310,6 +325,30 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
return arguments;
}
+ private Object getRemainingPathSegments(HttpServletRequest request) {
+ String[] pathTokens = tokenize(request, HandlerMapping.LOOKUP_PATH, 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;
+ }
+
+ private String[] tokenize(HttpServletRequest request, String attributeName, boolean decode) {
+ String value = (String) request.getAttribute(attributeName);
+ 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);
+ }
+ }
+ }
+ return segments;
+ }
+
@SuppressWarnings("unchecked")
private Map getTemplateVariables(HttpServletRequest request) {
return (Map) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/WebOperationRequestPredicateTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/WebOperationRequestPredicateTests.java
index a2e0006b1f3..9ce6f80d419 100644
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/WebOperationRequestPredicateTests.java
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/WebOperationRequestPredicateTests.java
@@ -26,6 +26,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link WebOperationRequestPredicate}.
*
* @author Andy Wilkinson
+ * @author Phillip Webb
*/
class WebOperationRequestPredicateTests {
@@ -54,12 +55,37 @@ class WebOperationRequestPredicateTests {
assertThat(predicateWithPath("/path/{foo1}")).isEqualTo(predicateWithPath("/path/{foo2}"));
}
+ @Test
+ void predicatesWithSingleWildcardPathVariablesInTheSamplePlaceAreEqual() {
+ assertThat(predicateWithPath("/path/{*foo1}")).isEqualTo(predicateWithPath("/path/{*foo2}"));
+ }
+
+ @Test
+ void predicatesWithSingleWildcardPathVariableAndRegularVariableInTheSamplePlaceAreNotEqual() {
+ assertThat(predicateWithPath("/path/{*foo1}")).isNotEqualTo(predicateWithPath("/path/{foo2}"));
+ }
+
@Test
void predicatesWithMultiplePathVariablesInTheSamplePlaceAreEqual() {
assertThat(predicateWithPath("/path/{foo1}/more/{bar1}"))
.isEqualTo(predicateWithPath("/path/{foo2}/more/{bar2}"));
}
+ @Test
+ void predicateWithWildcardPathVariableReturnsMatchAllRemainingPathSegmentsVariable() {
+ assertThat(predicateWithPath("/path/{*foo1}").getMatchAllRemainingPathSegmentsVariable()).isEqualTo("foo1");
+ }
+
+ @Test
+ void predicateWithRegularPathVariableDoesNotReturnMatchAllRemainingPathSegmentsVariable() {
+ assertThat(predicateWithPath("/path/{foo1}").getMatchAllRemainingPathSegmentsVariable()).isNull();
+ }
+
+ @Test
+ void predicateWithNoPathVariableDoesNotReturnMatchAllRemainingPathSegmentsVariable() {
+ assertThat(predicateWithPath("/path/foo1").getMatchAllRemainingPathSegmentsVariable()).isNull();
+ }
+
private WebOperationRequestPredicate predicateWithPath(String path) {
return new WebOperationRequestPredicate(path, WebEndpointHttpMethod.GET, Collections.emptyList(),
Collections.emptyList());
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java
index 5f2f305a4fb..5054be7e95f 100644
--- a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/AbstractWebEndpointIntegrationTests.java
@@ -35,6 +35,7 @@ import org.springframework.boot.actuate.endpoint.annotation.DeleteOperation;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector;
+import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.context.ApplicationContext;
@@ -50,6 +51,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.lang.Nullable;
import org.springframework.test.web.reactive.server.WebTestClient;
+import org.springframework.util.StringUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
@@ -124,6 +126,20 @@ public abstract class AbstractWebEndpointIntegrationTests client.get().uri("/matchallremaining/one/two/three").exchange().expectStatus().isOk()
+ .expectBody().jsonPath("selection").isEqualTo("one|two|three"));
+ }
+
+ @Test
+ void matchAllRemainingPathsSelectorShouldDecodePath() {
+ load(MatchAllRemainingEndpointConfiguration.class,
+ (client) -> client.get().uri("/matchallremaining/one/two%20three/").exchange().expectStatus().isOk()
+ .expectBody().jsonPath("selection").isEqualTo("one|two three"));
+ }
+
@Test
void readOperationWithSingleQueryParameters() {
load(QueryEndpointConfiguration.class, (client) -> client.get().uri("/query?one=1&two=2").exchange()
@@ -418,6 +434,17 @@ public abstract class AbstractWebEndpointIntegrationTests select(@Selector(match = Match.ALL_REMAINING) String... selection) {
+ return Collections.singletonMap("selection", StringUtils.arrayToDelimitedString(selection, "|"));
+ }
+
+ }
+
@Endpoint(id = "query")
static class QueryEndpoint {
diff --git a/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/RequestPredicateFactoryTests.java b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/RequestPredicateFactoryTests.java
new file mode 100644
index 00000000000..265af631978
--- /dev/null
+++ b/spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/RequestPredicateFactoryTests.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2012-2019 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.annotation;
+
+import java.lang.reflect.Method;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.boot.actuate.endpoint.OperationType;
+import org.springframework.boot.actuate.endpoint.annotation.DiscoveredOperationMethod;
+import org.springframework.boot.actuate.endpoint.annotation.Selector;
+import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
+import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
+import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
+import org.springframework.core.annotation.AnnotationAttributes;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+
+/**
+ * Tests for {@link RequestPredicateFactory}.
+ *
+ * @author Phillip Webb
+ */
+class RequestPredicateFactoryTests {
+
+ private final RequestPredicateFactory factory = new RequestPredicateFactory(
+ new EndpointMediaTypes(Collections.emptyList(), Collections.emptyList()));
+
+ private String rootPath = "/root";
+
+ @Test
+ void getRequestPredicateWhenHasMoreThanOneMatchAllThrowsException() {
+ DiscoveredOperationMethod operationMethod = getDiscoveredOperationMethod(MoreThanOneMatchAll.class);
+ assertThatIllegalStateException()
+ .isThrownBy(() -> this.factory.getRequestPredicate(this.rootPath, operationMethod))
+ .withMessage("@Selector annotation with Match.ALL_REMAINING must be unique");
+ }
+
+ @Test
+ void getRequestPredicateWhenMatchAllIsNotLastParameterThrowsException() {
+ DiscoveredOperationMethod operationMethod = getDiscoveredOperationMethod(MatchAllIsNotLastParameter.class);
+ assertThatIllegalStateException()
+ .isThrownBy(() -> this.factory.getRequestPredicate(this.rootPath, operationMethod))
+ .withMessage("@Selector annotation with Match.ALL_REMAINING must be the last parameter");
+ }
+
+ @Test
+ void getRequestPredicateReturnsRedicateWithPath() {
+ DiscoveredOperationMethod operationMethod = getDiscoveredOperationMethod(ValidSelectors.class);
+ WebOperationRequestPredicate requestPredicate = this.factory.getRequestPredicate(this.rootPath,
+ operationMethod);
+ assertThat(requestPredicate.getPath()).isEqualTo("/root/{one}/{*two}");
+ }
+
+ private DiscoveredOperationMethod getDiscoveredOperationMethod(Class> source) {
+ Method method = source.getDeclaredMethods()[0];
+ AnnotationAttributes attributes = new AnnotationAttributes();
+ attributes.put("produces", "application/json");
+ return new DiscoveredOperationMethod(method, OperationType.READ, attributes);
+ }
+
+ static class MoreThanOneMatchAll {
+
+ void test(@Selector(match = Match.ALL_REMAINING) String[] one,
+ @Selector(match = Match.ALL_REMAINING) String[] two) {
+ }
+
+ }
+
+ static class MatchAllIsNotLastParameter {
+
+ void test(@Selector(match = Match.ALL_REMAINING) String[] one, @Selector String[] two) {
+ }
+
+ }
+
+ static class ValidSelectors {
+
+ void test(@Selector String[] one, @Selector(match = Match.ALL_REMAINING) String[] two) {
+ }
+
+ }
+
+}
diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc
index b8597eb19b3..de378ddd35b 100644
--- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc
+++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/production-ready-features.adoc
@@ -577,7 +577,6 @@ endpoint.
[[production-ready-endpoints-custom-web-predicate-path]]
===== Path
-
The path of the predicate is determined by the ID of the endpoint and the base path of
web-exposed endpoints. The default base path is `/actuator`. For example, an endpoint with
the ID `sessions` will use `/actuator/sessions` as its path in the predicate.
@@ -585,13 +584,14 @@ the ID `sessions` will use `/actuator/sessions` as its path in the predicate.
The path can be further customized by annotating one or more parameters of the operation
method with `@Selector`. Such a parameter is added to the path predicate as a path
variable. The variable's value is passed into the operation method when the endpoint
-operation is invoked.
+operation is invoked. If you want to capture all remaining path elements, you can add
+`@Selector(Match=ALL_REMAINING)` to the last parameter and make it a type that is
+conversion compatible with a `String[]`.
[[production-ready-endpoints-custom-web-predicate-http-method]]
===== HTTP method
-
The HTTP method of the predicate is determined by the operation type, as shown in
the following table: