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