Skip to content

Commit 45147c2

Browse files
committed
Empty body checks in ConsumesRequestCondition
Normally consumes matches the "Content-Type" header but what should be done if there is no content? This commit adds checks for method parameters with @RequestBody(required=false) and if "false" then also match requests with no content. Closes gh-22010
1 parent cdf51c3 commit 45147c2

File tree

8 files changed

+239
-21
lines changed

8 files changed

+239
-21
lines changed

spring-webflux/src/main/java/org/springframework/web/reactive/result/condition/ConsumesRequestCondition.java

+40-1
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,13 @@
2323
import java.util.List;
2424
import java.util.Set;
2525

26+
import org.springframework.http.HttpHeaders;
2627
import org.springframework.http.InvalidMediaTypeException;
2728
import org.springframework.http.MediaType;
29+
import org.springframework.http.server.reactive.ServerHttpRequest;
2830
import org.springframework.lang.Nullable;
2931
import org.springframework.util.CollectionUtils;
32+
import org.springframework.util.StringUtils;
3033
import org.springframework.web.bind.annotation.RequestMapping;
3134
import org.springframework.web.cors.reactive.CorsUtils;
3235
import org.springframework.web.server.ServerWebExchange;
@@ -50,6 +53,8 @@ public final class ConsumesRequestCondition extends AbstractRequestCondition<Con
5053

5154
private final List<ConsumeMediaTypeExpression> expressions;
5255

56+
private boolean bodyRequired = true;
57+
5358

5459
/**
5560
* Creates a new instance from 0 or more "consumes" expressions.
@@ -141,6 +146,29 @@ protected String getToStringInfix() {
141146
return " || ";
142147
}
143148

149+
/**
150+
* Whether this condition should expect requests to have a body.
151+
* <p>By default this is set to {@code true} in which case it is assumed a
152+
* request body is required and this condition matches to the "Content-Type"
153+
* header or falls back on "Content-Type: application/octet-stream".
154+
* <p>If set to {@code false}, and the request does not have a body, then this
155+
* condition matches automatically, i.e. without checking expressions.
156+
* @param bodyRequired whether requests are expected to have a body
157+
* @since 5.2
158+
*/
159+
public void setBodyRequired(boolean bodyRequired) {
160+
this.bodyRequired = bodyRequired;
161+
}
162+
163+
/**
164+
* Return the setting for {@link #setBodyRequired(boolean)}.
165+
* @since 5.2
166+
*/
167+
public boolean isBodyRequired() {
168+
return this.bodyRequired;
169+
}
170+
171+
144172
/**
145173
* Returns the "other" instance if it has any expressions; returns "this"
146174
* instance otherwise. Practically that means a method-level "consumes"
@@ -163,16 +191,27 @@ public ConsumesRequestCondition combine(ConsumesRequestCondition other) {
163191
*/
164192
@Override
165193
public ConsumesRequestCondition getMatchingCondition(ServerWebExchange exchange) {
166-
if (CorsUtils.isPreFlightRequest(exchange.getRequest())) {
194+
ServerHttpRequest request = exchange.getRequest();
195+
if (CorsUtils.isPreFlightRequest(request)) {
167196
return EMPTY_CONDITION;
168197
}
169198
if (isEmpty()) {
170199
return this;
171200
}
201+
if (!hasBody(request) && !this.bodyRequired) {
202+
return EMPTY_CONDITION;
203+
}
172204
List<ConsumeMediaTypeExpression> result = getMatchingExpressions(exchange);
173205
return !CollectionUtils.isEmpty(result) ? new ConsumesRequestCondition(result) : null;
174206
}
175207

208+
private boolean hasBody(ServerHttpRequest request) {
209+
String contentLength = request.getHeaders().getFirst(HttpHeaders.CONTENT_LENGTH);
210+
String transferEncoding = request.getHeaders().getFirst(HttpHeaders.TRANSFER_ENCODING);
211+
return StringUtils.hasText(transferEncoding) ||
212+
(StringUtils.hasText(contentLength) && !contentLength.trim().equals("0"));
213+
}
214+
176215
@Nullable
177216
private List<ConsumeMediaTypeExpression> getMatchingExpressions(ServerWebExchange exchange) {
178217
List<ConsumeMediaTypeExpression> result = null;

spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java

+30
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,31 @@
1818

1919
import java.lang.reflect.AnnotatedElement;
2020
import java.lang.reflect.Method;
21+
import java.lang.reflect.Parameter;
2122
import java.util.Collections;
2223
import java.util.LinkedHashMap;
2324
import java.util.Map;
2425
import java.util.function.Predicate;
2526

2627
import org.springframework.context.EmbeddedValueResolverAware;
2728
import org.springframework.core.annotation.AnnotatedElementUtils;
29+
import org.springframework.core.annotation.MergedAnnotation;
30+
import org.springframework.core.annotation.MergedAnnotations;
2831
import org.springframework.lang.Nullable;
2932
import org.springframework.stereotype.Controller;
3033
import org.springframework.util.Assert;
3134
import org.springframework.util.CollectionUtils;
3235
import org.springframework.util.StringUtils;
3336
import org.springframework.util.StringValueResolver;
3437
import org.springframework.web.bind.annotation.CrossOrigin;
38+
import org.springframework.web.bind.annotation.RequestBody;
3539
import org.springframework.web.bind.annotation.RequestMapping;
3640
import org.springframework.web.bind.annotation.RequestMethod;
3741
import org.springframework.web.cors.CorsConfiguration;
3842
import org.springframework.web.method.HandlerMethod;
3943
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
4044
import org.springframework.web.reactive.accept.RequestedContentTypeResolverBuilder;
45+
import org.springframework.web.reactive.result.condition.ConsumesRequestCondition;
4146
import org.springframework.web.reactive.result.condition.RequestCondition;
4247
import org.springframework.web.reactive.result.method.RequestMappingInfo;
4348
import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping;
@@ -255,6 +260,31 @@ protected String[] resolveEmbeddedValuesInPatterns(String[] patterns) {
255260
}
256261
}
257262

263+
@Override
264+
public void registerMapping(RequestMappingInfo mapping, Object handler, Method method) {
265+
super.registerMapping(mapping, handler, method);
266+
updateConsumesCondition(mapping, method);
267+
}
268+
269+
@Override
270+
protected void registerHandlerMethod(Object handler, Method method, RequestMappingInfo mapping) {
271+
super.registerHandlerMethod(handler, method, mapping);
272+
updateConsumesCondition(mapping, method);
273+
}
274+
275+
private void updateConsumesCondition(RequestMappingInfo info, Method method) {
276+
ConsumesRequestCondition condition = info.getConsumesCondition();
277+
if (!condition.isEmpty()) {
278+
for (Parameter parameter : method.getParameters()) {
279+
MergedAnnotation<RequestBody> annot = MergedAnnotations.from(parameter).get(RequestBody.class);
280+
if (annot.isPresent()) {
281+
condition.setBodyRequired(annot.getBoolean("required"));
282+
break;
283+
}
284+
}
285+
}
286+
}
287+
258288
@Override
259289
protected CorsConfiguration initCorsConfiguration(Object handler, Method method, RequestMappingInfo mappingInfo) {
260290
HandlerMethod handlerMethod = createHandlerMethod(handler, method);

spring-webflux/src/test/java/org/springframework/web/reactive/result/condition/ConsumesRequestConditionTests.java

+19-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2012 the original author or authors.
2+
* Copyright 2002-2019 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -99,6 +99,24 @@ public void consumesParseErrorWithNegation() throws Exception {
9999
assertNull(condition.getMatchingCondition(exchange));
100100
}
101101

102+
@Test // gh-22010
103+
public void consumesNoContent() {
104+
ConsumesRequestCondition condition = new ConsumesRequestCondition("text/plain");
105+
condition.setBodyRequired(false);
106+
107+
MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
108+
assertNotNull(condition.getMatchingCondition(MockServerWebExchange.from(request)));
109+
110+
request = MockServerHttpRequest.get("/").header(HttpHeaders.CONTENT_LENGTH, "0").build();
111+
assertNotNull(condition.getMatchingCondition(MockServerWebExchange.from(request)));
112+
113+
request = MockServerHttpRequest.get("/").header(HttpHeaders.CONTENT_LENGTH, "21").build();
114+
assertNull(condition.getMatchingCondition(MockServerWebExchange.from(request)));
115+
116+
request = MockServerHttpRequest.get("/").header(HttpHeaders.TRANSFER_ENCODING, "chunked").build();
117+
assertNull(condition.getMatchingCondition(MockServerWebExchange.from(request)));
118+
}
119+
102120
@Test
103121
public void compareToSingle() throws Exception {
104122
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/"));

spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java

+30-8
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import java.lang.annotation.Target;
2323
import java.lang.reflect.Method;
2424
import java.security.Principal;
25-
import java.util.ArrayList;
2625
import java.util.Collections;
2726
import java.util.Set;
2827

@@ -32,16 +31,20 @@
3231
import org.springframework.core.annotation.AliasFor;
3332
import org.springframework.http.MediaType;
3433
import org.springframework.stereotype.Controller;
34+
import org.springframework.util.ClassUtils;
3535
import org.springframework.web.bind.annotation.DeleteMapping;
3636
import org.springframework.web.bind.annotation.GetMapping;
3737
import org.springframework.web.bind.annotation.PatchMapping;
3838
import org.springframework.web.bind.annotation.PostMapping;
3939
import org.springframework.web.bind.annotation.PutMapping;
40+
import org.springframework.web.bind.annotation.RequestBody;
4041
import org.springframework.web.bind.annotation.RequestMapping;
4142
import org.springframework.web.bind.annotation.RequestMethod;
4243
import org.springframework.web.bind.annotation.RestController;
4344
import org.springframework.web.context.support.StaticWebApplicationContext;
4445
import org.springframework.web.method.HandlerTypePredicate;
46+
import org.springframework.web.reactive.result.condition.ConsumesRequestCondition;
47+
import org.springframework.web.reactive.result.condition.PatternsRequestCondition;
4548
import org.springframework.web.reactive.result.method.RequestMappingInfo;
4649
import org.springframework.web.util.pattern.PathPattern;
4750
import org.springframework.web.util.pattern.PathPatternParser;
@@ -103,10 +106,26 @@ public void resolveRequestMappingViaComposedAnnotation() throws Exception {
103106

104107
@Test // SPR-14988
105108
public void getMappingOverridesConsumesFromTypeLevelAnnotation() throws Exception {
106-
RequestMappingInfo requestMappingInfo = assertComposedAnnotationMapping(RequestMethod.GET);
109+
RequestMappingInfo requestMappingInfo = assertComposedAnnotationMapping(RequestMethod.POST);
107110

108-
assertArrayEquals(new MediaType[]{MediaType.ALL}, new ArrayList<>(
109-
requestMappingInfo.getConsumesCondition().getConsumableMediaTypes()).toArray());
111+
ConsumesRequestCondition condition = requestMappingInfo.getConsumesCondition();
112+
assertEquals(Collections.singleton(MediaType.APPLICATION_XML), condition.getConsumableMediaTypes());
113+
}
114+
115+
@Test // gh-22010
116+
public void consumesWithOptionalRequestBody() {
117+
this.wac.registerSingleton("testController", ComposedAnnotationController.class);
118+
this.wac.refresh();
119+
this.handlerMapping.afterPropertiesSet();
120+
RequestMappingInfo info = this.handlerMapping.getHandlerMethods().keySet().stream()
121+
.filter(i -> {
122+
PatternsRequestCondition condition = i.getPatternsCondition();
123+
return condition.getPatterns().iterator().next().getPatternString().equals("/post");
124+
})
125+
.findFirst()
126+
.orElseThrow(() -> new AssertionError("No /post"));
127+
128+
assertFalse(info.getConsumesCondition().isBodyRequired());
110129
}
111130

112131
@Test
@@ -146,7 +165,7 @@ private RequestMappingInfo assertComposedAnnotationMapping(String methodName, St
146165
RequestMethod requestMethod) throws Exception {
147166

148167
Class<?> clazz = ComposedAnnotationController.class;
149-
Method method = clazz.getMethod(methodName);
168+
Method method = ClassUtils.getMethod(clazz, methodName, null);
150169
RequestMappingInfo info = this.handlerMapping.getMappingForMethod(method, clazz);
151170

152171
assertNotNull(info);
@@ -175,12 +194,12 @@ public void handle() {
175194
public void postJson() {
176195
}
177196

178-
@GetMapping(value = "/get", consumes = MediaType.ALL_VALUE)
197+
@GetMapping("/get")
179198
public void get() {
180199
}
181200

182-
@PostMapping("/post")
183-
public void post() {
201+
@PostMapping(path = "/post", consumes = MediaType.APPLICATION_XML_VALUE)
202+
public void post(@RequestBody(required = false) Foo foo) {
184203
}
185204

186205
@PutMapping("/put")
@@ -196,6 +215,9 @@ public void patch() {
196215
}
197216
}
198217

218+
private static class Foo {
219+
}
220+
199221

200222
@RequestMapping(method = RequestMethod.POST,
201223
produces = MediaType.APPLICATION_JSON_VALUE,

spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/condition/ConsumesRequestCondition.java

+39-2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.Set;
2525
import javax.servlet.http.HttpServletRequest;
2626

27+
import org.springframework.http.HttpHeaders;
2728
import org.springframework.http.InvalidMediaTypeException;
2829
import org.springframework.http.MediaType;
2930
import org.springframework.lang.Nullable;
@@ -49,8 +50,11 @@ public final class ConsumesRequestCondition extends AbstractRequestCondition<Con
4950

5051
private static final ConsumesRequestCondition EMPTY_CONDITION = new ConsumesRequestCondition();
5152

53+
5254
private final List<ConsumeMediaTypeExpression> expressions;
5355

56+
private boolean bodyRequired = true;
57+
5458

5559
/**
5660
* Creates a new instance from 0 or more "consumes" expressions.
@@ -141,6 +145,29 @@ protected String getToStringInfix() {
141145
return " || ";
142146
}
143147

148+
/**
149+
* Whether this condition should expect requests to have a body.
150+
* <p>By default this is set to {@code true} in which case it is assumed a
151+
* request body is required and this condition matches to the "Content-Type"
152+
* header or falls back on "Content-Type: application/octet-stream".
153+
* <p>If set to {@code false}, and the request does not have a body, then this
154+
* condition matches automatically, i.e. without checking expressions.
155+
* @param bodyRequired whether requests are expected to have a body
156+
* @since 5.2
157+
*/
158+
public void setBodyRequired(boolean bodyRequired) {
159+
this.bodyRequired = bodyRequired;
160+
}
161+
162+
/**
163+
* Return the setting for {@link #setBodyRequired(boolean)}.
164+
* @since 5.2
165+
*/
166+
public boolean isBodyRequired() {
167+
return this.bodyRequired;
168+
}
169+
170+
144171
/**
145172
* Returns the "other" instance if it has any expressions; returns "this"
146173
* instance otherwise. Practically that means a method-level "consumes"
@@ -170,14 +197,17 @@ public ConsumesRequestCondition getMatchingCondition(HttpServletRequest request)
170197
if (isEmpty()) {
171198
return this;
172199
}
200+
if (!hasBody(request) && !this.bodyRequired) {
201+
return EMPTY_CONDITION;
202+
}
173203

174204
// Common media types are cached at the level of MimeTypeUtils
175205

176206
MediaType contentType;
177207
try {
178-
contentType = (StringUtils.hasLength(request.getContentType()) ?
208+
contentType = StringUtils.hasLength(request.getContentType()) ?
179209
MediaType.parseMediaType(request.getContentType()) :
180-
MediaType.APPLICATION_OCTET_STREAM);
210+
MediaType.APPLICATION_OCTET_STREAM;
181211
}
182212
catch (InvalidMediaTypeException ex) {
183213
return null;
@@ -187,6 +217,13 @@ public ConsumesRequestCondition getMatchingCondition(HttpServletRequest request)
187217
return !CollectionUtils.isEmpty(result) ? new ConsumesRequestCondition(result) : null;
188218
}
189219

220+
private boolean hasBody(HttpServletRequest request) {
221+
String contentLength = request.getHeader(HttpHeaders.CONTENT_LENGTH);
222+
String transferEncoding = request.getHeader(HttpHeaders.TRANSFER_ENCODING);
223+
return StringUtils.hasText(transferEncoding) ||
224+
(StringUtils.hasText(contentLength) && !contentLength.trim().equals("0"));
225+
}
226+
190227
@Nullable
191228
private List<ConsumeMediaTypeExpression> getMatchingExpressions(MediaType contentType) {
192229
List<ConsumeMediaTypeExpression> result = null;

0 commit comments

Comments
 (0)