diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerMapping.java index 9dd0ccb072..6253cb8020 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerMapping.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/HandlerMapping.java @@ -29,6 +29,45 @@ import org.springframework.web.server.ServerWebExchange; */ public interface HandlerMapping { + /** + * Name of the {@link ServerWebExchange} attribute that contains the + * best matching pattern within the handler mapping. + *

Note: This attribute is not required to be supported by all + * HandlerMapping implementations. URL-based HandlerMappings will + * typically support it, but handlers should not necessarily expect + * this request attribute to be present in all scenarios. + */ + String BEST_MATCHING_PATTERN_ATTRIBUTE = HandlerMapping.class.getName() + ".bestMatchingPattern"; + + /** + * Name of the {@link ServerWebExchange} attribute that contains the URI + * templates map, mapping variable names to values. + *

Note: This attribute is not required to be supported by all + * HandlerMapping implementations. URL-based HandlerMappings will + * typically support it, but handlers should not necessarily expect + * this request attribute to be present in all scenarios. + */ + String URI_TEMPLATE_VARIABLES_ATTRIBUTE = HandlerMapping.class.getName() + ".uriTemplateVariables"; + + /** + * Name of the {@link ServerWebExchange} attribute that contains a map with + * URI matrix variables. + *

Note: This attribute is not required to be supported by all + * HandlerMapping implementations and may also not be present depending on + * whether the HandlerMapping is configured to keep matrix variable content + * in the request URI. + */ + String MATRIX_VARIABLES_ATTRIBUTE = HandlerMapping.class.getName() + ".matrixVariables"; + + /** + * Name of the {@link ServerWebExchange} attribute that contains the set of + * producible MediaTypes applicable to the mapped handler. + *

Note: This attribute is not required to be supported by all + * HandlerMapping implementations. Handlers should not necessarily expect + * this request attribute to be present in all scenarios. + */ + String PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE = HandlerMapping.class.getName() + ".producibleMediaTypes"; + /** * Return a handler for this request. * @param exchange current server exchange diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java new file mode 100644 index 0000000000..7e29549416 --- /dev/null +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMapping.java @@ -0,0 +1,336 @@ +/* + * Copyright 2002-2016 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 + * + * http://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.web.reactive.result.method; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.InvalidMediaTypeException; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.CollectionUtils; +import org.springframework.util.MultiValueMap; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.UnsatisfiedServletRequestParameterException; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.result.condition.NameValueExpression; +import org.springframework.web.reactive.result.condition.ParamsRequestCondition; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.WebUtils; + +/** + * Abstract base class for classes for which {@link RequestMappingInfo} defines + * the mapping between a request and a handler method. + * + * @author Rossen Stoyanchev + */ +public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMethodMapping { + + private static final Method HTTP_OPTIONS_HANDLE_METHOD; + + static { + try { + HTTP_OPTIONS_HANDLE_METHOD = HttpOptionsHandler.class.getMethod("handle"); + } + catch (NoSuchMethodException ex) { + // Should never happen + throw new IllegalStateException("No handler for HTTP OPTIONS", ex); + } + } + + + /** + * Get the URL path patterns associated with this {@link RequestMappingInfo}. + */ + @Override + protected Set getMappingPathPatterns(RequestMappingInfo info) { + return info.getPatternsCondition().getPatterns(); + } + + /** + * Check if the given RequestMappingInfo matches the current request and + * return a (potentially new) instance with conditions that match the + * current request -- for example with a subset of URL patterns. + * @return an info in case of a match; or {@code null} otherwise. + */ + @Override + protected RequestMappingInfo getMatchingMapping(RequestMappingInfo info, ServerWebExchange exchange) { + return info.getMatchingCondition(exchange); + } + + /** + * Provide a Comparator to sort RequestMappingInfos matched to a request. + */ + @Override + protected Comparator getMappingComparator(final ServerWebExchange exchange) { + return (info1, info2) -> info1.compareTo(info2, exchange); + } + + /** + * Expose URI template variables, matrix variables, and producible media types in the request. + * @see HandlerMapping#URI_TEMPLATE_VARIABLES_ATTRIBUTE + * @see HandlerMapping#MATRIX_VARIABLES_ATTRIBUTE + * @see HandlerMapping#PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE + */ + @Override + protected void handleMatch(RequestMappingInfo info, String lookupPath, ServerWebExchange exchange) { + super.handleMatch(info, lookupPath, exchange); + + String bestPattern; + Map uriVariables; + Map decodedUriVariables; + + Set patterns = info.getPatternsCondition().getPatterns(); + if (patterns.isEmpty()) { + bestPattern = lookupPath; + uriVariables = Collections.emptyMap(); + decodedUriVariables = Collections.emptyMap(); + } + else { + bestPattern = patterns.iterator().next(); + uriVariables = getPathMatcher().extractUriTemplateVariables(bestPattern, lookupPath); + decodedUriVariables = getPathHelper().decodePathVariables(exchange, uriVariables); + } + + exchange.getAttributes().put(BEST_MATCHING_PATTERN_ATTRIBUTE, bestPattern); + exchange.getAttributes().put(URI_TEMPLATE_VARIABLES_ATTRIBUTE, decodedUriVariables); + + Map> matrixVars = extractMatrixVariables(exchange, uriVariables); + exchange.getAttributes().put(MATRIX_VARIABLES_ATTRIBUTE, matrixVars); + + if (!info.getProducesCondition().getProducibleMediaTypes().isEmpty()) { + Set mediaTypes = info.getProducesCondition().getProducibleMediaTypes(); + exchange.getAttributes().put(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mediaTypes); + } + } + + private Map> extractMatrixVariables( + ServerWebExchange exchange, Map uriVariables) { + + Map> result = new LinkedHashMap<>(); + for (Entry uriVar : uriVariables.entrySet()) { + String uriVarValue = uriVar.getValue(); + + int equalsIndex = uriVarValue.indexOf('='); + if (equalsIndex == -1) { + continue; + } + + String matrixVariables; + + int semicolonIndex = uriVarValue.indexOf(';'); + if ((semicolonIndex == -1) || (semicolonIndex == 0) || (equalsIndex < semicolonIndex)) { + matrixVariables = uriVarValue; + } + else { + matrixVariables = uriVarValue.substring(semicolonIndex + 1); + uriVariables.put(uriVar.getKey(), uriVarValue.substring(0, semicolonIndex)); + } + + MultiValueMap vars = WebUtils.parseMatrixVariables(matrixVariables); + result.put(uriVar.getKey(), getPathHelper().decodeMatrixVariables(exchange, vars)); + } + return result; + } + + /** + * Iterate all RequestMappingInfos once again, look if any match by URL at + * least and raise exceptions accordingly. + * @throws HttpRequestMethodNotSupportedException if there are matches by URL + * but not by HTTP method + * @throws HttpMediaTypeNotAcceptableException if there are matches by URL + * but not by consumable/producible media types + */ + @Override + protected HandlerMethod handleNoMatch(Set requestMappingInfos, + String lookupPath, ServerWebExchange exchange) throws Exception { + + Set allowedMethods = new LinkedHashSet<>(4); + + Set patternMatches = new HashSet<>(); + Set patternAndMethodMatches = new HashSet<>(); + + for (RequestMappingInfo info : requestMappingInfos) { + if (info.getPatternsCondition().getMatchingCondition(exchange) != null) { + patternMatches.add(info); + if (info.getMethodsCondition().getMatchingCondition(exchange) != null) { + patternAndMethodMatches.add(info); + } + else { + for (RequestMethod method : info.getMethodsCondition().getMethods()) { + allowedMethods.add(method.name()); + } + } + } + } + + ServerHttpRequest request = exchange.getRequest(); + if (patternMatches.isEmpty()) { + return null; + } + else if (patternAndMethodMatches.isEmpty()) { + HttpMethod httpMethod = request.getMethod(); + if (HttpMethod.OPTIONS.matches(httpMethod.name())) { + HttpOptionsHandler handler = new HttpOptionsHandler(allowedMethods); + return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); + } + else if (!allowedMethods.isEmpty()) { + throw new HttpRequestMethodNotSupportedException(httpMethod.name(), allowedMethods); + } + } + + Set consumableMediaTypes; + Set producibleMediaTypes; + List paramConditions; + + if (patternAndMethodMatches.isEmpty()) { + consumableMediaTypes = getConsumableMediaTypes(exchange, patternMatches); + producibleMediaTypes = getProducibleMediaTypes(exchange, patternMatches); + paramConditions = getRequestParams(exchange, patternMatches); + } + else { + consumableMediaTypes = getConsumableMediaTypes(exchange, patternAndMethodMatches); + producibleMediaTypes = getProducibleMediaTypes(exchange, patternAndMethodMatches); + paramConditions = getRequestParams(exchange, patternAndMethodMatches); + } + + if (!consumableMediaTypes.isEmpty()) { + MediaType contentType; + try { + contentType = request.getHeaders().getContentType(); + } + catch (InvalidMediaTypeException ex) { + throw new HttpMediaTypeNotSupportedException(ex.getMessage()); + } + throw new HttpMediaTypeNotSupportedException(contentType, new ArrayList<>(consumableMediaTypes)); + } + else if (!producibleMediaTypes.isEmpty()) { + throw new HttpMediaTypeNotAcceptableException(new ArrayList<>(producibleMediaTypes)); + } + else { + if (!CollectionUtils.isEmpty(paramConditions)) { + Map params = request.getQueryParams().entrySet().stream() + .collect(Collectors.toMap(Entry::getKey, + entry -> entry.getValue().toArray(new String[entry.getValue().size()])) + ); + throw new UnsatisfiedServletRequestParameterException(paramConditions, params); + } + else { + return null; + } + } + } + + private Set getConsumableMediaTypes(ServerWebExchange exchange, + Set partialMatches) { + + Set result = new HashSet<>(); + for (RequestMappingInfo partialMatch : partialMatches) { + if (partialMatch.getConsumesCondition().getMatchingCondition(exchange) == null) { + result.addAll(partialMatch.getConsumesCondition().getConsumableMediaTypes()); + } + } + return result; + } + + private Set getProducibleMediaTypes(ServerWebExchange exchange, + Set partialMatches) { + + Set result = new HashSet<>(); + for (RequestMappingInfo partialMatch : partialMatches) { + if (partialMatch.getProducesCondition().getMatchingCondition(exchange) == null) { + result.addAll(partialMatch.getProducesCondition().getProducibleMediaTypes()); + } + } + return result; + } + + private List getRequestParams(ServerWebExchange exchange, + Set partialMatches) { + + List result = new ArrayList<>(); + for (RequestMappingInfo partialMatch : partialMatches) { + ParamsRequestCondition condition = partialMatch.getParamsCondition(); + Set> expressions = condition.getExpressions(); + if (!CollectionUtils.isEmpty(expressions) && condition.getMatchingCondition(exchange) == null) { + int i = 0; + String[] array = new String[expressions.size()]; + for (NameValueExpression expression : expressions) { + array[i++] = expression.toString(); + } + result.add(array); + } + } + return result; + } + + + /** + * Default handler for HTTP OPTIONS. + */ + private static class HttpOptionsHandler { + + private final HttpHeaders headers = new HttpHeaders(); + + + public HttpOptionsHandler(Set declaredMethods) { + this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); + } + + private static Set initAllowedHttpMethods(Set declaredMethods) { + Set result = new LinkedHashSet(declaredMethods.size()); + if (declaredMethods.isEmpty()) { + for (HttpMethod method : HttpMethod.values()) { + if (!HttpMethod.TRACE.equals(method)) { + result.add(method); + } + } + } + else { + boolean hasHead = declaredMethods.contains("HEAD"); + for (String method : declaredMethods) { + result.add(HttpMethod.valueOf(method)); + if (!hasHead && "GET".equals(method)) { + result.add(HttpMethod.HEAD); + } + } + } + return result; + } + + public HttpHeaders handle() { + return this.headers; + } + } + +} diff --git a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java index e6861bd8c2..96ec3b6c6e 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2015 the original author or authors. + * Copyright 2002-2016 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,154 +16,260 @@ package org.springframework.web.reactive.result.method.annotation; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Method; import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; -import java.util.TreeSet; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -import org.springframework.beans.factory.InitializingBean; -import org.springframework.context.ApplicationContext; -import org.springframework.context.ApplicationContextAware; -import org.springframework.core.annotation.AnnotationUtils; -import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.context.EmbeddedValueResolverAware; +import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.stereotype.Controller; +import org.springframework.util.Assert; +import org.springframework.util.StringValueResolver; +import org.springframework.web.accept.ContentNegotiationManager; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.method.HandlerMethodSelector; -import org.springframework.web.reactive.HandlerMapping; -import org.springframework.web.server.ServerWebExchange; - +import org.springframework.web.reactive.accept.ContentTypeResolver; +import org.springframework.web.reactive.accept.HeaderContentTypeResolver; +import org.springframework.web.reactive.result.condition.RequestCondition; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; /** + * An extension of {@link RequestMappingInfoHandlerMapping} that creates + * {@link RequestMappingInfo} instances from class-level and method-level + * {@link RequestMapping @RequestMapping} annotations. + * * @author Rossen Stoyanchev */ -public class RequestMappingHandlerMapping implements HandlerMapping, - ApplicationContextAware, InitializingBean { +public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping + implements EmbeddedValueResolverAware { - private static final Log logger = LogFactory.getLog(RequestMappingHandlerMapping.class); + private boolean useSuffixPatternMatch = true; + + private boolean useRegisteredSuffixPatternMatch = true; + + private boolean useTrailingSlashMatch = true; + + private ContentTypeResolver contentTypeResolver = new HeaderContentTypeResolver(); + + private StringValueResolver embeddedValueResolver; + + private RequestMappingInfo.BuilderConfiguration config = new RequestMappingInfo.BuilderConfiguration(); - private final Map methodMap = new TreeMap<>(); - - private ApplicationContext applicationContext; - - - @Override - public void setApplicationContext(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; + /** + * Whether to use suffix pattern matching. If enabled a method mapped to + * "/path" also matches to "/path.*". + *

The default value is {@code true}. + *

Note: when using suffix pattern matching it's usually + * preferable to be explicit about what is and isn't an extension so rather + * than setting this property consider using + * {@link #setUseRegisteredSuffixPatternMatch} instead. + */ + public void setUseSuffixPatternMatch(boolean useSuffixPatternMatch) { + this.useSuffixPatternMatch = useSuffixPatternMatch; } - - @Override - public void afterPropertiesSet() throws Exception { - this.applicationContext.getBeansOfType(Object.class).values().forEach(this::detectHandlerMethods); + /** + * Whether suffix pattern matching should work only against path extensions + * explicitly registered with the configured {@link ContentTypeResolver}. This + * is generally recommended to reduce ambiguity and to avoid issues such as + * when a "." appears in the path for other reasons. + *

By default this is set to "true". + */ + public void setUseRegisteredSuffixPatternMatch(boolean useRegisteredSuffixPatternMatch) { + this.useRegisteredSuffixPatternMatch = useRegisteredSuffixPatternMatch; + this.useSuffixPatternMatch = (useRegisteredSuffixPatternMatch || this.useSuffixPatternMatch); } - protected void detectHandlerMethods(final Object bean) { - final Class beanType = bean.getClass(); - if (AnnotationUtils.findAnnotation(beanType, Controller.class) != null) { - HandlerMethodSelector.selectMethods(beanType, method -> { - RequestMapping annotation = AnnotationUtils.findAnnotation(method, RequestMapping.class); - if (annotation != null && annotation.value().length > 0) { - String path = annotation.value()[0]; - RequestMethod[] methods = annotation.method(); - HandlerMethod handlerMethod = new HandlerMethod(bean, method); - if (logger.isInfoEnabled()) { - logger.info("Mapped \"" + path + "\" onto " + handlerMethod); - } - RequestMappingInfo info = new RequestMappingInfo(path, methods); - if (this.methodMap.containsKey(info)) { - throw new IllegalStateException("Duplicate mapping found for " + info); - } - methodMap.put(info, handlerMethod); - } - return false; - }); - } + /** + * Whether to match to URLs irrespective of the presence of a trailing slash. + * If enabled a method mapped to "/users" also matches to "/users/". + *

The default value is {@code true}. + */ + public void setUseTrailingSlashMatch(boolean useTrailingSlashMatch) { + this.useTrailingSlashMatch = useTrailingSlashMatch; + } + + /** + * Set the {@link ContentNegotiationManager} to use to determine requested media types. + * If not set, the default constructor is used. + */ + public void setContentTypeResolver(ContentTypeResolver contentTypeResolver) { + Assert.notNull(contentTypeResolver, "'ContentTypeResolver' must not be null"); + this.contentTypeResolver = contentTypeResolver; } @Override - public Mono getHandler(ServerWebExchange exchange) { - return Flux.create(subscriber -> { - for (Map.Entry entry : this.methodMap.entrySet()) { - RequestMappingInfo info = entry.getKey(); - if (info.matchesRequest(exchange.getRequest())) { - HandlerMethod handlerMethod = entry.getValue(); - if (logger.isDebugEnabled()) { - logger.debug("Mapped " + exchange.getRequest().getMethod() + " " + - exchange.getRequest().getURI().getPath() + " to [" + handlerMethod + "]"); - } - subscriber.onNext(handlerMethod); - break; - } - } - subscriber.onComplete(); - }).next(); + public void setEmbeddedValueResolver(StringValueResolver resolver) { + this.embeddedValueResolver = resolver; + } + + @Override + public void afterPropertiesSet() { + this.config = new RequestMappingInfo.BuilderConfiguration(); + this.config.setPathHelper(getPathHelper()); + this.config.setPathMatcher(getPathMatcher()); + this.config.setSuffixPatternMatch(this.useSuffixPatternMatch); + this.config.setTrailingSlashMatch(this.useTrailingSlashMatch); + this.config.setRegisteredSuffixPatternMatch(this.useRegisteredSuffixPatternMatch); + this.config.setContentTypeResolver(getContentTypeResolver()); + + super.afterPropertiesSet(); } - private static class RequestMappingInfo implements Comparable { + /** + * Whether to use suffix pattern matching. + */ + public boolean useSuffixPatternMatch() { + return this.useSuffixPatternMatch; + } - private String path; + /** + * Whether to use registered suffixes for pattern matching. + */ + public boolean useRegisteredSuffixPatternMatch() { + return this.useRegisteredSuffixPatternMatch; + } - private Set methods; + /** + * Whether to match to URLs irrespective of the presence of a trailing slash. + */ + public boolean useTrailingSlashMatch() { + return this.useTrailingSlashMatch; + } + + /** + * Return the configured {@link ContentTypeResolver}. + */ + public ContentTypeResolver getContentTypeResolver() { + return this.contentTypeResolver; + } + + /** + * Return the file extensions to use for suffix pattern matching. + */ + public List getFileExtensions() { + return this.config.getFileExtensions(); + } - public RequestMappingInfo(String path, RequestMethod... methods) { - this(path, asList(methods)); - } + /** + * {@inheritDoc} + * Expects a handler to have a type-level @{@link Controller} annotation. + */ + @Override + protected boolean isHandler(Class beanType) { + return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) || + AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class)); + } - private static List asList(RequestMethod... requestMethods) { - return (requestMethods != null ? - Arrays.asList(requestMethods) : Collections.emptyList()); - } - - public RequestMappingInfo(String path, Collection methods) { - this.path = path; - this.methods = new TreeSet<>(methods); - } - - - public String getPath() { - return this.path; - } - - public Set getMethods() { - return this.methods; - } - - public boolean matchesRequest(ServerHttpRequest request) { - String httpMethod = request.getMethod().name(); - return request.getURI().getPath().equals(getPath()) && - (getMethods().isEmpty() || getMethods().contains(RequestMethod.valueOf(httpMethod))); - } - - @Override - public int compareTo(Object o) { - RequestMappingInfo other = (RequestMappingInfo) o; - if (!this.path.equals(other.getPath())) { - return -1; + /** + * Uses method and type-level @{@link RequestMapping} annotations to create + * the RequestMappingInfo. + * @return the created RequestMappingInfo, or {@code null} if the method + * does not have a {@code @RequestMapping} annotation. + * @see #getCustomMethodCondition(Method) + * @see #getCustomTypeCondition(Class) + */ + @Override + protected RequestMappingInfo getMappingForMethod(Method method, Class handlerType) { + RequestMappingInfo info = createRequestMappingInfo(method); + if (info != null) { + RequestMappingInfo typeInfo = createRequestMappingInfo(handlerType); + if (typeInfo != null) { + info = typeInfo.combine(info); } - if (this.methods.isEmpty() && !other.methods.isEmpty()) { - return 1; + } + return info; + } + + /** + * Delegates to {@link #createRequestMappingInfo(RequestMapping, RequestCondition)}, + * supplying the appropriate custom {@link RequestCondition} depending on whether + * the supplied {@code annotatedElement} is a class or method. + * @see #getCustomTypeCondition(Class) + * @see #getCustomMethodCondition(Method) + */ + private RequestMappingInfo createRequestMappingInfo(AnnotatedElement element) { + RequestMapping requestMapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class); + RequestCondition condition = (element instanceof Class ? + getCustomTypeCondition((Class) element) : getCustomMethodCondition((Method) element)); + return (requestMapping != null ? createRequestMappingInfo(requestMapping, condition) : null); + } + + /** + * Provide a custom type-level request condition. + * The custom {@link RequestCondition} can be of any type so long as the + * same condition type is returned from all calls to this method in order + * to ensure custom request conditions can be combined and compared. + *

Consider extending + * {@link org.springframework.web.reactive.result.condition.AbstractRequestCondition + * AbstractRequestCondition} for custom condition types and using + * {@link org.springframework.web.reactive.result.condition.CompositeRequestCondition + * CompositeRequestCondition} to provide multiple custom conditions. + * @param handlerType the handler type for which to create the condition + * @return the condition, or {@code null} + */ + @SuppressWarnings("UnusedParameters") + protected RequestCondition getCustomTypeCondition(Class handlerType) { + return null; + } + + /** + * Provide a custom method-level request condition. + * The custom {@link RequestCondition} can be of any type so long as the + * same condition type is returned from all calls to this method in order + * to ensure custom request conditions can be combined and compared. + *

Consider extending + * {@link org.springframework.web.reactive.result.condition.AbstractRequestCondition + * AbstractRequestCondition} for custom condition types and using + * {@link org.springframework.web.reactive.result.condition.CompositeRequestCondition + * CompositeRequestCondition} to provide multiple custom conditions. + * @param method the handler method for which to create the condition + * @return the condition, or {@code null} + */ + @SuppressWarnings("UnusedParameters") + protected RequestCondition getCustomMethodCondition(Method method) { + return null; + } + + /** + * Create a {@link RequestMappingInfo} from the supplied + * {@link RequestMapping @RequestMapping} annotation, which is either + * a directly declared annotation, a meta-annotation, or the synthesized + * result of merging annotation attributes within an annotation hierarchy. + */ + protected RequestMappingInfo createRequestMappingInfo( + RequestMapping requestMapping, RequestCondition customCondition) { + + return RequestMappingInfo + .paths(resolveEmbeddedValuesInPatterns(requestMapping.path())) + .methods(requestMapping.method()) + .params(requestMapping.params()) + .headers(requestMapping.headers()) + .consumes(requestMapping.consumes()) + .produces(requestMapping.produces()) + .mappingName(requestMapping.name()) + .customCondition(customCondition) + .options(this.config) + .build(); + } + + /** + * Resolve placeholder values in the given array of patterns. + * @return a new array with updated patterns + */ + protected String[] resolveEmbeddedValuesInPatterns(String[] patterns) { + if (this.embeddedValueResolver == null) { + return patterns; + } + else { + String[] resolvedPatterns = new String[patterns.length]; + for (int i = 0; i < patterns.length; i++) { + resolvedPatterns[i] = this.embeddedValueResolver.resolveStringValue(patterns[i]); } - if (!this.methods.isEmpty() && other.methods.isEmpty()) { - return -1; - } - if (this.methods.equals(other.methods)) { - return 0; - } - return -1; + return resolvedPatterns; } } diff --git a/spring-web-reactive/src/main/java/org/springframework/web/util/HttpRequestPathHelper.java b/spring-web-reactive/src/main/java/org/springframework/web/util/HttpRequestPathHelper.java index 45c893d3e8..259033df7b 100644 --- a/spring-web-reactive/src/main/java/org/springframework/web/util/HttpRequestPathHelper.java +++ b/spring-web-reactive/src/main/java/org/springframework/web/util/HttpRequestPathHelper.java @@ -16,7 +16,11 @@ package org.springframework.web.util; import java.io.UnsupportedEncodingException; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.server.ServerWebExchange; /** @@ -49,11 +53,12 @@ public class HttpRequestPathHelper { public String getLookupPathForRequest(ServerWebExchange exchange) { - String path = exchange.getRequest().getURI().getPath(); - return (this.shouldUrlDecode() ? decode(path) : path); + String path = exchange.getRequest().getURI().getRawPath(); + return (this.shouldUrlDecode() ? decode(exchange, path) : path); } - private String decode(String path) { + private String decode(ServerWebExchange exchange, String path) { + // TODO: look up request encoding? try { return UriUtils.decode(path, "UTF-8"); } @@ -63,4 +68,48 @@ public class HttpRequestPathHelper { } } + /** + * Decode the given URI path variables unless {@link #setUrlDecode(boolean)} + * is set to {@code true} in which case it is assumed the URL path from + * which the variables were extracted is already decoded through a call to + * {@link #getLookupPathForRequest(ServerWebExchange)}. + * @param exchange current exchange + * @param vars URI variables extracted from the URL path + * @return the same Map or a new Map instance + */ + public Map decodePathVariables(ServerWebExchange exchange, Map vars) { + if (this.urlDecode) { + return vars; + } + Map decodedVars = new LinkedHashMap<>(vars.size()); + for (Map.Entry entry : vars.entrySet()) { + decodedVars.put(entry.getKey(), decode(exchange, entry.getValue())); + } + return decodedVars; + } + + /** + * Decode the given matrix variables unless {@link #setUrlDecode(boolean)} + * is set to {@code true} in which case it is assumed the URL path from + * which the variables were extracted is already decoded through a call to + * {@link #getLookupPathForRequest(ServerWebExchange)}. + * @param exchange current exchange + * @param vars URI variables extracted from the URL path + * @return the same Map or a new Map instance + */ + public MultiValueMap decodeMatrixVariables(ServerWebExchange exchange, + MultiValueMap vars) { + + if (this.urlDecode) { + return vars; + } + MultiValueMap decodedVars = new LinkedMultiValueMap<>(vars.size()); + for (String key : vars.keySet()) { + for (String value : vars.get(key)) { + decodedVars.add(key, decode(exchange, value)); + } + } + return decodedVars; + } + } \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java new file mode 100644 index 0000000000..50ca621bb4 --- /dev/null +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/RequestMappingInfoHandlerMappingTests.java @@ -0,0 +1,535 @@ +/* + * Copyright 2002-2016 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 + * + * http://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.web.reactive.result.method; + +import java.lang.reflect.Method; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import org.junit.Before; +import org.junit.Test; +import reactor.core.publisher.Mono; +import reactor.core.test.TestSubscriber; + +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.MockServerHttpRequest; +import org.springframework.http.server.reactive.MockServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.stereotype.Controller; +import org.springframework.ui.ExtendedModelMap; +import org.springframework.ui.ModelMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.HttpMediaTypeNotAcceptableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.UnsatisfiedServletRequestParameterException; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.HandlerResult; +import org.springframework.web.reactive.result.method.RequestMappingInfo.BuilderConfiguration; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.adapter.DefaultServerWebExchange; +import org.springframework.web.server.session.WebSessionManager; +import org.springframework.web.util.HttpRequestPathHelper; + +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + + +/** + * Unit tests for {@link RequestMappingInfoHandlerMapping}. + * + * @author Rossen Stoyanchev + */ +public class RequestMappingInfoHandlerMappingTests { + + private TestRequestMappingInfoHandlerMapping handlerMapping; + + private HandlerMethod fooMethod; + + private HandlerMethod fooParamMethod; + + private HandlerMethod barMethod; + + private HandlerMethod emptyMethod; + + + @Before + public void setUp() throws Exception { + TestController testController = new TestController(); + + this.fooMethod = new HandlerMethod(testController, "foo"); + this.fooParamMethod = new HandlerMethod(testController, "fooParam"); + this.barMethod = new HandlerMethod(testController, "bar"); + this.emptyMethod = new HandlerMethod(testController, "empty"); + + this.handlerMapping = new TestRequestMappingInfoHandlerMapping(); + this.handlerMapping.registerHandler(testController); + } + + + @Test + public void getMappingPathPatterns() throws Exception { + String[] patterns = {"/foo/*", "/foo", "/bar/*", "/bar"}; + RequestMappingInfo info = RequestMappingInfo.paths(patterns).build(); + Set actual = this.handlerMapping.getMappingPathPatterns(info); + + assertEquals(new HashSet<>(Arrays.asList(patterns)), actual); + } + + @Test + public void getHandlerDirectMatch() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/foo"); + HandlerMethod handlerMethod = getHandler(exchange); + assertEquals(this.fooMethod.getMethod(), handlerMethod.getMethod()); + } + + @Test + public void getHandlerGlobMatch() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/bar"); + HandlerMethod handlerMethod = getHandler(exchange); + assertEquals(this.barMethod.getMethod(), handlerMethod.getMethod()); + } + + @Test + public void getHandlerEmptyPathMatch() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, ""); + HandlerMethod handlerMethod = getHandler(exchange); + + assertEquals(this.emptyMethod.getMethod(), handlerMethod.getMethod()); + + exchange = createExchange(HttpMethod.GET, "/"); + handlerMethod = getHandler(exchange); + + assertEquals(this.emptyMethod.getMethod(), handlerMethod.getMethod()); + } + + @Test + public void getHandlerBestMatch() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/foo"); + exchange.getRequest().getQueryParams().add("p", "anything"); + HandlerMethod handlerMethod = getHandler(exchange); + + assertEquals(this.fooParamMethod.getMethod(), handlerMethod.getMethod()); + } + + @Test + public void getHandlerRequestMethodNotAllowed() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.POST, "/bar"); + Mono mono = this.handlerMapping.getHandler(exchange); + assertError(mono, HttpRequestMethodNotSupportedException.class, + ex -> assertArrayEquals(new String[]{"GET", "HEAD"}, ex.getSupportedMethods())); + } + + // SPR-9603 + + @Test + public void getHandlerRequestMethodMatchFalsePositive() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/users"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_XML)); + this.handlerMapping.registerHandler(new UserController()); + Mono mono = this.handlerMapping.getHandler(exchange); + + TestSubscriber subscriber = new TestSubscriber<>(); + mono.subscribeWith(subscriber); + subscriber.assertError(HttpMediaTypeNotAcceptableException.class); + } + + // SPR-8462 + + @Test + public void getHandlerMediaTypeNotSupported() throws Exception { + testHttpMediaTypeNotSupportedException("/person/1"); + testHttpMediaTypeNotSupportedException("/person/1/"); + testHttpMediaTypeNotSupportedException("/person/1.json"); + } + + @Test + public void getHandlerHttpOptions() throws Exception { + testHttpOptions("/foo", "GET,HEAD"); + testHttpOptions("/person/1", "PUT"); + testHttpOptions("/persons", "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS"); + testHttpOptions("/something", "PUT,POST"); + } + + @Test + public void getHandlerTestInvalidContentType() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.PUT, "/person/1"); + exchange.getRequest().getHeaders().add("Content-Type", "bogus"); + Mono mono = this.handlerMapping.getHandler(exchange); + assertError(mono, HttpMediaTypeNotSupportedException.class, + ex -> assertEquals("Invalid mime type \"bogus\": does not contain '/'", ex.getMessage())); + } + + // SPR-8462 + + @Test + public void getHandlerMediaTypeNotAccepted() throws Exception { + testHttpMediaTypeNotAcceptableException("/persons"); + testHttpMediaTypeNotAcceptableException("/persons/"); + testHttpMediaTypeNotAcceptableException("/persons.json"); + } + + // SPR-12854 + + @Test + public void getHandlerUnsatisfiedServletRequestParameterException() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/params"); + Mono mono = this.handlerMapping.getHandler(exchange); + assertError(mono, UnsatisfiedServletRequestParameterException.class, ex -> { + List groups = ex.getParamConditionGroups(); + assertEquals(2, groups.size()); + assertThat(Arrays.asList("foo=bar", "bar=baz"), + containsInAnyOrder(groups.get(0)[0], groups.get(1)[0])); + }); + } + + @Test + public void getHandlerProducibleMediaTypesAttribute() throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/content"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_XML)); + getHandler(exchange); + + String name = HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE; + assertEquals(Collections.singleton(MediaType.APPLICATION_XML), exchange.getAttributes().get(name)); + + exchange = createExchange(HttpMethod.GET, "/content"); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + getHandler(exchange); + + assertNull("Negated expression shouldn't be listed as producible type", + exchange.getAttributes().get(name)); + } + + @SuppressWarnings("unchecked") + @Test + public void handleMatchUriTemplateVariables() throws Exception { + RequestMappingInfo key = RequestMappingInfo.paths("/{path1}/{path2}").build(); + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/1/2"); + String lookupPath = exchange.getRequest().getURI().getPath(); + this.handlerMapping.handleMatch(key, lookupPath, exchange); + + String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE; + Map uriVariables = (Map) exchange.getAttributes().get(name); + + assertNotNull(uriVariables); + assertEquals("1", uriVariables.get("path1")); + assertEquals("2", uriVariables.get("path2")); + } + + // SPR-9098 + + @Test + public void handleMatchUriTemplateVariablesDecode() throws Exception { + RequestMappingInfo key = RequestMappingInfo.paths("/{group}/{identifier}").build(); + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/group/a%2Fb"); + + HttpRequestPathHelper pathHelper = new HttpRequestPathHelper(); + pathHelper.setUrlDecode(false); + String lookupPath = pathHelper.getLookupPathForRequest(exchange); + + this.handlerMapping.setPathHelper(pathHelper); + this.handlerMapping.handleMatch(key, lookupPath, exchange); + + String name = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE; + @SuppressWarnings("unchecked") + Map uriVariables = (Map) exchange.getAttributes().get(name); + + assertNotNull(uriVariables); + assertEquals("group", uriVariables.get("group")); + assertEquals("a/b", uriVariables.get("identifier")); + } + + @Test + public void handleMatchBestMatchingPatternAttribute() throws Exception { + RequestMappingInfo key = RequestMappingInfo.paths("/{path1}/2", "/**").build(); + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/1/2"); + this.handlerMapping.handleMatch(key, "/1/2", exchange); + + assertEquals("/{path1}/2", exchange.getAttributes().get(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE)); + } + + @Test + public void handleMatchBestMatchingPatternAttributeNoPatternsDefined() throws Exception { + RequestMappingInfo key = RequestMappingInfo.paths().build(); + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/1/2"); + + this.handlerMapping.handleMatch(key, "/1/2", exchange); + + assertEquals("/1/2", exchange.getAttributes().get(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE)); + } + + @Test + public void handleMatchMatrixVariables() throws Exception { + ServerWebExchange exchange; + MultiValueMap matrixVariables; + Map uriVariables; + + exchange = createExchange(HttpMethod.GET, "/"); + handleMatch(exchange, "/{cars}", "/cars;colors=red,blue,green;year=2012"); + + matrixVariables = getMatrixVariables(exchange, "cars"); + uriVariables = getUriTemplateVariables(exchange); + + assertNotNull(matrixVariables); + assertEquals(Arrays.asList("red", "blue", "green"), matrixVariables.get("colors")); + assertEquals("2012", matrixVariables.getFirst("year")); + assertEquals("cars", uriVariables.get("cars")); + + exchange = createExchange(HttpMethod.GET, "/"); + handleMatch(exchange, "/{cars:[^;]+}{params}", "/cars;colors=red,blue,green;year=2012"); + + matrixVariables = getMatrixVariables(exchange, "params"); + uriVariables = getUriTemplateVariables(exchange); + + assertNotNull(matrixVariables); + assertEquals(Arrays.asList("red", "blue", "green"), matrixVariables.get("colors")); + assertEquals("2012", matrixVariables.getFirst("year")); + assertEquals("cars", uriVariables.get("cars")); + assertEquals(";colors=red,blue,green;year=2012", uriVariables.get("params")); + + exchange = createExchange(HttpMethod.GET, "/"); + handleMatch(exchange, "/{cars:[^;]+}{params}", "/cars"); + + matrixVariables = getMatrixVariables(exchange, "params"); + uriVariables = getUriTemplateVariables(exchange); + + assertNull(matrixVariables); + assertEquals("cars", uriVariables.get("cars")); + assertEquals("", uriVariables.get("params")); + } + + @Test + public void handleMatchMatrixVariablesDecoding() throws Exception { + HttpRequestPathHelper urlPathHelper = new HttpRequestPathHelper(); + urlPathHelper.setUrlDecode(false); + + this.handlerMapping.setPathHelper(urlPathHelper ); + + ServerWebExchange exchange = createExchange(HttpMethod.GET, "/"); + handleMatch(exchange, "/path{filter}", "/path;mvar=a%2fb"); + + MultiValueMap matrixVariables = getMatrixVariables(exchange, "filter"); + Map uriVariables = getUriTemplateVariables(exchange); + + assertNotNull(matrixVariables); + assertEquals(Collections.singletonList("a/b"), matrixVariables.get("mvar")); + assertEquals(";mvar=a/b", uriVariables.get("filter")); + } + + + private ServerWebExchange createExchange(HttpMethod method, String url) throws URISyntaxException { + ServerHttpRequest request = new MockServerHttpRequest(method, new URI(url)); + WebSessionManager sessionManager = mock(WebSessionManager.class); + return new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); + + } + + @SuppressWarnings("unchecked") + private void assertError(Mono mono, final Class exceptionClass, final Consumer consumer) { + TestSubscriber subscriber = new TestSubscriber<>(); + mono.subscribeWith(subscriber); + subscriber.assertErrorWith(ex -> { + assertEquals(exceptionClass, ex.getClass()); + consumer.accept((T) ex); + }); + } + + @SuppressWarnings("ConstantConditions") + private HandlerMethod getHandler(ServerWebExchange exchange) throws Exception { + Mono handler = this.handlerMapping.getHandler(exchange); + return (HandlerMethod) handler.get(); + } + + private void testHttpMediaTypeNotSupportedException(String url) throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.PUT, url); + exchange.getRequest().getHeaders().setContentType(MediaType.APPLICATION_JSON); + Mono mono = this.handlerMapping.getHandler(exchange); + + assertError(mono, HttpMediaTypeNotSupportedException.class, ex -> + assertEquals("Invalid supported consumable media types", + Collections.singletonList(new MediaType("application", "xml")), + ex.getSupportedMediaTypes())); + } + + private void testHttpOptions(String requestURI, String allowHeader) throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.OPTIONS, requestURI); + HandlerMethod handlerMethod = getHandler(exchange); + + ModelMap model = new ExtendedModelMap(); + Mono mono = new InvocableHandlerMethod(handlerMethod).invokeForRequest(exchange, model); + + HandlerResult result = mono.get(); + assertNotNull(result); + + Optional value = result.getReturnValue(); + assertTrue(value.isPresent()); + assertEquals(HttpHeaders.class, value.get().getClass()); + assertEquals(allowHeader, ((HttpHeaders) value.get()).getFirst("Allow")); + } + + private void testHttpMediaTypeNotAcceptableException(String url) throws Exception { + ServerWebExchange exchange = createExchange(HttpMethod.GET, url); + exchange.getRequest().getHeaders().setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + Mono mono = this.handlerMapping.getHandler(exchange); + + assertError(mono, HttpMediaTypeNotAcceptableException.class, ex -> + assertEquals("Invalid supported producible media types", + Collections.singletonList(new MediaType("application", "xml")), + ex.getSupportedMediaTypes())); + } + + private void handleMatch(ServerWebExchange exchange, String pattern, String lookupPath) { + RequestMappingInfo info = RequestMappingInfo.paths(pattern).build(); + this.handlerMapping.handleMatch(info, lookupPath, exchange); + } + + @SuppressWarnings("unchecked") + private MultiValueMap getMatrixVariables(ServerWebExchange exchange, String uriVarName) { + String attrName = HandlerMapping.MATRIX_VARIABLES_ATTRIBUTE; + return ((Map>) exchange.getAttributes().get(attrName)).get(uriVarName); + } + + @SuppressWarnings("unchecked") + private Map getUriTemplateVariables(ServerWebExchange exchange) { + String attrName = HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE; + return (Map) exchange.getAttributes().get(attrName); + } + + + @SuppressWarnings("unused") + @Controller + private static class TestController { + + @RequestMapping(value = "/foo", method = RequestMethod.GET) + public void foo() { + } + + @RequestMapping(value = "/foo", method = RequestMethod.GET, params="p") + public void fooParam() { + } + + @RequestMapping(value = "/ba*", method = { RequestMethod.GET, RequestMethod.HEAD }) + public void bar() { + } + + @RequestMapping(value = "") + public void empty() { + } + + @RequestMapping(value = "/person/{id}", method = RequestMethod.PUT, consumes="application/xml") + public void consumes(@RequestBody String text) { + } + + @RequestMapping(value = "/persons", produces="application/xml") + public String produces() { + return ""; + } + + @RequestMapping(value = "/params", params="foo=bar") + public String param() { + return ""; + } + + @RequestMapping(value = "/params", params="bar=baz") + public String param2() { + return ""; + } + + @RequestMapping(value = "/content", produces="application/xml") + public String xmlContent() { + return ""; + } + + @RequestMapping(value = "/content", produces="!application/xml") + public String nonXmlContent() { + return ""; + } + + @RequestMapping(value = "/something", method = RequestMethod.OPTIONS) + public HttpHeaders fooOptions() { + HttpHeaders headers = new HttpHeaders(); + headers.add("Allow", "PUT,POST"); + return headers; + } + } + + @SuppressWarnings("unused") + @Controller + private static class UserController { + + @RequestMapping(value = "/users", method = RequestMethod.GET, produces = "application/json") + public void getUser() { + } + + @RequestMapping(value = "/users", method = RequestMethod.PUT) + public void saveUser() { + } + } + + private static class TestRequestMappingInfoHandlerMapping extends RequestMappingInfoHandlerMapping { + + public void registerHandler(Object handler) { + super.detectHandlerMethods(handler); + } + + @Override + protected boolean isHandler(Class beanType) { + return AnnotationUtils.findAnnotation(beanType, RequestMapping.class) != null; + } + + @Override + protected RequestMappingInfo getMappingForMethod(Method method, Class handlerType) { + RequestMapping annotation = AnnotationUtils.findAnnotation(method, RequestMapping.class); + if (annotation != null) { + BuilderConfiguration options = new BuilderConfiguration(); + options.setPathHelper(getPathHelper()); + options.setPathMatcher(getPathMatcher()); + options.setSuffixPatternMatch(true); + options.setTrailingSlashMatch(true); + return RequestMappingInfo.paths(annotation.value()).methods(annotation.method()) + .params(annotation.params()).headers(annotation.headers()) + .consumes(annotation.consumes()).produces(annotation.produces()) + .options(options).build(); + } + else { + return null; + } + } + } + +} \ No newline at end of file diff --git a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java index d9bba5aefb..f8feef526d 100644 --- a/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java +++ b/spring-web-reactive/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2015 the original author or authors. + * Copyright 2002-2016 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,111 +16,232 @@ package org.springframework.web.reactive.result.method.annotation; -import java.net.URI; -import java.util.List; -import java.util.stream.StreamSupport; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; import org.junit.Before; import org.junit.Test; -import org.reactivestreams.Publisher; -import reactor.core.publisher.Flux; -import org.springframework.context.support.StaticApplicationContext; -import org.springframework.http.HttpMethod; -import org.springframework.http.server.reactive.MockServerHttpRequest; -import org.springframework.http.server.reactive.MockServerHttpResponse; -import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.core.annotation.AliasFor; +import org.springframework.http.MediaType; import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; -import org.springframework.web.method.HandlerMethod; -import org.springframework.web.server.adapter.DefaultServerWebExchange; -import org.springframework.web.server.ServerWebExchange; -import org.springframework.web.server.session.WebSessionManager; +import org.springframework.web.context.support.StaticWebApplicationContext; +import org.springframework.web.reactive.accept.FileExtensionContentTypeResolver; +import org.springframework.web.reactive.result.method.RequestMappingInfo; -import static java.util.stream.Collectors.toList; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; /** - * @author Sebastien Deleuze + * Unit tests for {@link RequestMappingHandlerMapping}. + * + * @author Rossen Stoyanchev */ public class RequestMappingHandlerMappingTests { - private RequestMappingHandlerMapping mapping; + private final StaticWebApplicationContext wac = new StaticWebApplicationContext(); + + private final RequestMappingHandlerMapping handlerMapping = new RequestMappingHandlerMapping(); @Before - public void setup() { - StaticApplicationContext wac = new StaticApplicationContext(); - wac.registerSingleton("handlerMapping", RequestMappingHandlerMapping.class); - wac.registerSingleton("controller", TestController.class); - wac.refresh(); - this.mapping = (RequestMappingHandlerMapping)wac.getBean("handlerMapping"); + public void setUp() throws Exception { + this.handlerMapping.setApplicationContext(wac); } @Test - public void path() throws Exception { - ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.GET, new URI("boo")); - MockServerHttpResponse response = new MockServerHttpResponse(); - WebSessionManager sessionManager = mock(WebSessionManager.class); - ServerWebExchange exchange = new DefaultServerWebExchange(request, response, sessionManager); - Publisher handlerPublisher = this.mapping.getHandler(exchange); - HandlerMethod handlerMethod = toHandlerMethod(handlerPublisher); - assertEquals(TestController.class.getMethod("boo"), handlerMethod.getMethod()); + public void useRegisteredSuffixPatternMatch() { + assertTrue(this.handlerMapping.useSuffixPatternMatch()); + assertTrue(this.handlerMapping.useRegisteredSuffixPatternMatch()); + + FileExtensionContentTypeResolver contentTypeResolver = mock(FileExtensionContentTypeResolver.class); + when(contentTypeResolver.getAllFileExtensions()).thenReturn(Collections.singletonList("json")); + + this.handlerMapping.setContentTypeResolver(contentTypeResolver); + this.handlerMapping.afterPropertiesSet(); + + assertTrue(this.handlerMapping.useSuffixPatternMatch()); + assertTrue(this.handlerMapping.useRegisteredSuffixPatternMatch()); + assertEquals(Collections.singletonList("json"), this.handlerMapping.getFileExtensions()); } @Test - public void method() throws Exception { - ServerHttpRequest request = new MockServerHttpRequest(HttpMethod.POST, new URI("foo")); - MockServerHttpResponse response = new MockServerHttpResponse(); - WebSessionManager sessionManager = mock(WebSessionManager.class); - ServerWebExchange exchange = new DefaultServerWebExchange(request, response, sessionManager); - Publisher handlerPublisher = this.mapping.getHandler(exchange); - HandlerMethod handlerMethod = toHandlerMethod(handlerPublisher); - assertEquals(TestController.class.getMethod("postFoo"), handlerMethod.getMethod()); + public void useRegisteredSuffixPatternMatchInitialization() { + FileExtensionContentTypeResolver contentTypeResolver = mock(FileExtensionContentTypeResolver.class); + when(contentTypeResolver.getAllFileExtensions()).thenReturn(Collections.singletonList("json")); - request = new MockServerHttpRequest(HttpMethod.GET, new URI("foo")); - exchange = new DefaultServerWebExchange(request, new MockServerHttpResponse(), sessionManager); - handlerPublisher = this.mapping.getHandler(exchange); - handlerMethod = toHandlerMethod(handlerPublisher); - assertEquals(TestController.class.getMethod("getFoo"), handlerMethod.getMethod()); + final Set actualExtensions = new HashSet<>(); + RequestMappingHandlerMapping localHandlerMapping = new RequestMappingHandlerMapping() { + @Override + protected RequestMappingInfo getMappingForMethod(Method method, Class handlerType) { + actualExtensions.addAll(getFileExtensions()); + return super.getMappingForMethod(method, handlerType); + } + }; + this.wac.registerSingleton("testController", ComposedAnnotationController.class); + this.wac.refresh(); + + localHandlerMapping.setContentTypeResolver(contentTypeResolver); + localHandlerMapping.setUseRegisteredSuffixPatternMatch(true); + localHandlerMapping.setApplicationContext(this.wac); + localHandlerMapping.afterPropertiesSet(); + + assertEquals(Collections.singleton("json"), actualExtensions); } - private HandlerMethod toHandlerMethod(Publisher handlerPublisher) throws InterruptedException { - assertNotNull(handlerPublisher); - List handlerList = StreamSupport.stream(Flux.from(handlerPublisher).toIterable().spliterator(), false).collect(toList()); - assertEquals(1, handlerList.size()); - return (HandlerMethod) handlerList.get(0); + @Test + public void useSuffixPatternMatch() { + assertTrue(this.handlerMapping.useSuffixPatternMatch()); + assertTrue(this.handlerMapping.useRegisteredSuffixPatternMatch()); + + this.handlerMapping.setUseSuffixPatternMatch(false); + assertFalse(this.handlerMapping.useSuffixPatternMatch()); + + this.handlerMapping.setUseRegisteredSuffixPatternMatch(false); + assertFalse("'false' registeredSuffixPatternMatch shouldn't impact suffixPatternMatch", + this.handlerMapping.useSuffixPatternMatch()); + + this.handlerMapping.setUseRegisteredSuffixPatternMatch(true); + assertTrue("'true' registeredSuffixPatternMatch should enable suffixPatternMatch", + this.handlerMapping.useSuffixPatternMatch()); + } + + @Test + public void resolveEmbeddedValuesInPatterns() { + this.handlerMapping.setEmbeddedValueResolver( + value -> "/${pattern}/bar".equals(value) ? "/foo/bar" : value + ); + + String[] patterns = new String[] { "/foo", "/${pattern}/bar" }; + String[] result = this.handlerMapping.resolveEmbeddedValuesInPatterns(patterns); + + assertArrayEquals(new String[] { "/foo", "/foo/bar" }, result); + } + + @Test + public void resolveRequestMappingViaComposedAnnotation() throws Exception { + RequestMappingInfo info = assertComposedAnnotationMapping("postJson", "/postJson", RequestMethod.POST); + + assertEquals(MediaType.APPLICATION_JSON_VALUE, + info.getConsumesCondition().getConsumableMediaTypes().iterator().next().toString()); + assertEquals(MediaType.APPLICATION_JSON_VALUE, + info.getProducesCondition().getProducibleMediaTypes().iterator().next().toString()); + } + + @Test + public void getMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.GET); + } + + @Test + public void postMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.POST); + } + + @Test + public void putMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.PUT); + } + + @Test + public void deleteMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.DELETE); + } + + @Test + public void patchMapping() throws Exception { + assertComposedAnnotationMapping(RequestMethod.PATCH); + } + + private RequestMappingInfo assertComposedAnnotationMapping(RequestMethod requestMethod) throws Exception { + String methodName = requestMethod.name().toLowerCase(); + String path = "/" + methodName; + + return assertComposedAnnotationMapping(methodName, path, requestMethod); + } + + private RequestMappingInfo assertComposedAnnotationMapping(String methodName, String path, + RequestMethod requestMethod) throws Exception { + + Class clazz = ComposedAnnotationController.class; + Method method = clazz.getMethod(methodName); + RequestMappingInfo info = this.handlerMapping.getMappingForMethod(method, clazz); + + assertNotNull(info); + + Set paths = info.getPatternsCondition().getPatterns(); + assertEquals(1, paths.size()); + assertEquals(path, paths.iterator().next()); + + Set methods = info.getMethodsCondition().getMethods(); + assertEquals(1, methods.size()); + assertEquals(requestMethod, methods.iterator().next()); + + return info; } - @Controller - @SuppressWarnings("unused") - private static class TestController { + @Controller @SuppressWarnings("unused") + static class ComposedAnnotationController { - @RequestMapping(path = "foo", method = RequestMethod.POST) - public String postFoo() { - return "postFoo"; + @RequestMapping + public void handle() { } - @RequestMapping(path = "foo", method = RequestMethod.GET) - public String getFoo() { - return "getFoo"; + @PostJson("/postJson") + public void postJson() { } - @RequestMapping("bar") - public String bar() { - return "bar"; + @GetMapping("/get") + public void get() { } - @RequestMapping("boo") - public String boo() { - return "boo"; + @PostMapping("/post") + public void post() { } + @PutMapping("/put") + public void put() { + } + + @DeleteMapping("/delete") + public void delete() { + } + + @PatchMapping("/patch") + public void patch() { + } + + } + + @RequestMapping(method = RequestMethod.POST, + produces = MediaType.APPLICATION_JSON_VALUE, + consumes = MediaType.APPLICATION_JSON_VALUE) + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.RUNTIME) + @interface PostJson { + + @AliasFor(annotation = RequestMapping.class, attribute = "path") @SuppressWarnings("unused") + String[] value() default {}; } }