001/* 002 * Copyright 2002-2020 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.annotation; 018 019import java.lang.reflect.AnnotatedElement; 020import java.lang.reflect.Method; 021import java.lang.reflect.Parameter; 022import java.util.Collections; 023import java.util.LinkedHashMap; 024import java.util.Map; 025import java.util.function.Predicate; 026 027import org.springframework.context.EmbeddedValueResolverAware; 028import org.springframework.core.annotation.AnnotatedElementUtils; 029import org.springframework.core.annotation.MergedAnnotation; 030import org.springframework.core.annotation.MergedAnnotations; 031import org.springframework.lang.Nullable; 032import org.springframework.stereotype.Controller; 033import org.springframework.util.Assert; 034import org.springframework.util.CollectionUtils; 035import org.springframework.util.StringUtils; 036import org.springframework.util.StringValueResolver; 037import org.springframework.web.bind.annotation.CrossOrigin; 038import org.springframework.web.bind.annotation.RequestBody; 039import org.springframework.web.bind.annotation.RequestMapping; 040import org.springframework.web.bind.annotation.RequestMethod; 041import org.springframework.web.cors.CorsConfiguration; 042import org.springframework.web.method.HandlerMethod; 043import org.springframework.web.reactive.accept.RequestedContentTypeResolver; 044import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder; 045import org.springframework.web.reactive.result.condition.ConsumesRequestCondition; 046import org.springframework.web.reactive.result.condition.RequestCondition; 047import org.springframework.web.reactive.result.method.RequestMappingInfo; 048import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; 049 050/** 051 * An extension of {@link RequestMappingInfoHandlerMapping} that creates 052 * {@link RequestMappingInfo} instances from class-level and method-level 053 * {@link RequestMapping @RequestMapping} annotations. 054 * 055 * @author Rossen Stoyanchev 056 * @author Sam Brannen 057 * @since 5.0 058 */ 059public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping 060 implements EmbeddedValueResolverAware { 061 062 private final Map<String, Predicate<Class<?>>> pathPrefixes = new LinkedHashMap<>(); 063 064 private RequestedContentTypeResolver contentTypeResolver = new RequestedContentTypeResolverBuilder().build(); 065 066 @Nullable 067 private StringValueResolver embeddedValueResolver; 068 069 private RequestMappingInfo.BuilderConfiguration config = new RequestMappingInfo.BuilderConfiguration(); 070 071 072 /** 073 * Configure path prefixes to apply to controller methods. 074 * <p>Prefixes are used to enrich the mappings of every {@code @RequestMapping} 075 * method whose controller type is matched by a corresponding 076 * {@code Predicate} in the map. The prefix for the first matching predicate 077 * is used, assuming the input map has predictable order. 078 * <p>Consider using {@link org.springframework.web.method.HandlerTypePredicate 079 * HandlerTypePredicate} to group controllers. 080 * @param prefixes a map with path prefixes as key 081 * @since 5.1 082 * @see org.springframework.web.method.HandlerTypePredicate 083 */ 084 public void setPathPrefixes(Map<String, Predicate<Class<?>>> prefixes) { 085 this.pathPrefixes.clear(); 086 prefixes.entrySet().stream() 087 .filter(entry -> StringUtils.hasText(entry.getKey())) 088 .forEach(entry -> this.pathPrefixes.put(entry.getKey(), entry.getValue())); 089 } 090 091 /** 092 * The configured path prefixes as a read-only, possibly empty map. 093 * @since 5.1 094 */ 095 public Map<String, Predicate<Class<?>>> getPathPrefixes() { 096 return Collections.unmodifiableMap(this.pathPrefixes); 097 } 098 099 /** 100 * Set the {@link RequestedContentTypeResolver} to use to determine requested 101 * media types. If not set, the default constructor is used. 102 */ 103 public void setContentTypeResolver(RequestedContentTypeResolver contentTypeResolver) { 104 Assert.notNull(contentTypeResolver, "'contentTypeResolver' must not be null"); 105 this.contentTypeResolver = contentTypeResolver; 106 } 107 108 /** 109 * Return the configured {@link RequestedContentTypeResolver}. 110 */ 111 public RequestedContentTypeResolver getContentTypeResolver() { 112 return this.contentTypeResolver; 113 } 114 115 @Override 116 public void setEmbeddedValueResolver(StringValueResolver resolver) { 117 this.embeddedValueResolver = resolver; 118 } 119 120 @Override 121 public void afterPropertiesSet() { 122 this.config = new RequestMappingInfo.BuilderConfiguration(); 123 this.config.setPatternParser(getPathPatternParser()); 124 this.config.setContentTypeResolver(getContentTypeResolver()); 125 126 super.afterPropertiesSet(); 127 } 128 129 130 /** 131 * {@inheritDoc} 132 * Expects a handler to have a type-level @{@link Controller} annotation. 133 */ 134 @Override 135 protected boolean isHandler(Class<?> beanType) { 136 return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) || 137 AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class)); 138 } 139 140 /** 141 * Uses method and type-level @{@link RequestMapping} annotations to create 142 * the RequestMappingInfo. 143 * @return the created RequestMappingInfo, or {@code null} if the method 144 * does not have a {@code @RequestMapping} annotation. 145 * @see #getCustomMethodCondition(Method) 146 * @see #getCustomTypeCondition(Class) 147 */ 148 @Override 149 @Nullable 150 protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) { 151 RequestMappingInfo info = createRequestMappingInfo(method); 152 if (info != null) { 153 RequestMappingInfo typeInfo = createRequestMappingInfo(handlerType); 154 if (typeInfo != null) { 155 info = typeInfo.combine(info); 156 } 157 for (Map.Entry<String, Predicate<Class<?>>> entry : this.pathPrefixes.entrySet()) { 158 if (entry.getValue().test(handlerType)) { 159 String prefix = entry.getKey(); 160 if (this.embeddedValueResolver != null) { 161 prefix = this.embeddedValueResolver.resolveStringValue(prefix); 162 } 163 info = RequestMappingInfo.paths(prefix).options(this.config).build().combine(info); 164 break; 165 } 166 } 167 } 168 return info; 169 } 170 171 /** 172 * Delegates to {@link #createRequestMappingInfo(RequestMapping, RequestCondition)}, 173 * supplying the appropriate custom {@link RequestCondition} depending on whether 174 * the supplied {@code annotatedElement} is a class or method. 175 * @see #getCustomTypeCondition(Class) 176 * @see #getCustomMethodCondition(Method) 177 */ 178 @Nullable 179 private RequestMappingInfo createRequestMappingInfo(AnnotatedElement element) { 180 RequestMapping requestMapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class); 181 RequestCondition<?> condition = (element instanceof Class ? 182 getCustomTypeCondition((Class<?>) element) : getCustomMethodCondition((Method) element)); 183 return (requestMapping != null ? createRequestMappingInfo(requestMapping, condition) : null); 184 } 185 186 /** 187 * Provide a custom type-level request condition. 188 * The custom {@link RequestCondition} can be of any type so long as the 189 * same condition type is returned from all calls to this method in order 190 * to ensure custom request conditions can be combined and compared. 191 * <p>Consider extending 192 * {@link org.springframework.web.reactive.result.condition.AbstractRequestCondition 193 * AbstractRequestCondition} for custom condition types and using 194 * {@link org.springframework.web.reactive.result.condition.CompositeRequestCondition 195 * CompositeRequestCondition} to provide multiple custom conditions. 196 * @param handlerType the handler type for which to create the condition 197 * @return the condition, or {@code null} 198 */ 199 @SuppressWarnings("UnusedParameters") 200 @Nullable 201 protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) { 202 return null; 203 } 204 205 /** 206 * Provide a custom method-level request condition. 207 * The custom {@link RequestCondition} can be of any type so long as the 208 * same condition type is returned from all calls to this method in order 209 * to ensure custom request conditions can be combined and compared. 210 * <p>Consider extending 211 * {@link org.springframework.web.reactive.result.condition.AbstractRequestCondition 212 * AbstractRequestCondition} for custom condition types and using 213 * {@link org.springframework.web.reactive.result.condition.CompositeRequestCondition 214 * CompositeRequestCondition} to provide multiple custom conditions. 215 * @param method the handler method for which to create the condition 216 * @return the condition, or {@code null} 217 */ 218 @SuppressWarnings("UnusedParameters") 219 @Nullable 220 protected RequestCondition<?> getCustomMethodCondition(Method method) { 221 return null; 222 } 223 224 /** 225 * Create a {@link RequestMappingInfo} from the supplied 226 * {@link RequestMapping @RequestMapping} annotation, which is either 227 * a directly declared annotation, a meta-annotation, or the synthesized 228 * result of merging annotation attributes within an annotation hierarchy. 229 */ 230 protected RequestMappingInfo createRequestMappingInfo( 231 RequestMapping requestMapping, @Nullable RequestCondition<?> customCondition) { 232 233 RequestMappingInfo.Builder builder = RequestMappingInfo 234 .paths(resolveEmbeddedValuesInPatterns(requestMapping.path())) 235 .methods(requestMapping.method()) 236 .params(requestMapping.params()) 237 .headers(requestMapping.headers()) 238 .consumes(requestMapping.consumes()) 239 .produces(requestMapping.produces()) 240 .mappingName(requestMapping.name()); 241 if (customCondition != null) { 242 builder.customCondition(customCondition); 243 } 244 return builder.options(this.config).build(); 245 } 246 247 /** 248 * Resolve placeholder values in the given array of patterns. 249 * @return a new array with updated patterns 250 */ 251 protected String[] resolveEmbeddedValuesInPatterns(String[] patterns) { 252 if (this.embeddedValueResolver == null) { 253 return patterns; 254 } 255 else { 256 String[] resolvedPatterns = new String[patterns.length]; 257 for (int i = 0; i < patterns.length; i++) { 258 resolvedPatterns[i] = this.embeddedValueResolver.resolveStringValue(patterns[i]); 259 } 260 return resolvedPatterns; 261 } 262 } 263 264 @Override 265 public void registerMapping(RequestMappingInfo mapping, Object handler, Method method) { 266 super.registerMapping(mapping, handler, method); 267 updateConsumesCondition(mapping, method); 268 } 269 270 @Override 271 protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) { 272 super.registerHandlerMethod(handler, method, mapping); 273 updateConsumesCondition(mapping, method); 274 } 275 276 private void updateConsumesCondition(RequestMappingInfo info, Method method) { 277 ConsumesRequestCondition condition = info.getConsumesCondition(); 278 if (!condition.isEmpty()) { 279 for (Parameter parameter : method.getParameters()) { 280 MergedAnnotation<RequestBody> annot = MergedAnnotations.from(parameter).get(RequestBody.class); 281 if (annot.isPresent()) { 282 condition.setBodyRequired(annot.getBoolean("required")); 283 break; 284 } 285 } 286 } 287 } 288 289 @Override 290 protected CorsConfiguration initCorsConfiguration(Object handler, Method method, RequestMappingInfo mappingInfo) { 291 HandlerMethod handlerMethod = createHandlerMethod(handler, method); 292 Class<?> beanType = handlerMethod.getBeanType(); 293 CrossOrigin typeAnnotation = AnnotatedElementUtils.findMergedAnnotation(beanType, CrossOrigin.class); 294 CrossOrigin methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, CrossOrigin.class); 295 296 if (typeAnnotation == null && methodAnnotation == null) { 297 return null; 298 } 299 300 CorsConfiguration config = new CorsConfiguration(); 301 updateCorsConfig(config, typeAnnotation); 302 updateCorsConfig(config, methodAnnotation); 303 304 if (CollectionUtils.isEmpty(config.getAllowedMethods())) { 305 for (RequestMethod allowedMethod : mappingInfo.getMethodsCondition().getMethods()) { 306 config.addAllowedMethod(allowedMethod.name()); 307 } 308 } 309 return config.applyPermitDefaultValues(); 310 } 311 312 private void updateCorsConfig(CorsConfiguration config, @Nullable CrossOrigin annotation) { 313 if (annotation == null) { 314 return; 315 } 316 for (String origin : annotation.origins()) { 317 config.addAllowedOrigin(resolveCorsAnnotationValue(origin)); 318 } 319 for (RequestMethod method : annotation.methods()) { 320 config.addAllowedMethod(method.name()); 321 } 322 for (String header : annotation.allowedHeaders()) { 323 config.addAllowedHeader(resolveCorsAnnotationValue(header)); 324 } 325 for (String header : annotation.exposedHeaders()) { 326 config.addExposedHeader(resolveCorsAnnotationValue(header)); 327 } 328 329 String allowCredentials = resolveCorsAnnotationValue(annotation.allowCredentials()); 330 if ("true".equalsIgnoreCase(allowCredentials)) { 331 config.setAllowCredentials(true); 332 } 333 else if ("false".equalsIgnoreCase(allowCredentials)) { 334 config.setAllowCredentials(false); 335 } 336 else if (!allowCredentials.isEmpty()) { 337 throw new IllegalStateException("@CrossOrigin's allowCredentials value must be \"true\", \"false\", " + 338 "or an empty string (\"\"): current value is [" + allowCredentials + "]"); 339 } 340 341 if (annotation.maxAge() >= 0 && config.getMaxAge() == null) { 342 config.setMaxAge(annotation.maxAge()); 343 } 344 } 345 346 private String resolveCorsAnnotationValue(String value) { 347 if (this.embeddedValueResolver != null) { 348 String resolved = this.embeddedValueResolver.resolveStringValue(value); 349 return (resolved != null ? resolved : ""); 350 } 351 else { 352 return value; 353 } 354 } 355 356}