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.condition;
018
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.LinkedHashSet;
023import java.util.List;
024import java.util.Set;
025
026import org.springframework.http.HttpHeaders;
027import org.springframework.http.InvalidMediaTypeException;
028import org.springframework.http.MediaType;
029import org.springframework.http.server.reactive.ServerHttpRequest;
030import org.springframework.lang.Nullable;
031import org.springframework.util.CollectionUtils;
032import org.springframework.util.ObjectUtils;
033import org.springframework.util.StringUtils;
034import org.springframework.web.bind.annotation.RequestMapping;
035import org.springframework.web.cors.reactive.CorsUtils;
036import org.springframework.web.server.ServerWebExchange;
037import org.springframework.web.server.UnsupportedMediaTypeStatusException;
038
039/**
040 * A logical disjunction (' || ') request condition to match a request's
041 * 'Content-Type' header to a list of media type expressions. Two kinds of
042 * media type expressions are supported, which are described in
043 * {@link RequestMapping#consumes()} and {@link RequestMapping#headers()}
044 * where the header name is 'Content-Type'. Regardless of which syntax is
045 * used, the semantics are the same.
046 *
047 * @author Rossen Stoyanchev
048 * @since 5.0
049 */
050public final class ConsumesRequestCondition extends AbstractRequestCondition<ConsumesRequestCondition> {
051
052        private static final ConsumesRequestCondition EMPTY_CONDITION = new ConsumesRequestCondition();
053
054
055        private final List<ConsumeMediaTypeExpression> expressions;
056
057        private boolean bodyRequired = true;
058
059
060        /**
061         * Creates a new instance from 0 or more "consumes" expressions.
062         * @param consumes expressions with the syntax described in
063         * {@link RequestMapping#consumes()}; if 0 expressions are provided,
064         * the condition will match to every request
065         */
066        public ConsumesRequestCondition(String... consumes) {
067                this(consumes, null);
068        }
069
070        /**
071         * Creates a new instance with "consumes" and "header" expressions.
072         * "Header" expressions where the header name is not 'Content-Type' or have
073         * no header value defined are ignored. If 0 expressions are provided in
074         * total, the condition will match to every request
075         * @param consumes as described in {@link RequestMapping#consumes()}
076         * @param headers as described in {@link RequestMapping#headers()}
077         */
078        public ConsumesRequestCondition(String[] consumes, String[] headers) {
079                this.expressions = parseExpressions(consumes, headers);
080                if (this.expressions.size() > 1) {
081                        Collections.sort(this.expressions);
082                }
083        }
084
085        private static List<ConsumeMediaTypeExpression> parseExpressions(String[] consumes, String[] headers) {
086                Set<ConsumeMediaTypeExpression> result = null;
087                if (!ObjectUtils.isEmpty(headers)) {
088                        for (String header : headers) {
089                                HeadersRequestCondition.HeaderExpression expr = new HeadersRequestCondition.HeaderExpression(header);
090                                if ("Content-Type".equalsIgnoreCase(expr.name)) {
091                                        result = (result != null ? result : new LinkedHashSet<>());
092                                        for (MediaType mediaType : MediaType.parseMediaTypes(expr.value)) {
093                                                result.add(new ConsumeMediaTypeExpression(mediaType, expr.isNegated));
094                                        }
095                                }
096                        }
097                }
098                if (!ObjectUtils.isEmpty(consumes)) {
099                        result = (result != null ? result : new LinkedHashSet<>());
100                        for (String consume : consumes) {
101                                result.add(new ConsumeMediaTypeExpression(consume));
102                        }
103                }
104                return (result != null ? new ArrayList<>(result) : Collections.emptyList());
105        }
106
107        /**
108         * Private constructor for internal when creating matching conditions.
109         */
110        private ConsumesRequestCondition(List<ConsumeMediaTypeExpression> expressions) {
111                this.expressions = expressions;
112        }
113
114
115        /**
116         * Return the contained MediaType expressions.
117         */
118        public Set<MediaTypeExpression> getExpressions() {
119                return new LinkedHashSet<>(this.expressions);
120        }
121
122        /**
123         * Returns the media types for this condition excluding negated expressions.
124         */
125        public Set<MediaType> getConsumableMediaTypes() {
126                Set<MediaType> result = new LinkedHashSet<>();
127                for (ConsumeMediaTypeExpression expression : this.expressions) {
128                        if (!expression.isNegated()) {
129                                result.add(expression.getMediaType());
130                        }
131                }
132                return result;
133        }
134
135        /**
136         * Whether the condition has any media type expressions.
137         */
138        @Override
139        public boolean isEmpty() {
140                return this.expressions.isEmpty();
141        }
142
143        @Override
144        protected Collection<ConsumeMediaTypeExpression> getContent() {
145                return this.expressions;
146        }
147
148        @Override
149        protected String getToStringInfix() {
150                return " || ";
151        }
152
153        /**
154         * Whether this condition should expect requests to have a body.
155         * <p>By default this is set to {@code true} in which case it is assumed a
156         * request body is required and this condition matches to the "Content-Type"
157         * header or falls back on "Content-Type: application/octet-stream".
158         * <p>If set to {@code false}, and the request does not have a body, then this
159         * condition matches automatically, i.e. without checking expressions.
160         * @param bodyRequired whether requests are expected to have a body
161         * @since 5.2
162         */
163        public void setBodyRequired(boolean bodyRequired) {
164                this.bodyRequired = bodyRequired;
165        }
166
167        /**
168         * Return the setting for {@link #setBodyRequired(boolean)}.
169         * @since 5.2
170         */
171        public boolean isBodyRequired() {
172                return this.bodyRequired;
173        }
174
175
176        /**
177         * Returns the "other" instance if it has any expressions; returns "this"
178         * instance otherwise. Practically that means a method-level "consumes"
179         * overrides a type-level "consumes" condition.
180         */
181        @Override
182        public ConsumesRequestCondition combine(ConsumesRequestCondition other) {
183                return (!other.expressions.isEmpty() ? other : this);
184        }
185
186        /**
187         * Checks if any of the contained media type expressions match the given
188         * request 'Content-Type' header and returns an instance that is guaranteed
189         * to contain matching expressions only. The match is performed via
190         * {@link MediaType#includes(MediaType)}.
191         * @param exchange the current exchange
192         * @return the same instance if the condition contains no expressions;
193         * or a new condition with matching expressions only;
194         * or {@code null} if no expressions match.
195         */
196        @Override
197        public ConsumesRequestCondition getMatchingCondition(ServerWebExchange exchange) {
198                ServerHttpRequest request = exchange.getRequest();
199                if (CorsUtils.isPreFlightRequest(request)) {
200                        return EMPTY_CONDITION;
201                }
202                if (isEmpty()) {
203                        return this;
204                }
205                if (!hasBody(request) && !this.bodyRequired) {
206                        return EMPTY_CONDITION;
207                }
208                List<ConsumeMediaTypeExpression> result = getMatchingExpressions(exchange);
209                return !CollectionUtils.isEmpty(result) ? new ConsumesRequestCondition(result) : null;
210        }
211
212        private boolean hasBody(ServerHttpRequest request) {
213                String contentLength = request.getHeaders().getFirst(HttpHeaders.CONTENT_LENGTH);
214                String transferEncoding = request.getHeaders().getFirst(HttpHeaders.TRANSFER_ENCODING);
215                return StringUtils.hasText(transferEncoding) ||
216                                (StringUtils.hasText(contentLength) && !contentLength.trim().equals("0"));
217        }
218
219        @Nullable
220        private List<ConsumeMediaTypeExpression> getMatchingExpressions(ServerWebExchange exchange) {
221                List<ConsumeMediaTypeExpression> result = null;
222                for (ConsumeMediaTypeExpression expression : this.expressions) {
223                        if (expression.match(exchange)) {
224                                result = result != null ? result : new ArrayList<>();
225                                result.add(expression);
226                        }
227                }
228                return result;
229        }
230
231        /**
232         * Returns:
233         * <ul>
234         * <li>0 if the two conditions have the same number of expressions
235         * <li>Less than 0 if "this" has more or more specific media type expressions
236         * <li>Greater than 0 if "other" has more or more specific media type expressions
237         * </ul>
238         * <p>It is assumed that both instances have been obtained via
239         * {@link #getMatchingCondition(ServerWebExchange)} and each instance contains
240         * the matching consumable media type expression only or is otherwise empty.
241         */
242        @Override
243        public int compareTo(ConsumesRequestCondition other, ServerWebExchange exchange) {
244                if (this.expressions.isEmpty() && other.expressions.isEmpty()) {
245                        return 0;
246                }
247                else if (this.expressions.isEmpty()) {
248                        return 1;
249                }
250                else if (other.expressions.isEmpty()) {
251                        return -1;
252                }
253                else {
254                        return this.expressions.get(0).compareTo(other.expressions.get(0));
255                }
256        }
257
258
259        /**
260         * Parses and matches a single media type expression to a request's 'Content-Type' header.
261         */
262        static class ConsumeMediaTypeExpression extends AbstractMediaTypeExpression {
263
264                ConsumeMediaTypeExpression(String expression) {
265                        super(expression);
266                }
267
268                ConsumeMediaTypeExpression(MediaType mediaType, boolean negated) {
269                        super(mediaType, negated);
270                }
271
272                @Override
273                protected boolean matchMediaType(ServerWebExchange exchange) throws UnsupportedMediaTypeStatusException {
274                        try {
275                                MediaType contentType = exchange.getRequest().getHeaders().getContentType();
276                                contentType = (contentType != null ? contentType : MediaType.APPLICATION_OCTET_STREAM);
277                                return getMediaType().includes(contentType);
278                        }
279                        catch (InvalidMediaTypeException ex) {
280                                throw new UnsupportedMediaTypeStatusException("Can't parse Content-Type [" +
281                                                exchange.getRequest().getHeaders().getFirst("Content-Type") +
282                                                "]: " + ex.getMessage());
283                        }
284                }
285        }
286
287}