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}