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