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;
018
019import java.lang.reflect.Method;
020import java.util.ArrayList;
021import java.util.Collections;
022import java.util.Comparator;
023import java.util.LinkedHashMap;
024import java.util.LinkedHashSet;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028
029import javax.servlet.ServletException;
030import javax.servlet.http.HttpServletRequest;
031
032import org.springframework.http.HttpHeaders;
033import org.springframework.http.HttpMethod;
034import org.springframework.http.InvalidMediaTypeException;
035import org.springframework.http.MediaType;
036import org.springframework.util.CollectionUtils;
037import org.springframework.util.MultiValueMap;
038import org.springframework.util.StringUtils;
039import org.springframework.web.HttpMediaTypeNotAcceptableException;
040import org.springframework.web.HttpMediaTypeNotSupportedException;
041import org.springframework.web.HttpRequestMethodNotSupportedException;
042import org.springframework.web.bind.UnsatisfiedServletRequestParameterException;
043import org.springframework.web.bind.annotation.RequestMethod;
044import org.springframework.web.method.HandlerMethod;
045import org.springframework.web.servlet.HandlerMapping;
046import org.springframework.web.servlet.handler.AbstractHandlerMethodMapping;
047import org.springframework.web.servlet.mvc.condition.NameValueExpression;
048import org.springframework.web.servlet.mvc.condition.ProducesRequestCondition;
049import org.springframework.web.util.WebUtils;
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 Arjen Poutsma
056 * @author Rossen Stoyanchev
057 * @since 3.1
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("Failed to retrieve internal handler method for HTTP OPTIONS", ex);
070                }
071        }
072
073
074        protected RequestMappingInfoHandlerMapping() {
075                setHandlerMethodMappingNamingStrategy(new RequestMappingInfoHandlerMethodMappingNamingStrategy());
076        }
077
078
079        /**
080         * Get the URL path patterns associated with the supplied {@link RequestMappingInfo}.
081         */
082        @Override
083        protected Set<String> getMappingPathPatterns(RequestMappingInfo info) {
084                return info.getPatternsCondition().getPatterns();
085        }
086
087        /**
088         * Check if the given RequestMappingInfo matches the current request and
089         * return a (potentially new) instance with conditions that match the
090         * current request -- for example with a subset of URL patterns.
091         * @return an info in case of a match; or {@code null} otherwise.
092         */
093        @Override
094        protected RequestMappingInfo getMatchingMapping(RequestMappingInfo info, HttpServletRequest request) {
095                return info.getMatchingCondition(request);
096        }
097
098        /**
099         * Provide a Comparator to sort RequestMappingInfos matched to a request.
100         */
101        @Override
102        protected Comparator<RequestMappingInfo> getMappingComparator(final HttpServletRequest request) {
103                return (info1, info2) -> info1.compareTo(info2, request);
104        }
105
106        @Override
107        protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
108                request.removeAttribute(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
109                try {
110                        return super.getHandlerInternal(request);
111                }
112                finally {
113                        ProducesRequestCondition.clearMediaTypesAttribute(request);
114                }
115        }
116
117        /**
118         * Expose URI template variables, matrix variables, and producible media types in the request.
119         * @see HandlerMapping#URI_TEMPLATE_VARIABLES_ATTRIBUTE
120         * @see HandlerMapping#MATRIX_VARIABLES_ATTRIBUTE
121         * @see HandlerMapping#PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE
122         */
123        @Override
124        protected void handleMatch(RequestMappingInfo info, String lookupPath, HttpServletRequest request) {
125                super.handleMatch(info, lookupPath, request);
126
127                String bestPattern;
128                Map<String, String> uriVariables;
129
130                Set<String> patterns = info.getPatternsCondition().getPatterns();
131                if (patterns.isEmpty()) {
132                        bestPattern = lookupPath;
133                        uriVariables = Collections.emptyMap();
134                }
135                else {
136                        bestPattern = patterns.iterator().next();
137                        uriVariables = getPathMatcher().extractUriTemplateVariables(bestPattern, lookupPath);
138                }
139
140                request.setAttribute(BEST_MATCHING_PATTERN_ATTRIBUTE, bestPattern);
141
142                if (isMatrixVariableContentAvailable()) {
143                        Map<String, MultiValueMap<String, String>> matrixVars = extractMatrixVariables(request, uriVariables);
144                        request.setAttribute(HandlerMapping.MATRIX_VARIABLES_ATTRIBUTE, matrixVars);
145                }
146
147                Map<String, String> decodedUriVariables = getUrlPathHelper().decodePathVariables(request, uriVariables);
148                request.setAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE, decodedUriVariables);
149
150                if (!info.getProducesCondition().getProducibleMediaTypes().isEmpty()) {
151                        Set<MediaType> mediaTypes = info.getProducesCondition().getProducibleMediaTypes();
152                        request.setAttribute(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, mediaTypes);
153                }
154        }
155
156        private boolean isMatrixVariableContentAvailable() {
157                return !getUrlPathHelper().shouldRemoveSemicolonContent();
158        }
159
160        private Map<String, MultiValueMap<String, String>> extractMatrixVariables(
161                        HttpServletRequest request, Map<String, String> uriVariables) {
162
163                Map<String, MultiValueMap<String, String>> result = new LinkedHashMap<>();
164                uriVariables.forEach((uriVarKey, uriVarValue) -> {
165
166                        int equalsIndex = uriVarValue.indexOf('=');
167                        if (equalsIndex == -1) {
168                                return;
169                        }
170
171                        int semicolonIndex = uriVarValue.indexOf(';');
172                        if (semicolonIndex != -1 && semicolonIndex != 0) {
173                                uriVariables.put(uriVarKey, uriVarValue.substring(0, semicolonIndex));
174                        }
175
176                        String matrixVariables;
177                        if (semicolonIndex == -1 || semicolonIndex == 0 || equalsIndex < semicolonIndex) {
178                                matrixVariables = uriVarValue;
179                        }
180                        else {
181                                matrixVariables = uriVarValue.substring(semicolonIndex + 1);
182                        }
183
184                        MultiValueMap<String, String> vars = WebUtils.parseMatrixVariables(matrixVariables);
185                        result.put(uriVarKey, getUrlPathHelper().decodeMatrixVariables(request, vars));
186                });
187                return result;
188        }
189
190        /**
191         * Iterate all RequestMappingInfo's once again, look if any match by URL at
192         * least and raise exceptions according to what doesn't match.
193         * @throws HttpRequestMethodNotSupportedException if there are matches by URL
194         * but not by HTTP method
195         * @throws HttpMediaTypeNotAcceptableException if there are matches by URL
196         * but not by consumable/producible media types
197         */
198        @Override
199        protected HandlerMethod handleNoMatch(
200                        Set<RequestMappingInfo> infos, String lookupPath, HttpServletRequest request) throws ServletException {
201
202                PartialMatchHelper helper = new PartialMatchHelper(infos, request);
203                if (helper.isEmpty()) {
204                        return null;
205                }
206
207                if (helper.hasMethodsMismatch()) {
208                        Set<String> methods = helper.getAllowedMethods();
209                        if (HttpMethod.OPTIONS.matches(request.getMethod())) {
210                                HttpOptionsHandler handler = new HttpOptionsHandler(methods);
211                                return new HandlerMethod(handler, HTTP_OPTIONS_HANDLE_METHOD);
212                        }
213                        throw new HttpRequestMethodNotSupportedException(request.getMethod(), methods);
214                }
215
216                if (helper.hasConsumesMismatch()) {
217                        Set<MediaType> mediaTypes = helper.getConsumableMediaTypes();
218                        MediaType contentType = null;
219                        if (StringUtils.hasLength(request.getContentType())) {
220                                try {
221                                        contentType = MediaType.parseMediaType(request.getContentType());
222                                }
223                                catch (InvalidMediaTypeException ex) {
224                                        throw new HttpMediaTypeNotSupportedException(ex.getMessage());
225                                }
226                        }
227                        throw new HttpMediaTypeNotSupportedException(contentType, new ArrayList<>(mediaTypes));
228                }
229
230                if (helper.hasProducesMismatch()) {
231                        Set<MediaType> mediaTypes = helper.getProducibleMediaTypes();
232                        throw new HttpMediaTypeNotAcceptableException(new ArrayList<>(mediaTypes));
233                }
234
235                if (helper.hasParamsMismatch()) {
236                        List<String[]> conditions = helper.getParamConditions();
237                        throw new UnsatisfiedServletRequestParameterException(conditions, request.getParameterMap());
238                }
239
240                return null;
241        }
242
243
244        /**
245         * Aggregate all partial matches and expose methods checking across them.
246         */
247        private static class PartialMatchHelper {
248
249                private final List<PartialMatch> partialMatches = new ArrayList<>();
250
251                public PartialMatchHelper(Set<RequestMappingInfo> infos, HttpServletRequest request) {
252                        for (RequestMappingInfo info : infos) {
253                                if (info.getPatternsCondition().getMatchingCondition(request) != null) {
254                                        this.partialMatches.add(new PartialMatch(info, request));
255                                }
256                        }
257                }
258
259                /**
260                 * Whether there any partial matches.
261                 */
262                public boolean isEmpty() {
263                        return this.partialMatches.isEmpty();
264                }
265
266                /**
267                 * Any partial matches for "methods"?
268                 */
269                public boolean hasMethodsMismatch() {
270                        for (PartialMatch match : this.partialMatches) {
271                                if (match.hasMethodsMatch()) {
272                                        return false;
273                                }
274                        }
275                        return true;
276                }
277
278                /**
279                 * Any partial matches for "methods" and "consumes"?
280                 */
281                public boolean hasConsumesMismatch() {
282                        for (PartialMatch match : this.partialMatches) {
283                                if (match.hasConsumesMatch()) {
284                                        return false;
285                                }
286                        }
287                        return true;
288                }
289
290                /**
291                 * Any partial matches for "methods", "consumes", and "produces"?
292                 */
293                public boolean hasProducesMismatch() {
294                        for (PartialMatch match : this.partialMatches) {
295                                if (match.hasProducesMatch()) {
296                                        return false;
297                                }
298                        }
299                        return true;
300                }
301
302                /**
303                 * Any partial matches for "methods", "consumes", "produces", and "params"?
304                 */
305                public boolean hasParamsMismatch() {
306                        for (PartialMatch match : this.partialMatches) {
307                                if (match.hasParamsMatch()) {
308                                        return false;
309                                }
310                        }
311                        return true;
312                }
313
314                /**
315                 * Return declared HTTP methods.
316                 */
317                public Set<String> getAllowedMethods() {
318                        Set<String> result = new LinkedHashSet<>();
319                        for (PartialMatch match : this.partialMatches) {
320                                for (RequestMethod method : match.getInfo().getMethodsCondition().getMethods()) {
321                                        result.add(method.name());
322                                }
323                        }
324                        return result;
325                }
326
327                /**
328                 * Return declared "consumable" types but only among those that also
329                 * match the "methods" condition.
330                 */
331                public Set<MediaType> getConsumableMediaTypes() {
332                        Set<MediaType> result = new LinkedHashSet<>();
333                        for (PartialMatch match : this.partialMatches) {
334                                if (match.hasMethodsMatch()) {
335                                        result.addAll(match.getInfo().getConsumesCondition().getConsumableMediaTypes());
336                                }
337                        }
338                        return result;
339                }
340
341                /**
342                 * Return declared "producible" types but only among those that also
343                 * match the "methods" and "consumes" conditions.
344                 */
345                public Set<MediaType> getProducibleMediaTypes() {
346                        Set<MediaType> result = new LinkedHashSet<>();
347                        for (PartialMatch match : this.partialMatches) {
348                                if (match.hasConsumesMatch()) {
349                                        result.addAll(match.getInfo().getProducesCondition().getProducibleMediaTypes());
350                                }
351                        }
352                        return result;
353                }
354
355                /**
356                 * Return declared "params" conditions but only among those that also
357                 * match the "methods", "consumes", and "params" conditions.
358                 */
359                public List<String[]> getParamConditions() {
360                        List<String[]> result = new ArrayList<>();
361                        for (PartialMatch match : this.partialMatches) {
362                                if (match.hasProducesMatch()) {
363                                        Set<NameValueExpression<String>> set = match.getInfo().getParamsCondition().getExpressions();
364                                        if (!CollectionUtils.isEmpty(set)) {
365                                                int i = 0;
366                                                String[] array = new String[set.size()];
367                                                for (NameValueExpression<String> expression : set) {
368                                                        array[i++] = expression.toString();
369                                                }
370                                                result.add(array);
371                                        }
372                                }
373                        }
374                        return result;
375                }
376
377
378                /**
379                 * Container for a RequestMappingInfo that matches the URL path at least.
380                 */
381                private static class PartialMatch {
382
383                        private final RequestMappingInfo info;
384
385                        private final boolean methodsMatch;
386
387                        private final boolean consumesMatch;
388
389                        private final boolean producesMatch;
390
391                        private final boolean paramsMatch;
392
393                        /**
394                         * Create a new {@link PartialMatch} instance.
395                         * @param info the RequestMappingInfo that matches the URL path.
396                         * @param request the current request
397                         */
398                        public PartialMatch(RequestMappingInfo info, HttpServletRequest request) {
399                                this.info = info;
400                                this.methodsMatch = (info.getMethodsCondition().getMatchingCondition(request) != null);
401                                this.consumesMatch = (info.getConsumesCondition().getMatchingCondition(request) != null);
402                                this.producesMatch = (info.getProducesCondition().getMatchingCondition(request) != null);
403                                this.paramsMatch = (info.getParamsCondition().getMatchingCondition(request) != null);
404                        }
405
406                        public RequestMappingInfo getInfo() {
407                                return this.info;
408                        }
409
410                        public boolean hasMethodsMatch() {
411                                return this.methodsMatch;
412                        }
413
414                        public boolean hasConsumesMatch() {
415                                return (hasMethodsMatch() && this.consumesMatch);
416                        }
417
418                        public boolean hasProducesMatch() {
419                                return (hasConsumesMatch() && this.producesMatch);
420                        }
421
422                        public boolean hasParamsMatch() {
423                                return (hasProducesMatch() && this.paramsMatch);
424                        }
425
426                        @Override
427                        public String toString() {
428                                return this.info.toString();
429                        }
430                }
431        }
432
433
434        /**
435         * Default handler for HTTP OPTIONS.
436         */
437        private static class HttpOptionsHandler {
438
439                private final HttpHeaders headers = new HttpHeaders();
440
441                public HttpOptionsHandler(Set<String> declaredMethods) {
442                        this.headers.setAllow(initAllowedHttpMethods(declaredMethods));
443                }
444
445                private static Set<HttpMethod> initAllowedHttpMethods(Set<String> declaredMethods) {
446                        Set<HttpMethod> result = new LinkedHashSet<>(declaredMethods.size());
447                        if (declaredMethods.isEmpty()) {
448                                for (HttpMethod method : HttpMethod.values()) {
449                                        if (method != HttpMethod.TRACE) {
450                                                result.add(method);
451                                        }
452                                }
453                        }
454                        else {
455                                for (String method : declaredMethods) {
456                                        HttpMethod httpMethod = HttpMethod.valueOf(method);
457                                        result.add(httpMethod);
458                                        if (httpMethod == HttpMethod.GET) {
459                                                result.add(HttpMethod.HEAD);
460                                        }
461                                }
462                                result.add(HttpMethod.OPTIONS);
463                        }
464                        return result;
465                }
466
467                @SuppressWarnings("unused")
468                public HttpHeaders handle() {
469                        return this.headers;
470                }
471        }
472
473}