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}