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
pull/17761/head
Phillip Webb 5 years ago
parent b8bda1c03d
commit 890ea153bf

@ -23,8 +23,11 @@ import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target; 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. * to indicate that the parameter is used to select a subset of the endpoint's data.
* <p>
* 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 * @author Andy Wilkinson
* @since 2.0.0 * @since 2.0.0
@ -34,4 +37,31 @@ import java.lang.annotation.Target;
@Documented @Documented
public @interface Selector { 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
}
} }

@ -18,6 +18,7 @@ package org.springframework.boot.actuate.endpoint.web;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
@ -31,10 +32,14 @@ import org.springframework.util.StringUtils;
*/ */
public final class WebOperationRequestPredicate { 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 path;
private final String matchAllRemainingPathSegmentsVariable;
private final String canonicalPath; private final String canonicalPath;
private final WebEndpointHttpMethod httpMethod; private final WebEndpointHttpMethod httpMethod;
@ -53,12 +58,23 @@ public final class WebOperationRequestPredicate {
public WebOperationRequestPredicate(String path, WebEndpointHttpMethod httpMethod, Collection<String> consumes, public WebOperationRequestPredicate(String path, WebEndpointHttpMethod httpMethod, Collection<String> consumes,
Collection<String> produces) { Collection<String> produces) {
this.path = path; this.path = path;
this.canonicalPath = PATH_VAR_PATTERN.matcher(path).replaceAll("{*}"); this.canonicalPath = extractCanonicalPath(path);
this.matchAllRemainingPathSegmentsVariable = extractMatchAllRemainingPathSegmentsVariable(path);
this.httpMethod = httpMethod; this.httpMethod = httpMethod;
this.consumes = consumes; this.consumes = consumes;
this.produces = produces; 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. * Returns the path for the operation.
* @return the path * @return the path
@ -67,6 +83,16 @@ public final class WebOperationRequestPredicate {
return this.path; 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. * Returns the HTTP method for the operation.
* @return the HTTP method * @return the HTTP method

@ -18,14 +18,15 @@ package org.springframework.boot.actuate.endpoint.web.annotation;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.lang.reflect.Parameter; import java.lang.reflect.Parameter;
import java.util.Arrays;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import org.springframework.boot.actuate.endpoint.OperationType; import org.springframework.boot.actuate.endpoint.OperationType;
import org.springframework.boot.actuate.endpoint.annotation.DiscoveredOperationMethod; import org.springframework.boot.actuate.endpoint.annotation.DiscoveredOperationMethod;
import org.springframework.boot.actuate.endpoint.annotation.Selector; 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.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.WebEndpointHttpMethod; import org.springframework.boot.actuate.endpoint.web.WebEndpointHttpMethod;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
@ -52,24 +53,49 @@ class RequestPredicateFactory {
WebOperationRequestPredicate getRequestPredicate(String rootPath, DiscoveredOperationMethod operationMethod) { WebOperationRequestPredicate getRequestPredicate(String rootPath, DiscoveredOperationMethod operationMethod) {
Method method = operationMethod.getMethod(); 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()); WebEndpointHttpMethod httpMethod = determineHttpMethod(operationMethod.getOperationType());
Collection<String> consumes = getConsumes(httpMethod, method); Collection<String> consumes = getConsumes(httpMethod, method);
Collection<String> produces = getProduces(operationMethod, method); Collection<String> produces = getProduces(operationMethod, method);
return new WebOperationRequestPredicate(path, httpMethod, consumes, produces); return new WebOperationRequestPredicate(path, httpMethod, consumes, produces);
} }
private String getPath(String rootPath, Method method) { private Parameter getAllRemainingPathSegmentsParameter(Parameter[] selectorParameters) {
return rootPath + Stream.of(method.getParameters()).filter(this::hasSelector).map(this::slashName) Parameter trailingPathsParameter = null;
.collect(Collectors.joining()); 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) { private String getPath(String rootPath, Parameter[] selectorParameters, boolean matchRemainingPathSegments) {
return parameter.getAnnotation(Selector.class) != null; 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) { private boolean hasSelector(Parameter parameter) {
return "/{" + parameter.getName() + "}"; return parameter.getAnnotation(Selector.class) != null;
} }
private Collection<String> getConsumes(WebEndpointHttpMethod httpMethod, Method method) { private Collection<String> getConsumes(WebEndpointHttpMethod httpMethod, Method method) {

@ -18,6 +18,7 @@ package org.springframework.boot.actuate.endpoint.web.jersey;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.Principal; import java.security.Principal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection; 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.WebEndpointResponse;
import org.springframework.boot.actuate.endpoint.web.WebOperation; import org.springframework.boot.actuate.endpoint.web.WebOperation;
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate; import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@ -90,7 +92,13 @@ public class JerseyEndpointResourceFactory {
private Resource createResource(EndpointMapping endpointMapping, WebOperation operation) { private Resource createResource(EndpointMapping endpointMapping, WebOperation operation) {
WebOperationRequestPredicate requestPredicate = operation.getRequestPredicate(); 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()) resourceBuilder.addMethod(requestPredicate.getHttpMethod().name())
.consumes(StringUtils.toStringArray(requestPredicate.getConsumes())) .consumes(StringUtils.toStringArray(requestPredicate.getConsumes()))
.produces(StringUtils.toStringArray(requestPredicate.getProduces())) .produces(StringUtils.toStringArray(requestPredicate.getProduces()))
@ -111,6 +119,8 @@ public class JerseyEndpointResourceFactory {
*/ */
private static final class OperationInflector implements Inflector<ContainerRequestContext, Object> { private static final class OperationInflector implements Inflector<ContainerRequestContext, Object> {
private static final String PATH_SEPARATOR = AntPathMatcher.DEFAULT_PATH_SEPARATOR;
private static final List<Function<Object, Object>> BODY_CONVERTERS; private static final List<Function<Object, Object>> BODY_CONVERTERS;
static { static {
@ -159,7 +169,24 @@ public class JerseyEndpointResourceFactory {
} }
private Map<String, Object> extractPathParameters(ContainerRequestContext requestContext) { private Map<String, Object> extractPathParameters(ContainerRequestContext requestContext) {
return extract(requestContext.getUriInfo().getPathParameters()); Map<String, Object> 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<String, Object> extractQueryParameters(ContainerRequestContext requestContext) { private Map<String, Object> extractQueryParameters(ContainerRequestContext requestContext) {

@ -17,6 +17,7 @@
package org.springframework.boot.actuate.endpoint.web.reactive; package org.springframework.boot.actuate.endpoint.web.reactive;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.security.Principal; import java.security.Principal;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; 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.access.vote.RoleVoter;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.ClassUtils; import org.springframework.util.ClassUtils;
import org.springframework.util.ReflectionUtils; import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
@ -264,15 +266,17 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi
*/ */
private static final class ReactiveWebOperationAdapter implements ReactiveWebOperation { 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<Mono<? extends SecurityContext>> securityContextSupplier; private final Supplier<Mono<? extends SecurityContext>> securityContextSupplier;
private ReactiveWebOperationAdapter(WebOperation operation) { private ReactiveWebOperationAdapter(WebOperation operation) {
this.operation = operation;
this.invoker = getInvoker(operation); this.invoker = getInvoker(operation);
this.operationId = operation.getId();
this.securityContextSupplier = getSecurityContextSupplier(); this.securityContextSupplier = getSecurityContextSupplier();
} }
@ -305,12 +309,28 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi
@Override @Override
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body) { public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body) {
Map<String, Object> arguments = getArguments(exchange, body); Map<String, Object> 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() return this.securityContextSupplier.get()
.map((securityContext) -> new InvocationContext(securityContext, arguments)) .map((securityContext) -> new InvocationContext(securityContext, arguments))
.flatMap((invocationContext) -> handleResult((Publisher<?>) this.invoker.invoke(invocationContext), .flatMap((invocationContext) -> handleResult((Publisher<?>) this.invoker.invoke(invocationContext),
exchange.getRequest().getMethod())); 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<String, Object> getArguments(ServerWebExchange exchange, Map<String, String> body) { private Map<String, Object> getArguments(ServerWebExchange exchange, Map<String, String> body) {
Map<String, Object> arguments = new LinkedHashMap<>(); Map<String, Object> arguments = new LinkedHashMap<>();
arguments.putAll(getTemplateVariables(exchange)); arguments.putAll(getTemplateVariables(exchange));
@ -345,7 +365,7 @@ public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappi
@Override @Override
public String toString() { public String toString() {
return "Actuator web endpoint '" + this.operationId + "'"; return "Actuator web endpoint '" + this.operation.getId() + "'";
} }
} }

@ -17,6 +17,7 @@
package org.springframework.boot.actuate.endpoint.web.servlet; package org.springframework.boot.actuate.endpoint.web.servlet;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.security.Principal; import java.security.Principal;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection; 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.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.Assert;
import org.springframework.util.ReflectionUtils; import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@ -162,9 +165,15 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
} }
private void registerMappingForOperation(ExposableWebEndpoint endpoint, WebOperation operation) { 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, ServletWebOperation servletWebOperation = wrapServletWebOperation(endpoint, operation,
new ServletWebOperationAdapter(operation)); new ServletWebOperationAdapter(operation));
registerMapping(createRequestMappingInfo(operation), new OperationHandler(servletWebOperation), registerMapping(createRequestMappingInfo(predicate, path), new OperationHandler(servletWebOperation),
this.handleMethod); this.handleMethod);
} }
@ -181,9 +190,8 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
return servletWebOperation; return servletWebOperation;
} }
private RequestMappingInfo createRequestMappingInfo(WebOperation operation) { private RequestMappingInfo createRequestMappingInfo(WebOperationRequestPredicate predicate, String path) {
WebOperationRequestPredicate predicate = operation.getRequestPredicate(); PatternsRequestCondition patterns = patternsRequestConditionForPattern(path);
PatternsRequestCondition patterns = patternsRequestConditionForPattern(predicate.getPath());
RequestMethodsRequestCondition methods = new RequestMethodsRequestCondition( RequestMethodsRequestCondition methods = new RequestMethodsRequestCondition(
RequestMethod.valueOf(predicate.getHttpMethod().name())); RequestMethod.valueOf(predicate.getHttpMethod().name()));
ConsumesRequestCondition consumes = new ConsumesRequestCondition( ConsumesRequestCondition consumes = new ConsumesRequestCondition(
@ -275,6 +283,8 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
*/ */
private class ServletWebOperationAdapter implements ServletWebOperation { private class ServletWebOperationAdapter implements ServletWebOperation {
private static final String PATH_SEPARATOR = AntPathMatcher.DEFAULT_PATH_SEPARATOR;
private final WebOperation operation; private final WebOperation operation;
ServletWebOperationAdapter(WebOperation operation) { ServletWebOperationAdapter(WebOperation operation) {
@ -302,6 +312,11 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
private Map<String, Object> getArguments(HttpServletRequest request, Map<String, String> body) { private Map<String, Object> getArguments(HttpServletRequest request, Map<String, String> body) {
Map<String, Object> arguments = new LinkedHashMap<>(); Map<String, Object> arguments = new LinkedHashMap<>();
arguments.putAll(getTemplateVariables(request)); 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())) { if (body != null && HttpMethod.POST.name().equals(request.getMethod())) {
arguments.putAll(body); arguments.putAll(body);
} }
@ -310,6 +325,30 @@ public abstract class AbstractWebMvcEndpointHandlerMapping extends RequestMappin
return arguments; 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") @SuppressWarnings("unchecked")
private Map<String, String> getTemplateVariables(HttpServletRequest request) { private Map<String, String> getTemplateVariables(HttpServletRequest request) {
return (Map<String, String>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE); return (Map<String, String>) request.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE);

@ -26,6 +26,7 @@ import static org.assertj.core.api.Assertions.assertThat;
* Tests for {@link WebOperationRequestPredicate}. * Tests for {@link WebOperationRequestPredicate}.
* *
* @author Andy Wilkinson * @author Andy Wilkinson
* @author Phillip Webb
*/ */
class WebOperationRequestPredicateTests { class WebOperationRequestPredicateTests {
@ -54,12 +55,37 @@ class WebOperationRequestPredicateTests {
assertThat(predicateWithPath("/path/{foo1}")).isEqualTo(predicateWithPath("/path/{foo2}")); 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 @Test
void predicatesWithMultiplePathVariablesInTheSamplePlaceAreEqual() { void predicatesWithMultiplePathVariablesInTheSamplePlaceAreEqual() {
assertThat(predicateWithPath("/path/{foo1}/more/{bar1}")) assertThat(predicateWithPath("/path/{foo1}/more/{bar1}"))
.isEqualTo(predicateWithPath("/path/{foo2}/more/{bar2}")); .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) { private WebOperationRequestPredicate predicateWithPath(String path) {
return new WebOperationRequestPredicate(path, WebEndpointHttpMethod.GET, Collections.emptyList(), return new WebOperationRequestPredicate(path, WebEndpointHttpMethod.GET, Collections.emptyList(),
Collections.emptyList()); Collections.emptyList());

@ -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.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
import org.springframework.boot.actuate.endpoint.annotation.Selector; 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.annotation.WriteOperation;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
@ -50,6 +51,7 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.lang.Nullable; import org.springframework.lang.Nullable;
import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.util.StringUtils;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ -124,6 +126,20 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
.expectBody().jsonPath("All").isEqualTo(true)); .expectBody().jsonPath("All").isEqualTo(true));
} }
@Test
void matchAllRemainingPathsSelectorShouldMatchFullPath() {
load(MatchAllRemainingEndpointConfiguration.class,
(client) -> 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 @Test
void readOperationWithSingleQueryParameters() { void readOperationWithSingleQueryParameters() {
load(QueryEndpointConfiguration.class, (client) -> client.get().uri("/query?one=1&two=2").exchange() load(QueryEndpointConfiguration.class, (client) -> client.get().uri("/query?one=1&two=2").exchange()
@ -418,6 +434,17 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
} }
@Configuration(proxyBeanMethods = false)
@Import(BaseConfiguration.class)
static class MatchAllRemainingEndpointConfiguration {
@Bean
MatchAllRemainingEndpoint matchAllRemainingEndpoint() {
return new MatchAllRemainingEndpoint();
}
}
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@Import(BaseConfiguration.class) @Import(BaseConfiguration.class)
static class QueryEndpointConfiguration { static class QueryEndpointConfiguration {
@ -625,6 +652,16 @@ public abstract class AbstractWebEndpointIntegrationTests<T extends Configurable
} }
@Endpoint(id = "matchallremaining")
static class MatchAllRemainingEndpoint {
@ReadOperation
Map<String, String> select(@Selector(match = Match.ALL_REMAINING) String... selection) {
return Collections.singletonMap("selection", StringUtils.arrayToDelimitedString(selection, "|"));
}
}
@Endpoint(id = "query") @Endpoint(id = "query")
static class QueryEndpoint { static class QueryEndpoint {

@ -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) {
}
}
}

@ -577,7 +577,6 @@ endpoint.
[[production-ready-endpoints-custom-web-predicate-path]] [[production-ready-endpoints-custom-web-predicate-path]]
===== Path ===== Path
The path of the predicate is determined by the ID of the endpoint and the base path of 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 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. 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 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 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 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]] [[production-ready-endpoints-custom-web-predicate-http-method]]
===== HTTP method ===== HTTP method
The HTTP method of the predicate is determined by the operation type, as shown in The HTTP method of the predicate is determined by the operation type, as shown in
the following table: the following table:

Loading…
Cancel
Save