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.Collections;
021import java.util.LinkedHashSet;
022import java.util.List;
023import java.util.Set;
024
025import javax.servlet.http.HttpServletRequest;
026
027import org.springframework.http.MediaType;
028import org.springframework.lang.Nullable;
029import org.springframework.util.CollectionUtils;
030import org.springframework.util.ObjectUtils;
031import org.springframework.util.StringUtils;
032import org.springframework.web.HttpMediaTypeException;
033import org.springframework.web.HttpMediaTypeNotAcceptableException;
034import org.springframework.web.accept.ContentNegotiationManager;
035import org.springframework.web.bind.annotation.RequestMapping;
036import org.springframework.web.context.request.ServletWebRequest;
037import org.springframework.web.cors.CorsUtils;
038import org.springframework.web.servlet.mvc.condition.HeadersRequestCondition.HeaderExpression;
039
040/**
041 * A logical disjunction (' || ') request condition to match a request's 'Accept' header
042 * to a list of media type expressions. Two kinds of media type expressions are
043 * supported, which are described in {@link RequestMapping#produces()} and
044 * {@link RequestMapping#headers()} where the header name is 'Accept'.
045 * Regardless of which syntax is used, the semantics are the same.
046 *
047 * @author Arjen Poutsma
048 * @author Rossen Stoyanchev
049 * @since 3.1
050 */
051public final class ProducesRequestCondition extends AbstractRequestCondition<ProducesRequestCondition> {
052
053        private static final ContentNegotiationManager DEFAULT_CONTENT_NEGOTIATION_MANAGER =
054                        new ContentNegotiationManager();
055
056        private static final ProducesRequestCondition EMPTY_CONDITION = new ProducesRequestCondition();
057
058        private static final List<ProduceMediaTypeExpression> MEDIA_TYPE_ALL_LIST =
059                        Collections.singletonList(new ProduceMediaTypeExpression(MediaType.ALL_VALUE));
060
061        private static final String MEDIA_TYPES_ATTRIBUTE = ProducesRequestCondition.class.getName() + ".MEDIA_TYPES";
062
063
064        private final List<ProduceMediaTypeExpression> expressions;
065
066        private final ContentNegotiationManager contentNegotiationManager;
067
068
069        /**
070         * Creates a new instance from "produces" expressions. If 0 expressions
071         * are provided in total, this condition will match to any request.
072         * @param produces expressions with syntax defined by {@link RequestMapping#produces()}
073         */
074        public ProducesRequestCondition(String... produces) {
075                this(produces, null, null);
076        }
077
078        /**
079         * Creates a new instance with "produces" and "header" expressions. "Header"
080         * expressions where the header name is not 'Accept' or have no header value
081         * defined are ignored. If 0 expressions are provided in total, this condition
082         * will match to any request.
083         * @param produces expressions with syntax defined by {@link RequestMapping#produces()}
084         * @param headers expressions with syntax defined by {@link RequestMapping#headers()}
085         */
086        public ProducesRequestCondition(String[] produces, @Nullable String[] headers) {
087                this(produces, headers, null);
088        }
089
090        /**
091         * Same as {@link #ProducesRequestCondition(String[], String[])} but also
092         * accepting a {@link ContentNegotiationManager}.
093         * @param produces expressions with syntax defined by {@link RequestMapping#produces()}
094         * @param headers expressions with syntax defined by {@link RequestMapping#headers()}
095         * @param manager used to determine requested media types
096         */
097        public ProducesRequestCondition(String[] produces, @Nullable String[] headers,
098                        @Nullable ContentNegotiationManager manager) {
099
100                this.expressions = parseExpressions(produces, headers);
101                if (this.expressions.size() > 1) {
102                        Collections.sort(this.expressions);
103                }
104                this.contentNegotiationManager = manager != null ? manager : DEFAULT_CONTENT_NEGOTIATION_MANAGER;
105        }
106
107        private List<ProduceMediaTypeExpression> parseExpressions(String[] produces, @Nullable String[] headers) {
108                Set<ProduceMediaTypeExpression> result = null;
109                if (!ObjectUtils.isEmpty(headers)) {
110                        for (String header : headers) {
111                                HeaderExpression expr = new HeaderExpression(header);
112                                if ("Accept".equalsIgnoreCase(expr.name) && expr.value != null) {
113                                        for (MediaType mediaType : MediaType.parseMediaTypes(expr.value)) {
114                                                result = (result != null ? result : new LinkedHashSet<>());
115                                                result.add(new ProduceMediaTypeExpression(mediaType, expr.isNegated));
116                                        }
117                                }
118                        }
119                }
120                if (!ObjectUtils.isEmpty(produces)) {
121                        for (String produce : produces) {
122                                result = (result != null ? result : new LinkedHashSet<>());
123                                result.add(new ProduceMediaTypeExpression(produce));
124                        }
125                }
126                return (result != null ? new ArrayList<>(result) : Collections.emptyList());
127        }
128
129        /**
130         * Private constructor for internal use to create matching conditions.
131         * Note the expressions List is neither sorted nor deep copied.
132         */
133        private ProducesRequestCondition(List<ProduceMediaTypeExpression> expressions, ProducesRequestCondition other) {
134                this.expressions = expressions;
135                this.contentNegotiationManager = other.contentNegotiationManager;
136        }
137
138
139        /**
140         * Return the contained "produces" expressions.
141         */
142        public Set<MediaTypeExpression> getExpressions() {
143                return new LinkedHashSet<>(this.expressions);
144        }
145
146        /**
147         * Return the contained producible media types excluding negated expressions.
148         */
149        public Set<MediaType> getProducibleMediaTypes() {
150                Set<MediaType> result = new LinkedHashSet<>();
151                for (ProduceMediaTypeExpression expression : this.expressions) {
152                        if (!expression.isNegated()) {
153                                result.add(expression.getMediaType());
154                        }
155                }
156                return result;
157        }
158
159        /**
160         * Whether the condition has any media type expressions.
161         */
162        @Override
163        public boolean isEmpty() {
164                return this.expressions.isEmpty();
165        }
166
167        @Override
168        protected List<ProduceMediaTypeExpression> getContent() {
169                return this.expressions;
170        }
171
172        @Override
173        protected String getToStringInfix() {
174                return " || ";
175        }
176
177        /**
178         * Returns the "other" instance if it has any expressions; returns "this"
179         * instance otherwise. Practically that means a method-level "produces"
180         * overrides a type-level "produces" condition.
181         */
182        @Override
183        public ProducesRequestCondition combine(ProducesRequestCondition other) {
184                return (!other.expressions.isEmpty() ? other : this);
185        }
186
187        /**
188         * Checks if any of the contained media type expressions match the given
189         * request 'Content-Type' header and returns an instance that is guaranteed
190         * to contain matching expressions only. The match is performed via
191         * {@link MediaType#isCompatibleWith(MediaType)}.
192         * @param request the current request
193         * @return the same instance if there are no expressions;
194         * or a new condition with matching expressions;
195         * or {@code null} if no expressions match.
196         */
197        @Override
198        @Nullable
199        public ProducesRequestCondition getMatchingCondition(HttpServletRequest request) {
200                if (CorsUtils.isPreFlightRequest(request)) {
201                        return EMPTY_CONDITION;
202                }
203                if (isEmpty()) {
204                        return this;
205                }
206                List<MediaType> acceptedMediaTypes;
207                try {
208                        acceptedMediaTypes = getAcceptedMediaTypes(request);
209                }
210                catch (HttpMediaTypeException ex) {
211                        return null;
212                }
213                List<ProduceMediaTypeExpression> result = getMatchingExpressions(acceptedMediaTypes);
214                if (!CollectionUtils.isEmpty(result)) {
215                        return new ProducesRequestCondition(result, this);
216                }
217                else if (MediaType.ALL.isPresentIn(acceptedMediaTypes)) {
218                        return EMPTY_CONDITION;
219                }
220                else {
221                        return null;
222                }
223        }
224
225        @Nullable
226        private List<ProduceMediaTypeExpression> getMatchingExpressions(List<MediaType> acceptedMediaTypes) {
227                List<ProduceMediaTypeExpression> result = null;
228                for (ProduceMediaTypeExpression expression : this.expressions) {
229                        if (expression.match(acceptedMediaTypes)) {
230                                result = result != null ? result : new ArrayList<>();
231                                result.add(expression);
232                        }
233                }
234                return result;
235        }
236
237        /**
238         * Compares this and another "produces" condition as follows:
239         * <ol>
240         * <li>Sort 'Accept' header media types by quality value via
241         * {@link MediaType#sortByQualityValue(List)} and iterate the list.
242         * <li>Get the first index of matching media types in each "produces"
243         * condition first matching with {@link MediaType#equals(Object)} and
244         * then with {@link MediaType#includes(MediaType)}.
245         * <li>If a lower index is found, the condition at that index wins.
246         * <li>If both indexes are equal, the media types at the index are
247         * compared further with {@link MediaType#SPECIFICITY_COMPARATOR}.
248         * </ol>
249         * <p>It is assumed that both instances have been obtained via
250         * {@link #getMatchingCondition(HttpServletRequest)} and each instance
251         * contains the matching producible media type expression only or
252         * is otherwise empty.
253         */
254        @Override
255        public int compareTo(ProducesRequestCondition other, HttpServletRequest request) {
256                try {
257                        List<MediaType> acceptedMediaTypes = getAcceptedMediaTypes(request);
258                        for (MediaType acceptedMediaType : acceptedMediaTypes) {
259                                int thisIndex = this.indexOfEqualMediaType(acceptedMediaType);
260                                int otherIndex = other.indexOfEqualMediaType(acceptedMediaType);
261                                int result = compareMatchingMediaTypes(this, thisIndex, other, otherIndex);
262                                if (result != 0) {
263                                        return result;
264                                }
265                                thisIndex = this.indexOfIncludedMediaType(acceptedMediaType);
266                                otherIndex = other.indexOfIncludedMediaType(acceptedMediaType);
267                                result = compareMatchingMediaTypes(this, thisIndex, other, otherIndex);
268                                if (result != 0) {
269                                        return result;
270                                }
271                        }
272                        return 0;
273                }
274                catch (HttpMediaTypeNotAcceptableException ex) {
275                        // should never happen
276                        throw new IllegalStateException("Cannot compare without having any requested media types", ex);
277                }
278        }
279
280        @SuppressWarnings("unchecked")
281        private List<MediaType> getAcceptedMediaTypes(HttpServletRequest request)
282                        throws HttpMediaTypeNotAcceptableException {
283
284                List<MediaType> result = (List<MediaType>) request.getAttribute(MEDIA_TYPES_ATTRIBUTE);
285                if (result == null) {
286                        result = this.contentNegotiationManager.resolveMediaTypes(new ServletWebRequest(request));
287                        request.setAttribute(MEDIA_TYPES_ATTRIBUTE, result);
288                }
289                return result;
290        }
291
292        private int indexOfEqualMediaType(MediaType mediaType) {
293                for (int i = 0; i < getExpressionsToCompare().size(); i++) {
294                        MediaType currentMediaType = getExpressionsToCompare().get(i).getMediaType();
295                        if (mediaType.getType().equalsIgnoreCase(currentMediaType.getType()) &&
296                                        mediaType.getSubtype().equalsIgnoreCase(currentMediaType.getSubtype())) {
297                                return i;
298                        }
299                }
300                return -1;
301        }
302
303        private int indexOfIncludedMediaType(MediaType mediaType) {
304                for (int i = 0; i < getExpressionsToCompare().size(); i++) {
305                        if (mediaType.includes(getExpressionsToCompare().get(i).getMediaType())) {
306                                return i;
307                        }
308                }
309                return -1;
310        }
311
312        private int compareMatchingMediaTypes(ProducesRequestCondition condition1, int index1,
313                        ProducesRequestCondition condition2, int index2) {
314
315                int result = 0;
316                if (index1 != index2) {
317                        result = index2 - index1;
318                }
319                else if (index1 != -1) {
320                        ProduceMediaTypeExpression expr1 = condition1.getExpressionsToCompare().get(index1);
321                        ProduceMediaTypeExpression expr2 = condition2.getExpressionsToCompare().get(index2);
322                        result = expr1.compareTo(expr2);
323                        result = (result != 0) ? result : expr1.getMediaType().compareTo(expr2.getMediaType());
324                }
325                return result;
326        }
327
328        /**
329         * Return the contained "produces" expressions or if that's empty, a list
330         * with a {@value MediaType#ALL_VALUE} expression.
331         */
332        private List<ProduceMediaTypeExpression> getExpressionsToCompare() {
333                return (this.expressions.isEmpty() ? MEDIA_TYPE_ALL_LIST : this.expressions);
334        }
335
336
337        /**
338         * Use this to clear {@link #MEDIA_TYPES_ATTRIBUTE} that contains the parsed,
339         * requested media types.
340         * @param request the current request
341         * @since 5.2
342         */
343        public static void clearMediaTypesAttribute(HttpServletRequest request) {
344                request.removeAttribute(MEDIA_TYPES_ATTRIBUTE);
345        }
346
347
348        /**
349         * Parses and matches a single media type expression to a request's 'Accept' header.
350         */
351        static class ProduceMediaTypeExpression extends AbstractMediaTypeExpression {
352
353                ProduceMediaTypeExpression(MediaType mediaType, boolean negated) {
354                        super(mediaType, negated);
355                }
356
357                ProduceMediaTypeExpression(String expression) {
358                        super(expression);
359                }
360
361                public final boolean match(List<MediaType> acceptedMediaTypes) {
362                        boolean match = matchMediaType(acceptedMediaTypes);
363                        return !isNegated() == match;
364                }
365
366                private boolean matchMediaType(List<MediaType> acceptedMediaTypes) {
367                        for (MediaType acceptedMediaType : acceptedMediaTypes) {
368                                if (getMediaType().isCompatibleWith(acceptedMediaType) && matchParameters(acceptedMediaType)) {
369                                        return true;
370                                }
371                        }
372                        return false;
373                }
374
375                private boolean matchParameters(MediaType acceptedMediaType) {
376                        for (String name : getMediaType().getParameters().keySet()) {
377                                String s1 = getMediaType().getParameter(name);
378                                String s2 = acceptedMediaType.getParameter(name);
379                                if (StringUtils.hasText(s1) && StringUtils.hasText(s2) && !s1.equalsIgnoreCase(s2)) {
380                                        return false;
381                                }
382                        }
383                        return true;
384                }
385        }
386
387}