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.servlet.mvc.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.List;
025import java.util.Map;
026import java.util.Set;
027import java.util.function.Predicate;
028
029import javax.servlet.http.HttpServletRequest;
030
031import org.springframework.context.EmbeddedValueResolverAware;
032import org.springframework.core.annotation.AnnotatedElementUtils;
033import org.springframework.core.annotation.MergedAnnotation;
034import org.springframework.core.annotation.MergedAnnotations;
035import org.springframework.lang.Nullable;
036import org.springframework.stereotype.Controller;
037import org.springframework.util.Assert;
038import org.springframework.util.CollectionUtils;
039import org.springframework.util.StringValueResolver;
040import org.springframework.web.accept.ContentNegotiationManager;
041import org.springframework.web.bind.annotation.CrossOrigin;
042import org.springframework.web.bind.annotation.RequestBody;
043import org.springframework.web.bind.annotation.RequestMapping;
044import org.springframework.web.bind.annotation.RequestMethod;
045import org.springframework.web.cors.CorsConfiguration;
046import org.springframework.web.method.HandlerMethod;
047import org.springframework.web.servlet.handler.MatchableHandlerMapping;
048import org.springframework.web.servlet.handler.RequestMatchResult;
049import org.springframework.web.servlet.mvc.condition.AbstractRequestCondition;
050import org.springframework.web.servlet.mvc.condition.CompositeRequestCondition;
051import org.springframework.web.servlet.mvc.condition.ConsumesRequestCondition;
052import org.springframework.web.servlet.mvc.condition.RequestCondition;
053import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
054import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;
055
056/**
057 * Creates {@link RequestMappingInfo} instances from type and method-level
058 * {@link RequestMapping @RequestMapping} annotations in
059 * {@link Controller @Controller} classes.
060 *
061 * <p><strong>Deprecation Note:</strong></p> In 5.2.4,
062 * {@link #setUseSuffixPatternMatch(boolean) useSuffixPatternMatch} and
063 * {@link #setUseRegisteredSuffixPatternMatch(boolean) useRegisteredSuffixPatternMatch}
064 * are deprecated in order to discourage use of path extensions for request
065 * mapping and for content negotiation (with similar deprecations in
066 * {@link org.springframework.web.accept.ContentNegotiationManagerFactoryBean
067 * ContentNegotiationManagerFactoryBean}). For further context, please read issue
068 * <a href="https://github.com/spring-projects/spring-framework/issues/24179">#24719</a>.
069 *
070 * @author Arjen Poutsma
071 * @author Rossen Stoyanchev
072 * @author Sam Brannen
073 * @since 3.1
074 */
075public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMapping
076                implements MatchableHandlerMapping, EmbeddedValueResolverAware {
077
078        private boolean useSuffixPatternMatch = true;
079
080        private boolean useRegisteredSuffixPatternMatch = false;
081
082        private boolean useTrailingSlashMatch = true;
083
084        private Map<String, Predicate<Class<?>>> pathPrefixes = new LinkedHashMap<>();
085
086        private ContentNegotiationManager contentNegotiationManager = new ContentNegotiationManager();
087
088        @Nullable
089        private StringValueResolver embeddedValueResolver;
090
091        private RequestMappingInfo.BuilderConfiguration config = new RequestMappingInfo.BuilderConfiguration();
092
093
094        /**
095         * Whether to use suffix pattern match (".*") when matching patterns to
096         * requests. If enabled a method mapped to "/users" also matches to "/users.*".
097         * <p>The default value is {@code true}.
098         * <p>Also see {@link #setUseRegisteredSuffixPatternMatch(boolean)} for
099         * more fine-grained control over specific suffixes to allow.
100         * @deprecated as of 5.2.4. See class level comment about deprecation of
101         * path extension config options. As there is no replacement for this method,
102         * for the time being it's necessary to set it to {@code false}. In 5.3
103         * when {@code false} becomes the default, use of this property will no
104         * longer be necessary.
105         */
106        @Deprecated
107        public void setUseSuffixPatternMatch(boolean useSuffixPatternMatch) {
108                this.useSuffixPatternMatch = useSuffixPatternMatch;
109        }
110
111        /**
112         * Whether suffix pattern matching should work only against path extensions
113         * explicitly registered with the {@link ContentNegotiationManager}. This
114         * is generally recommended to reduce ambiguity and to avoid issues such as
115         * when a "." appears in the path for other reasons.
116         * <p>By default this is set to "false".
117         * @deprecated as of 5.2.4. See class level comment about deprecation of
118         * path extension config options.
119         */
120        @Deprecated
121        public void setUseRegisteredSuffixPatternMatch(boolean useRegisteredSuffixPatternMatch) {
122                this.useRegisteredSuffixPatternMatch = useRegisteredSuffixPatternMatch;
123                this.useSuffixPatternMatch = (useRegisteredSuffixPatternMatch || this.useSuffixPatternMatch);
124        }
125
126        /**
127         * Whether to match to URLs irrespective of the presence of a trailing slash.
128         * If enabled a method mapped to "/users" also matches to "/users/".
129         * <p>The default value is {@code true}.
130         */
131        public void setUseTrailingSlashMatch(boolean useTrailingSlashMatch) {
132                this.useTrailingSlashMatch = useTrailingSlashMatch;
133        }
134
135        /**
136         * Configure path prefixes to apply to controller methods.
137         * <p>Prefixes are used to enrich the mappings of every {@code @RequestMapping}
138         * method whose controller type is matched by the corresponding
139         * {@code Predicate}. The prefix for the first matching predicate is used.
140         * <p>Consider using {@link org.springframework.web.method.HandlerTypePredicate
141         * HandlerTypePredicate} to group controllers.
142         * @param prefixes a map with path prefixes as key
143         * @since 5.1
144         */
145        public void setPathPrefixes(Map<String, Predicate<Class<?>>> prefixes) {
146                this.pathPrefixes = Collections.unmodifiableMap(new LinkedHashMap<>(prefixes));
147        }
148
149        /**
150         * The configured path prefixes as a read-only, possibly empty map.
151         * @since 5.1
152         */
153        public Map<String, Predicate<Class<?>>> getPathPrefixes() {
154                return this.pathPrefixes;
155        }
156
157        /**
158         * Set the {@link ContentNegotiationManager} to use to determine requested media types.
159         * If not set, the default constructor is used.
160         */
161        public void setContentNegotiationManager(ContentNegotiationManager contentNegotiationManager) {
162                Assert.notNull(contentNegotiationManager, "ContentNegotiationManager must not be null");
163                this.contentNegotiationManager = contentNegotiationManager;
164        }
165
166        /**
167         * Return the configured {@link ContentNegotiationManager}.
168         */
169        public ContentNegotiationManager getContentNegotiationManager() {
170                return this.contentNegotiationManager;
171        }
172
173        @Override
174        public void setEmbeddedValueResolver(StringValueResolver resolver) {
175                this.embeddedValueResolver = resolver;
176        }
177
178        @Override
179        @SuppressWarnings("deprecation")
180        public void afterPropertiesSet() {
181                this.config = new RequestMappingInfo.BuilderConfiguration();
182                this.config.setUrlPathHelper(getUrlPathHelper());
183                this.config.setPathMatcher(getPathMatcher());
184                this.config.setSuffixPatternMatch(useSuffixPatternMatch());
185                this.config.setTrailingSlashMatch(useTrailingSlashMatch());
186                this.config.setRegisteredSuffixPatternMatch(useRegisteredSuffixPatternMatch());
187                this.config.setContentNegotiationManager(getContentNegotiationManager());
188
189                super.afterPropertiesSet();
190        }
191
192
193        /**
194         * Whether to use registered suffixes for pattern matching.
195         * @deprecated as of 5.2.4. See class-level note on the deprecation of path
196         * extension config options.
197         */
198        @Deprecated
199        public boolean useSuffixPatternMatch() {
200                return this.useSuffixPatternMatch;
201        }
202
203        /**
204         * Whether to use registered suffixes for pattern matching.
205         * @deprecated as of 5.2.4. See class-level note on the deprecation of path
206         * extension config options.
207         */
208        @Deprecated
209        public boolean useRegisteredSuffixPatternMatch() {
210                return this.useRegisteredSuffixPatternMatch;
211        }
212
213        /**
214         * Whether to match to URLs irrespective of the presence of a trailing slash.
215         */
216        public boolean useTrailingSlashMatch() {
217                return this.useTrailingSlashMatch;
218        }
219
220        /**
221         * Return the file extensions to use for suffix pattern matching.
222         * @deprecated as of 5.2.4. See class-level note on the deprecation of path
223         * extension config options.
224         */
225        @Nullable
226        @Deprecated
227        @SuppressWarnings("deprecation")
228        public List<String> getFileExtensions() {
229                return this.config.getFileExtensions();
230        }
231
232
233        /**
234         * {@inheritDoc}
235         * <p>Expects a handler to have either a type-level @{@link Controller}
236         * annotation or a type-level @{@link RequestMapping} annotation.
237         */
238        @Override
239        protected boolean isHandler(Class<?> beanType) {
240                return (AnnotatedElementUtils.hasAnnotation(beanType, Controller.class) ||
241                                AnnotatedElementUtils.hasAnnotation(beanType, RequestMapping.class));
242        }
243
244        /**
245         * Uses method and type-level @{@link RequestMapping} annotations to create
246         * the RequestMappingInfo.
247         * @return the created RequestMappingInfo, or {@code null} if the method
248         * does not have a {@code @RequestMapping} annotation.
249         * @see #getCustomMethodCondition(Method)
250         * @see #getCustomTypeCondition(Class)
251         */
252        @Override
253        @Nullable
254        protected RequestMappingInfo getMappingForMethod(Method method, Class<?> handlerType) {
255                RequestMappingInfo info = createRequestMappingInfo(method);
256                if (info != null) {
257                        RequestMappingInfo typeInfo = createRequestMappingInfo(handlerType);
258                        if (typeInfo != null) {
259                                info = typeInfo.combine(info);
260                        }
261                        String prefix = getPathPrefix(handlerType);
262                        if (prefix != null) {
263                                info = RequestMappingInfo.paths(prefix).options(this.config).build().combine(info);
264                        }
265                }
266                return info;
267        }
268
269        @Nullable
270        String getPathPrefix(Class<?> handlerType) {
271                for (Map.Entry<String, Predicate<Class<?>>> entry : this.pathPrefixes.entrySet()) {
272                        if (entry.getValue().test(handlerType)) {
273                                String prefix = entry.getKey();
274                                if (this.embeddedValueResolver != null) {
275                                        prefix = this.embeddedValueResolver.resolveStringValue(prefix);
276                                }
277                                return prefix;
278                        }
279                }
280                return null;
281        }
282
283        /**
284         * Delegates to {@link #createRequestMappingInfo(RequestMapping, RequestCondition)},
285         * supplying the appropriate custom {@link RequestCondition} depending on whether
286         * the supplied {@code annotatedElement} is a class or method.
287         * @see #getCustomTypeCondition(Class)
288         * @see #getCustomMethodCondition(Method)
289         */
290        @Nullable
291        private RequestMappingInfo createRequestMappingInfo(AnnotatedElement element) {
292                RequestMapping requestMapping = AnnotatedElementUtils.findMergedAnnotation(element, RequestMapping.class);
293                RequestCondition<?> condition = (element instanceof Class ?
294                                getCustomTypeCondition((Class<?>) element) : getCustomMethodCondition((Method) element));
295                return (requestMapping != null ? createRequestMappingInfo(requestMapping, condition) : null);
296        }
297
298        /**
299         * Provide a custom type-level request condition.
300         * The custom {@link RequestCondition} can be of any type so long as the
301         * same condition type is returned from all calls to this method in order
302         * to ensure custom request conditions can be combined and compared.
303         * <p>Consider extending {@link AbstractRequestCondition} for custom
304         * condition types and using {@link CompositeRequestCondition} to provide
305         * multiple custom conditions.
306         * @param handlerType the handler type for which to create the condition
307         * @return the condition, or {@code null}
308         */
309        @Nullable
310        protected RequestCondition<?> getCustomTypeCondition(Class<?> handlerType) {
311                return null;
312        }
313
314        /**
315         * Provide a custom method-level request condition.
316         * The custom {@link RequestCondition} can be of any type so long as the
317         * same condition type is returned from all calls to this method in order
318         * to ensure custom request conditions can be combined and compared.
319         * <p>Consider extending {@link AbstractRequestCondition} for custom
320         * condition types and using {@link CompositeRequestCondition} to provide
321         * multiple custom conditions.
322         * @param method the handler method for which to create the condition
323         * @return the condition, or {@code null}
324         */
325        @Nullable
326        protected RequestCondition<?> getCustomMethodCondition(Method method) {
327                return null;
328        }
329
330        /**
331         * Create a {@link RequestMappingInfo} from the supplied
332         * {@link RequestMapping @RequestMapping} annotation, which is either
333         * a directly declared annotation, a meta-annotation, or the synthesized
334         * result of merging annotation attributes within an annotation hierarchy.
335         */
336        protected RequestMappingInfo createRequestMappingInfo(
337                        RequestMapping requestMapping, @Nullable RequestCondition<?> customCondition) {
338
339                RequestMappingInfo.Builder builder = RequestMappingInfo
340                                .paths(resolveEmbeddedValuesInPatterns(requestMapping.path()))
341                                .methods(requestMapping.method())
342                                .params(requestMapping.params())
343                                .headers(requestMapping.headers())
344                                .consumes(requestMapping.consumes())
345                                .produces(requestMapping.produces())
346                                .mappingName(requestMapping.name());
347                if (customCondition != null) {
348                        builder.customCondition(customCondition);
349                }
350                return builder.options(this.config).build();
351        }
352
353        /**
354         * Resolve placeholder values in the given array of patterns.
355         * @return a new array with updated patterns
356         */
357        protected String[] resolveEmbeddedValuesInPatterns(String[] patterns) {
358                if (this.embeddedValueResolver == null) {
359                        return patterns;
360                }
361                else {
362                        String[] resolvedPatterns = new String[patterns.length];
363                        for (int i = 0; i < patterns.length; i++) {
364                                resolvedPatterns[i] = this.embeddedValueResolver.resolveStringValue(patterns[i]);
365                        }
366                        return resolvedPatterns;
367                }
368        }
369
370        @Override
371        public void registerMapping(RequestMappingInfo mapping, Object handler, Method method) {
372                super.registerMapping(mapping, handler, method);
373                updateConsumesCondition(mapping, method);
374        }
375
376        @Override
377        protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {
378                super.registerHandlerMethod(handler, method, mapping);
379                updateConsumesCondition(mapping, method);
380        }
381
382        private void updateConsumesCondition(RequestMappingInfo info, Method method) {
383                ConsumesRequestCondition condition = info.getConsumesCondition();
384                if (!condition.isEmpty()) {
385                        for (Parameter parameter : method.getParameters()) {
386                                MergedAnnotation<RequestBody> annot = MergedAnnotations.from(parameter).get(RequestBody.class);
387                                if (annot.isPresent()) {
388                                        condition.setBodyRequired(annot.getBoolean("required"));
389                                        break;
390                                }
391                        }
392                }
393        }
394
395        @Override
396        public RequestMatchResult match(HttpServletRequest request, String pattern) {
397                RequestMappingInfo info = RequestMappingInfo.paths(pattern).options(this.config).build();
398                RequestMappingInfo matchingInfo = info.getMatchingCondition(request);
399                if (matchingInfo == null) {
400                        return null;
401                }
402                Set<String> patterns = matchingInfo.getPatternsCondition().getPatterns();
403                String lookupPath = getUrlPathHelper().getLookupPathForRequest(request, LOOKUP_PATH);
404                return new RequestMatchResult(patterns.iterator().next(), lookupPath, getPathMatcher());
405        }
406
407        @Override
408        protected CorsConfiguration initCorsConfiguration(Object handler, Method method, RequestMappingInfo mappingInfo) {
409                HandlerMethod handlerMethod = createHandlerMethod(handler, method);
410                Class<?> beanType = handlerMethod.getBeanType();
411                CrossOrigin typeAnnotation = AnnotatedElementUtils.findMergedAnnotation(beanType, CrossOrigin.class);
412                CrossOrigin methodAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, CrossOrigin.class);
413
414                if (typeAnnotation == null && methodAnnotation == null) {
415                        return null;
416                }
417
418                CorsConfiguration config = new CorsConfiguration();
419                updateCorsConfig(config, typeAnnotation);
420                updateCorsConfig(config, methodAnnotation);
421
422                if (CollectionUtils.isEmpty(config.getAllowedMethods())) {
423                        for (RequestMethod allowedMethod : mappingInfo.getMethodsCondition().getMethods()) {
424                                config.addAllowedMethod(allowedMethod.name());
425                        }
426                }
427                return config.applyPermitDefaultValues();
428        }
429
430        private void updateCorsConfig(CorsConfiguration config, @Nullable CrossOrigin annotation) {
431                if (annotation == null) {
432                        return;
433                }
434                for (String origin : annotation.origins()) {
435                        config.addAllowedOrigin(resolveCorsAnnotationValue(origin));
436                }
437                for (RequestMethod method : annotation.methods()) {
438                        config.addAllowedMethod(method.name());
439                }
440                for (String header : annotation.allowedHeaders()) {
441                        config.addAllowedHeader(resolveCorsAnnotationValue(header));
442                }
443                for (String header : annotation.exposedHeaders()) {
444                        config.addExposedHeader(resolveCorsAnnotationValue(header));
445                }
446
447                String allowCredentials = resolveCorsAnnotationValue(annotation.allowCredentials());
448                if ("true".equalsIgnoreCase(allowCredentials)) {
449                        config.setAllowCredentials(true);
450                }
451                else if ("false".equalsIgnoreCase(allowCredentials)) {
452                        config.setAllowCredentials(false);
453                }
454                else if (!allowCredentials.isEmpty()) {
455                        throw new IllegalStateException("@CrossOrigin's allowCredentials value must be \"true\", \"false\", " +
456                                        "or an empty string (\"\"): current value is [" + allowCredentials + "]");
457                }
458
459                if (annotation.maxAge() >= 0 && config.getMaxAge() == null) {
460                        config.setMaxAge(annotation.maxAge());
461                }
462        }
463
464        private String resolveCorsAnnotationValue(String value) {
465                if (this.embeddedValueResolver != null) {
466                        String resolved = this.embeddedValueResolver.resolveStringValue(value);
467                        return (resolved != null ? resolved : "");
468                }
469                else {
470                        return value;
471                }
472        }
473
474}