001/* 002 * Copyright 2002-2019 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.web.reactive.result.method; 018 019import java.lang.reflect.Method; 020import java.util.ArrayList; 021import java.util.Collections; 022import java.util.Comparator; 023import java.util.EnumSet; 024import java.util.LinkedHashSet; 025import java.util.List; 026import java.util.Map; 027import java.util.Set; 028import java.util.stream.Collectors; 029 030import reactor.core.publisher.Mono; 031 032import org.springframework.http.HttpHeaders; 033import org.springframework.http.HttpMethod; 034import org.springframework.http.InvalidMediaTypeException; 035import org.springframework.http.MediaType; 036import org.springframework.http.server.PathContainer; 037import org.springframework.http.server.reactive.ServerHttpRequest; 038import org.springframework.util.Assert; 039import org.springframework.util.MultiValueMap; 040import org.springframework.web.method.HandlerMethod; 041import org.springframework.web.reactive.HandlerMapping; 042import org.springframework.web.reactive.result.condition.NameValueExpression; 043import org.springframework.web.reactive.result.condition.ProducesRequestCondition; 044import org.springframework.web.server.MethodNotAllowedException; 045import org.springframework.web.server.NotAcceptableStatusException; 046import org.springframework.web.server.ServerWebExchange; 047import org.springframework.web.server.ServerWebInputException; 048import org.springframework.web.server.UnsupportedMediaTypeStatusException; 049import org.springframework.web.util.pattern.PathPattern; 050 051/** 052 * Abstract base class for classes for which {@link RequestMappingInfo} defines 053 * the mapping between a request and a handler method. 054 * 055 * @author Rossen Stoyanchev 056 * @author Sam Brannen 057 * @since 5.0 058 */ 059public abstract class RequestMappingInfoHandlerMapping extends AbstractHandlerMethodMapping<RequestMappingInfo> { 060 061 private static final Method HTTP_OPTIONS_HANDLE_METHOD; 062 063 static { 064 try { 065 HTTP_OPTIONS_HANDLE_METHOD = HttpOptionsHandler.class.getMethod("handle"); 066 } 067 catch (NoSuchMethodException ex) { 068 // Should never happen 069 throw new IllegalStateException("No handler for HTTP OPTIONS", ex); 070 } 071 } 072 073 074 /** 075 * Check if the given RequestMappingInfo matches the current request and 076 * return a (potentially new) instance with conditions that match the 077 * current request -- for example with a subset of URL patterns. 078 * @return an info in case of a match; or {@code null} otherwise. 079 */ 080 @Override 081 protected RequestMappingInfo getMatchingMapping(RequestMappingInfo info, ServerWebExchange exchange) { 082 return info.getMatchingCondition(exchange); 083 } 084 085 /** 086 * Provide a Comparator to sort RequestMappingInfos matched to a request. 087 */ 088 @Override 089 protected Comparator<RequestMappingInfo> getMappingComparator(final ServerWebExchange exchange) { 090 return (info1, info2) -> info1.compareTo(info2, exchange); 091 } 092 093 @Override 094 public Mono<HandlerMethod> getHandlerInternal(ServerWebExchange exchange) { 095 exchange.getAttributes().remove(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE); 096 return super.getHandlerInternal(exchange) 097 .doOnTerminate(() -> ProducesRequestCondition.clearMediaTypesAttribute(exchange)); 098 } 099 100 /** 101 * Expose URI template variables, matrix variables, and producible media types in the request. 102 * @see HandlerMapping#URI_TEMPLATE_VARIABLES_ATTRIBUTE 103 * @see HandlerMapping#MATRIX_VARIABLES_ATTRIBUTE 104 * @see HandlerMapping#PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE 105 */ 106 @Override 107 protected void handleMatch(RequestMappingInfo info, HandlerMethod handlerMethod, 108 ServerWebExchange exchange) { 109 110 super.handleMatch(info, handlerMethod, exchange); 111 112 PathContainer lookupPath = exchange.getRequest().getPath().pathWithinApplication(); 113 114 PathPattern bestPattern; 115 Map<String, String> uriVariables; 116 Map<String, MultiValueMap<String, String>> matrixVariables; 117 118 Set<PathPattern> patterns = info.getPatternsCondition().getPatterns(); 119 if (patterns.isEmpty()) { 120 bestPattern = getPathPatternParser().parse(lookupPath.value()); 121 uriVariables = Collections.emptyMap(); 122 matrixVariables = Collections.emptyMap(); 123 } 124 else { 125 bestPattern = patterns.iterator().next(); 126 PathPattern.PathMatchInfo result = bestPattern.matchAndExtract(lookupPath); 127 Assert.notNull(result, () -> 128 "Expected bestPattern: " + bestPattern + " to match lookupPath " + lookupPath); 129 uriVariables = result.getUriVariables(); 130 matrixVariables = result.getMatrixVariables(); 131 } 132 133 exchange.getAttributes().put(BEST_MATCHING_HANDLER_ATTRIBUTE, handlerMethod); 134 exchange.getAttributes().put(BEST_MATCHING_PATTERN_ATTRIBUTE, bestPattern); 135 exchange.getAttributes().put(URI_TEMPLATE_VARIABLES_ATTRIBUTE, uriVariables); 136 exchange.getAttributes().put(MATRIX_VARIABLES_ATTRIBUTE, matrixVariables); 137 138 if (!info.getProducesCondition().getProducibleMediaTypes().isEmpty()) { 139 Set<MediaType> mediaTypes = info.getProducesCondition().getProducibleMediaTypes(); 140 exchange.getAttributes().put(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mediaTypes); 141 } 142 } 143 144 /** 145 * Iterate all RequestMappingInfos once again, look if any match by URL at 146 * least and raise exceptions accordingly. 147 * @throws MethodNotAllowedException for matches by URL but not by HTTP method 148 * @throws UnsupportedMediaTypeStatusException if there are matches by URL 149 * and HTTP method but not by consumable media types 150 * @throws NotAcceptableStatusException if there are matches by URL and HTTP 151 * method but not by producible media types 152 * @throws ServerWebInputException if there are matches by URL and HTTP 153 * method but not by query parameter conditions 154 */ 155 @Override 156 protected HandlerMethod handleNoMatch(Set<RequestMappingInfo> infos, 157 ServerWebExchange exchange) throws Exception { 158 159 PartialMatchHelper helper = new PartialMatchHelper(infos, exchange); 160 161 if (helper.isEmpty()) { 162 return null; 163 } 164 165 ServerHttpRequest request = exchange.getRequest(); 166 167 if (helper.hasMethodsMismatch()) { 168 String httpMethod = request.getMethodValue(); 169 Set<HttpMethod> methods = helper.getAllowedMethods(); 170 if (HttpMethod.OPTIONS.matches(httpMethod)) { 171 HttpOptionsHandler handler = new HttpOptionsHandler(methods); 172 return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD); 173 } 174 throw new MethodNotAllowedException(httpMethod, methods); 175 } 176 177 if (helper.hasConsumesMismatch()) { 178 Set<MediaType> mediaTypes = helper.getConsumableMediaTypes(); 179 MediaType contentType; 180 try { 181 contentType = request.getHeaders().getContentType(); 182 } 183 catch (InvalidMediaTypeException ex) { 184 throw new UnsupportedMediaTypeStatusException(ex.getMessage()); 185 } 186 throw new UnsupportedMediaTypeStatusException(contentType, new ArrayList<>(mediaTypes)); 187 } 188 189 if (helper.hasProducesMismatch()) { 190 Set<MediaType> mediaTypes = helper.getProducibleMediaTypes(); 191 throw new NotAcceptableStatusException(new ArrayList<>(mediaTypes)); 192 } 193 194 if (helper.hasParamsMismatch()) { 195 throw new ServerWebInputException( 196 "Unsatisfied query parameter conditions: " + helper.getParamConditions() + 197 ", actual parameters: " + request.getQueryParams()); 198 } 199 200 return null; 201 } 202 203 204 /** 205 * Aggregate all partial matches and expose methods checking across them. 206 */ 207 private static class PartialMatchHelper { 208 209 private final List<PartialMatch> partialMatches = new ArrayList<>(); 210 211 212 public PartialMatchHelper(Set<RequestMappingInfo> infos, ServerWebExchange exchange) { 213 this.partialMatches.addAll(infos.stream(). 214 filter(info -> info.getPatternsCondition().getMatchingCondition(exchange) != null). 215 map(info -> new PartialMatch(info, exchange)). 216 collect(Collectors.toList())); 217 } 218 219 220 /** 221 * Whether there any partial matches. 222 */ 223 public boolean isEmpty() { 224 return this.partialMatches.isEmpty(); 225 } 226 227 /** 228 * Any partial matches for "methods"? 229 */ 230 public boolean hasMethodsMismatch() { 231 return this.partialMatches.stream(). 232 noneMatch(PartialMatch::hasMethodsMatch); 233 } 234 235 /** 236 * Any partial matches for "methods" and "consumes"? 237 */ 238 public boolean hasConsumesMismatch() { 239 return this.partialMatches.stream(). 240 noneMatch(PartialMatch::hasConsumesMatch); 241 } 242 243 /** 244 * Any partial matches for "methods", "consumes", and "produces"? 245 */ 246 public boolean hasProducesMismatch() { 247 return this.partialMatches.stream(). 248 noneMatch(PartialMatch::hasProducesMatch); 249 } 250 251 /** 252 * Any partial matches for "methods", "consumes", "produces", and "params"? 253 */ 254 public boolean hasParamsMismatch() { 255 return this.partialMatches.stream(). 256 noneMatch(PartialMatch::hasParamsMatch); 257 } 258 259 /** 260 * Return declared HTTP methods. 261 */ 262 public Set<HttpMethod> getAllowedMethods() { 263 return this.partialMatches.stream(). 264 flatMap(m -> m.getInfo().getMethodsCondition().getMethods().stream()). 265 map(requestMethod -> HttpMethod.resolve(requestMethod.name())). 266 collect(Collectors.toSet()); 267 } 268 269 /** 270 * Return declared "consumable" types but only among those that also 271 * match the "methods" condition. 272 */ 273 public Set<MediaType> getConsumableMediaTypes() { 274 return this.partialMatches.stream().filter(PartialMatch::hasMethodsMatch). 275 flatMap(m -> m.getInfo().getConsumesCondition().getConsumableMediaTypes().stream()). 276 collect(Collectors.toCollection(LinkedHashSet::new)); 277 } 278 279 /** 280 * Return declared "producible" types but only among those that also 281 * match the "methods" and "consumes" conditions. 282 */ 283 public Set<MediaType> getProducibleMediaTypes() { 284 return this.partialMatches.stream().filter(PartialMatch::hasConsumesMatch). 285 flatMap(m -> m.getInfo().getProducesCondition().getProducibleMediaTypes().stream()). 286 collect(Collectors.toCollection(LinkedHashSet::new)); 287 } 288 289 /** 290 * Return declared "params" conditions but only among those that also 291 * match the "methods", "consumes", and "params" conditions. 292 */ 293 public List<Set<NameValueExpression<String>>> getParamConditions() { 294 return this.partialMatches.stream().filter(PartialMatch::hasProducesMatch). 295 map(match -> match.getInfo().getParamsCondition().getExpressions()). 296 collect(Collectors.toList()); 297 } 298 299 300 /** 301 * Container for a RequestMappingInfo that matches the URL path at least. 302 */ 303 private static class PartialMatch { 304 305 private final RequestMappingInfo info; 306 307 private final boolean methodsMatch; 308 309 private final boolean consumesMatch; 310 311 private final boolean producesMatch; 312 313 private final boolean paramsMatch; 314 315 316 /** 317 * Create a new {@link PartialMatch} instance. 318 * @param info the RequestMappingInfo that matches the URL path 319 * @param exchange the current exchange 320 */ 321 public PartialMatch(RequestMappingInfo info, ServerWebExchange exchange) { 322 this.info = info; 323 this.methodsMatch = info.getMethodsCondition().getMatchingCondition(exchange) != null; 324 this.consumesMatch = info.getConsumesCondition().getMatchingCondition(exchange) != null; 325 this.producesMatch = info.getProducesCondition().getMatchingCondition(exchange) != null; 326 this.paramsMatch = info.getParamsCondition().getMatchingCondition(exchange) != null; 327 } 328 329 330 public RequestMappingInfo getInfo() { 331 return this.info; 332 } 333 334 public boolean hasMethodsMatch() { 335 return this.methodsMatch; 336 } 337 338 public boolean hasConsumesMatch() { 339 return hasMethodsMatch() && this.consumesMatch; 340 } 341 342 public boolean hasProducesMatch() { 343 return hasConsumesMatch() && this.producesMatch; 344 } 345 346 public boolean hasParamsMatch() { 347 return hasProducesMatch() && this.paramsMatch; 348 } 349 350 @Override 351 public String toString() { 352 return this.info.toString(); 353 } 354 } 355 } 356 357 /** 358 * Default handler for HTTP OPTIONS. 359 */ 360 private static class HttpOptionsHandler { 361 362 private final HttpHeaders headers = new HttpHeaders(); 363 364 365 public HttpOptionsHandler(Set<HttpMethod> declaredMethods) { 366 this.headers.setAllow(initAllowedHttpMethods(declaredMethods)); 367 } 368 369 private static Set<HttpMethod> initAllowedHttpMethods(Set<HttpMethod> declaredMethods) { 370 if (declaredMethods.isEmpty()) { 371 return EnumSet.allOf(HttpMethod.class).stream() 372 .filter(method -> method != HttpMethod.TRACE) 373 .collect(Collectors.toSet()); 374 } 375 else { 376 Set<HttpMethod> result = new LinkedHashSet<>(declaredMethods); 377 if (result.contains(HttpMethod.GET)) { 378 result.add(HttpMethod.HEAD); 379 } 380 result.add(HttpMethod.OPTIONS); 381 return result; 382 } 383 } 384 385 @SuppressWarnings("unused") 386 public HttpHeaders handle() { 387 return this.headers; 388 } 389 } 390 391}