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}