From 890ea153bf8150f9cbda7845b1d69fd84c76c1df Mon Sep 17 00:00:00 2001 From: Phillip Webb Date: Wed, 31 Jul 2019 12:33:59 +0100 Subject: [PATCH] Allow endpoint @Selector to capture all paths Update `@Selector` with a `match` attribute that can be used to select all remaining path segments. An endpoint method like this: select(@Selector(match = Match.ALL_REMAINING) String... selection) Will now have all reaming path segments injected into the `selection` parameter. Closes gh-17743 --- .../actuate/endpoint/annotation/Selector.java | 32 +++++- .../web/WebOperationRequestPredicate.java | 30 +++++- .../annotation/RequestPredicateFactory.java | 44 ++++++-- .../jersey/JerseyEndpointResourceFactory.java | 31 +++++- ...AbstractWebFluxEndpointHandlerMapping.java | 28 ++++- .../AbstractWebMvcEndpointHandlerMapping.java | 47 +++++++- .../WebOperationRequestPredicateTests.java | 26 +++++ .../AbstractWebEndpointIntegrationTests.java | 37 +++++++ .../RequestPredicateFactoryTests.java | 100 ++++++++++++++++++ .../asciidoc/production-ready-features.adoc | 6 +- 10 files changed, 356 insertions(+), 25 deletions(-) create mode 100644 spring-boot-project/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/web/annotation/RequestPredicateFactoryTests.java 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: