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