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}