Improve empty request body handling

The check for an empty request body InputStream is now in the base
class AbstractMessageConverterMethodArgumentResolver shared for
all arguments that involve reading with an HttpMessageConverter --
@RequestBody, @RequestPart, and HttpEntity.

When an empty body is detected any configured RequestBodyAdvice is
given a chance to select a default value or leave it as null.

Issue: SPR-12778, SPR-12860, SPR-12861
This commit is contained in:
Rossen Stoyanchev
2015-03-29 21:24:30 -04:00
parent 0556ed4f16
commit 36ed4df59d
9 changed files with 256 additions and 107 deletions

View File

@@ -16,14 +16,19 @@
package org.springframework.web.servlet.mvc.method.annotation;
import static org.junit.Assert.*;
import static org.mockito.BDDMockito.any;
import static org.mockito.BDDMockito.*;
import static org.mockito.BDDMockito.isA;
import static org.mockito.Matchers.eq;
import static org.springframework.web.servlet.HandlerMapping.*;
import java.lang.reflect.Method;
import java.net.URI;
import java.text.SimpleDateFormat;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Locale;
import java.util.TimeZone;
import org.junit.Before;
import org.junit.Test;
@@ -48,13 +53,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.support.ModelAndViewContainer;
import static org.junit.Assert.*;
import static org.mockito.BDDMockito.any;
import static org.mockito.BDDMockito.*;
import static org.mockito.BDDMockito.isA;
import static org.mockito.Matchers.eq;
import static org.springframework.web.servlet.HandlerMapping.*;
/**
* Test fixture for {@link HttpEntityMethodProcessor} delegating to a mock
* {@link HttpMessageConverter}.
@@ -136,10 +134,12 @@ public class HttpEntityMethodProcessorMockTests {
@Test
public void resolveArgument() throws Exception {
String body = "Foo";
MediaType contentType = MediaType.TEXT_PLAIN;
servletRequest.addHeader("Content-Type", contentType.toString());
servletRequest.setContent(body.getBytes(Charset.forName("UTF-8")));
String body = "Foo";
given(messageConverter.canRead(String.class, contentType)).willReturn(true);
given(messageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(body);
@@ -152,14 +152,16 @@ public class HttpEntityMethodProcessorMockTests {
@Test
public void resolveArgumentRequestEntity() throws Exception {
String body = "Foo";
MediaType contentType = MediaType.TEXT_PLAIN;
servletRequest.addHeader("Content-Type", contentType.toString());
servletRequest.setMethod("GET");
servletRequest.setServerName("www.example.com");
servletRequest.setServerPort(80);
servletRequest.setRequestURI("/path");
servletRequest.setContent(body.getBytes(Charset.forName("UTF-8")));
String body = "Foo";
given(messageConverter.canRead(String.class, contentType)).willReturn(true);
given(messageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(body);
@@ -226,6 +228,7 @@ public class HttpEntityMethodProcessorMockTests {
verify(messageConverter).write(eq(body), eq(MediaType.TEXT_HTML), isA(HttpOutputMessage.class));
}
@SuppressWarnings("unchecked")
@Test
public void handleReturnValueWithResponseBodyAdvice() throws Exception {
ResponseEntity<String> returnValue = new ResponseEntity<>(HttpStatus.OK);
@@ -416,28 +419,35 @@ public class HttpEntityMethodProcessorMockTests {
assertEquals(0, servletResponse.getContentAsByteArray().length);
}
public ResponseEntity<String> handle1(HttpEntity<String> httpEntity, ResponseEntity<String> responseEntity, int i, RequestEntity<String> requestEntity) {
return responseEntity;
@SuppressWarnings("unused")
public ResponseEntity<String> handle1(HttpEntity<String> httpEntity, ResponseEntity<String> entity,
int i, RequestEntity<String> requestEntity) {
return entity;
}
@SuppressWarnings("unused")
public HttpEntity<?> handle2(HttpEntity<?> entity) {
return entity;
}
@SuppressWarnings("unused")
public CustomHttpEntity handle2x(HttpEntity<?> entity) {
return new CustomHttpEntity();
}
@SuppressWarnings("unused")
public int handle3() {
return 42;
}
@SuppressWarnings("unused")
@RequestMapping(produces = {"text/html", "application/xhtml+xml"})
public ResponseEntity<String> handle4() {
return null;
}
@SuppressWarnings("unused")
public static class CustomHttpEntity extends HttpEntity<Object> {
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2014 the original author or authors.
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,9 +16,12 @@
package org.springframework.web.servlet.mvc.method.annotation;
import static org.junit.Assert.*;
import java.io.Serializable;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.junit.Before;
@@ -39,8 +42,6 @@ import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.method.support.ModelAndViewContainer;
import static org.junit.Assert.*;
/**
* Test fixture with {@link HttpEntityMethodProcessor} delegating to
* actual {@link HttpMessageConverter} instances.
@@ -61,8 +62,6 @@ public class HttpEntityMethodProcessorTests {
private MockHttpServletRequest servletRequest;
private MockHttpServletResponse servletResponse;
private ServletWebRequest webRequest;
@@ -75,8 +74,7 @@ public class HttpEntityMethodProcessorTests {
mavContainer = new ModelAndViewContainer();
binderFactory = new ValidatingBinderFactory();
servletRequest = new MockHttpServletRequest();
servletResponse = new MockHttpServletResponse();
webRequest = new ServletWebRequest(servletRequest, servletResponse);
webRequest = new ServletWebRequest(servletRequest, new MockHttpServletResponse());
}
@Test
@@ -97,6 +95,23 @@ public class HttpEntityMethodProcessorTests {
assertEquals("Jad", result.getBody().getName());
}
// SPR-12861
@Test
public void resolveArgumentWithEmptyBody() throws Exception {
this.servletRequest.setContent(new byte[0]);
this.servletRequest.setContentType("application/json");
List<HttpMessageConverter<?>> converters = Arrays.asList(new MappingJackson2HttpMessageConverter());
HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor(converters);
HttpEntity<?> result = (HttpEntity<?>) processor.resolveArgument(this.paramSimpleBean,
this.mavContainer, this.webRequest, this.binderFactory);
assertNotNull(result);
assertNull(result.getBody());
}
@Test
public void resolveGenericArgument() throws Exception {
String content = "[{\"name\" : \"Jad\"}, {\"name\" : \"Robert\"}]";
@@ -139,21 +154,21 @@ public class HttpEntityMethodProcessorTests {
}
@SuppressWarnings("unused")
public void handle(HttpEntity<List<SimpleBean>> arg1, HttpEntity<SimpleBean> arg2) {
}
@SuppressWarnings("unused")
private static abstract class MyParameterizedController<DTO extends Identifiable> {
public void handleDto(HttpEntity<DTO> dto) {
}
}
@SuppressWarnings("unused")
private static class MySimpleParameterizedController extends MyParameterizedController<SimpleBean> {
}
private interface Identifiable extends Serializable {
public Long getId();

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2014 the original author or authors.
* Copyright 2002-2015 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,8 +16,13 @@
package org.springframework.web.servlet.mvc.method.annotation;
import static org.junit.Assert.*;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.servlet.MultipartConfigElement;
import org.eclipse.jetty.server.Server;
@@ -34,8 +39,10 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
@@ -57,8 +64,6 @@ import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import static org.junit.Assert.*;
/**
* Test access to parts of a multipart request with {@link RequestPart}.
*
@@ -102,9 +107,16 @@ public class RequestPartIntegrationTests {
@Before
public void setUp() {
ByteArrayHttpMessageConverter emptyBodyConverter = new ByteArrayHttpMessageConverter();
emptyBodyConverter.setSupportedMediaTypes(Arrays.asList(MediaType.APPLICATION_JSON));
List<HttpMessageConverter<?>> converters = new ArrayList<>(3);
converters.add(emptyBodyConverter);
converters.add(new ResourceHttpMessageConverter());
converters.add(new MappingJackson2HttpMessageConverter());
AllEncompassingFormHttpMessageConverter converter = new AllEncompassingFormHttpMessageConverter();
converter.setPartConverters(Arrays.<HttpMessageConverter<?>>asList(
new ResourceHttpMessageConverter(), new MappingJackson2HttpMessageConverter()));
converter.setPartConverters(converters);
restTemplate = new RestTemplate(new HttpComponentsClientHttpRequestFactory());
restTemplate.setMessageConverters(Arrays.<HttpMessageConverter<?>>asList(converter));
@@ -130,9 +142,9 @@ public class RequestPartIntegrationTests {
private void testCreate(String url) {
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<String, Object>();
HttpEntity<TestData> jsonEntity = new HttpEntity<TestData>(new TestData("Jason"));
parts.add("json-data", jsonEntity);
parts.add("json-data", new HttpEntity<TestData>(new TestData("Jason")));
parts.add("file-data", new ClassPathResource("logo.jpg", this.getClass()));
parts.add("empty-data", new HttpEntity<byte[]>(new byte[0])); // SPR-12860
URI location = restTemplate.postForLocation(url, parts);
assertEquals("http://localhost:8080/test/Jason/logo.jpg", location.toString());
@@ -167,11 +179,15 @@ public class RequestPartIntegrationTests {
}
}
@SuppressWarnings("unused")
@Controller
private static class RequestPartTestController {
@RequestMapping(value = "/test", method = RequestMethod.POST, consumes = { "multipart/mixed", "multipart/form-data" })
public ResponseEntity<Object> create(@RequestPart("json-data") TestData testData, @RequestPart("file-data") MultipartFile file) {
public ResponseEntity<Object> create(@RequestPart("json-data") TestData testData,
@RequestPart("file-data") MultipartFile file,
@RequestPart(value = "empty-data", required = false) TestData emptyData) {
String url = "http://localhost:8080/test/" + testData.getName() + "/" + file.getOriginalFilename();
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(url));

View File

@@ -16,11 +16,16 @@
package org.springframework.web.servlet.mvc.method.annotation;
import static org.junit.Assert.*;
import static org.mockito.BDDMockito.*;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import javax.servlet.http.Part;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@@ -31,6 +36,7 @@ import org.junit.Test;
import org.springframework.core.LocalVariableTableParameterNameDiscoverer;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.mock.web.test.MockHttpServletRequest;
@@ -52,9 +58,6 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import org.springframework.web.multipart.support.RequestPartServletServerHttpRequest;
import static org.junit.Assert.*;
import static org.mockito.BDDMockito.*;
/**
* Test fixture with {@link RequestPartMethodArgumentResolver} and mock {@link HttpMessageConverter}.
*
@@ -90,8 +93,6 @@ public class RequestPartMethodArgumentResolverTests {
private MockMultipartHttpServletRequest multipartRequest;
private MockHttpServletResponse servletResponse;
@SuppressWarnings("unchecked")
@Before
@@ -128,13 +129,14 @@ public class RequestPartMethodArgumentResolverTests {
resolver = new RequestPartMethodArgumentResolver(Collections.<HttpMessageConverter<?>>singletonList(messageConverter));
reset(messageConverter);
multipartFile1 = new MockMultipartFile("requestPart", "", "text/plain", (byte[]) null);
multipartFile2 = new MockMultipartFile("requestPart", "", "text/plain", (byte[]) null);
byte[] content = "doesn't matter as long as not empty".getBytes(Charset.forName("UTF-8"));
multipartFile1 = new MockMultipartFile("requestPart", "", "text/plain", content);
multipartFile2 = new MockMultipartFile("requestPart", "", "text/plain", content);
multipartRequest = new MockMultipartHttpServletRequest();
multipartRequest.addFile(multipartFile1);
multipartRequest.addFile(multipartFile2);
servletResponse = new MockHttpServletResponse();
webRequest = new ServletWebRequest(multipartRequest, servletResponse);
webRequest = new ServletWebRequest(multipartRequest, new MockHttpServletResponse());
}
@@ -361,7 +363,7 @@ public class RequestPartMethodArgumentResolverTests {
SimpleBean simpleBean = new SimpleBean("foo");
given(messageConverter.canRead(SimpleBean.class, MediaType.TEXT_PLAIN)).willReturn(true);
given(messageConverter.read(eq(SimpleBean.class), isA(RequestPartServletServerHttpRequest.class))).willReturn(simpleBean);
given(messageConverter.read(eq(SimpleBean.class), isA(HttpInputMessage.class))).willReturn(simpleBean);
ModelAndViewContainer mavContainer = new ModelAndViewContainer();
Object actualValue = resolver.resolveArgument(optionalRequestPart, mavContainer, webRequest, new ValidatingBinderFactory());
@@ -385,7 +387,7 @@ public class RequestPartMethodArgumentResolverTests {
private void testResolveArgument(SimpleBean argValue, MethodParameter parameter) throws Exception {
given(messageConverter.canRead(SimpleBean.class, MediaType.TEXT_PLAIN)).willReturn(true);
given(messageConverter.read(eq(SimpleBean.class), isA(RequestPartServletServerHttpRequest.class))).willReturn(argValue);
given(messageConverter.read(eq(SimpleBean.class), isA(HttpInputMessage.class))).willReturn(argValue);
ModelAndViewContainer mavContainer = new ModelAndViewContainer();
Object actualValue = resolver.resolveArgument(parameter, mavContainer, webRequest, new ValidatingBinderFactory());
@@ -423,7 +425,7 @@ public class RequestPartMethodArgumentResolverTests {
}
}
@SuppressWarnings("unused")
public void handle(@RequestPart SimpleBean requestPart,
@RequestPart(value="requestPart", required=false) SimpleBean namedRequestPart,
@Valid @RequestPart("requestPart") SimpleBean validRequestPart,

View File

@@ -16,12 +16,15 @@
package org.springframework.web.servlet.mvc.method.annotation;
import java.io.IOException;
import static org.junit.Assert.*;
import static org.mockito.BDDMockito.*;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@@ -49,9 +52,6 @@ import org.springframework.web.context.request.ServletWebRequest;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.servlet.HandlerMapping;
import static org.junit.Assert.*;
import static org.mockito.BDDMockito.*;
/**
* Test fixture for {@link RequestResponseBodyMethodProcessor} delegating to a
* mock HttpMessageConverter.
@@ -81,7 +81,6 @@ public class RequestResponseBodyMethodProcessorMockTests {
private MockHttpServletRequest servletRequest;
private MockHttpServletResponse servletResponse;
@SuppressWarnings("unchecked")
@Before
@@ -103,8 +102,7 @@ public class RequestResponseBodyMethodProcessorMockTests {
mavContainer = new ModelAndViewContainer();
servletRequest = new MockHttpServletRequest();
servletResponse = new MockHttpServletResponse();
webRequest = new ServletWebRequest(servletRequest, servletResponse);
webRequest = new ServletWebRequest(servletRequest, new MockHttpServletResponse());
}
@Test
@@ -154,7 +152,7 @@ public class RequestResponseBodyMethodProcessorMockTests {
testResolveArgumentWithValidation(new SimpleBean("name"));
}
private void testResolveArgumentWithValidation(SimpleBean simpleBean) throws IOException, Exception {
private void testResolveArgumentWithValidation(SimpleBean simpleBean) throws Exception {
MediaType contentType = MediaType.TEXT_PLAIN;
servletRequest.addHeader("Content-Type", contentType.toString());
servletRequest.setContent("payload".getBytes(Charset.forName("UTF-8")));
@@ -189,6 +187,7 @@ public class RequestResponseBodyMethodProcessorMockTests {
fail("Expected exception");
}
catch (HttpMediaTypeNotSupportedException ex) {
// expected
}
}
@@ -212,7 +211,9 @@ public class RequestResponseBodyMethodProcessorMockTests {
@Test
public void resolveArgumentNotRequiredNoContent() throws Exception {
servletRequest.setContentType("text/plain");
servletRequest.setContent(new byte[0]);
given(messageConverter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true);
assertNull(processor.resolveArgument(paramStringNotRequired, mavContainer, webRequest, new ValidatingBinderFactory()));
}
@@ -293,23 +294,28 @@ public class RequestResponseBodyMethodProcessorMockTests {
}
@SuppressWarnings("unused")
@ResponseBody
public String handle1(@RequestBody String s, int i) {
return s;
}
@SuppressWarnings("unused")
public int handle2() {
return 42;
}
@SuppressWarnings("unused")
@ResponseBody
public String handle3() {
return null;
}
@SuppressWarnings("unused")
public void handle4(@Valid @RequestBody SimpleBean b) {
}
@SuppressWarnings("unused")
public void handle5(@RequestBody(required=false) String s) {
}

View File

@@ -18,6 +18,7 @@ package org.springframework.web.servlet.mvc.method.annotation;
import java.io.Serializable;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@@ -29,6 +30,7 @@ import org.junit.Test;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.aop.target.SingletonTargetSource;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -177,7 +179,9 @@ public class RequestResponseBodyMethodProcessorTests {
assertEquals("foobarbaz", result);
}
@Test(expected = HttpMessageNotReadableException.class) // SPR-9942
// SPR-9942
@Test(expected = HttpMessageNotReadableException.class)
public void resolveArgumentRequiredNoContent() throws Exception {
this.servletRequest.setContent(new byte[0]);
this.servletRequest.setContentType("text/plain");
@@ -187,7 +191,23 @@ public class RequestResponseBodyMethodProcessorTests {
processor.resolveArgument(paramString, mavContainer, webRequest, binderFactory);
}
@Test // SPR-9964
// SPR-12778
@Test
public void resolveArgumentRequiredNoContentDefaultValue() throws Exception {
this.servletRequest.setContent(new byte[0]);
this.servletRequest.setContentType("text/plain");
List<HttpMessageConverter<?>> converters = Arrays.asList(new StringHttpMessageConverter());
List<Object> advice = Arrays.asList(new EmptyRequestBodyAdvice());
RequestResponseBodyMethodProcessor processor = new RequestResponseBodyMethodProcessor(converters, advice);
String arg = (String) processor.resolveArgument(paramString, mavContainer, webRequest, binderFactory);
assertNotNull(arg);
assertEquals("default value for empty body", arg);
}
// SPR-9964
@Test
public void resolveArgumentTypeVariable() throws Exception {
Method method = MyParameterizedController.class.getMethod("handleDto", Identifiable.class);
HandlerMethod handlerMethod = new HandlerMethod(new MySimpleParameterizedController(), method);
@@ -207,7 +227,9 @@ public class RequestResponseBodyMethodProcessorTests {
assertEquals("Jad", result.getName());
}
@Test // SPR-11225
// SPR-11225
@Test
public void resolveArgumentTypeVariableWithNonGenericConverter() throws Exception {
Method method = MyParameterizedController.class.getMethod("handleDto", Identifiable.class);
HandlerMethod handlerMethod = new HandlerMethod(new MySimpleParameterizedController(), method);
@@ -229,7 +251,9 @@ public class RequestResponseBodyMethodProcessorTests {
assertEquals("Jad", result.getName());
}
@Test // SPR-9160
// SPR-9160
@Test
public void handleReturnValueSortByQuality() throws Exception {
this.servletRequest.addHeader("Accept", "text/plain; q=0.5, application/json");
@@ -383,6 +407,7 @@ public class RequestResponseBodyMethodProcessorTests {
}
@SuppressWarnings("unused")
public String handle(
@RequestBody List<SimpleBean> list,
@RequestBody SimpleBean simpleBean,
@@ -393,6 +418,7 @@ public class RequestResponseBodyMethodProcessorTests {
}
@SuppressWarnings("unused")
private static abstract class MyParameterizedController<DTO extends Identifiable> {
@SuppressWarnings("unused")
@@ -400,6 +426,7 @@ public class RequestResponseBodyMethodProcessorTests {
}
@SuppressWarnings("unused")
private static class MySimpleParameterizedController extends MyParameterizedController<SimpleBean> {
}
@@ -472,9 +499,10 @@ public class RequestResponseBodyMethodProcessorTests {
}
}
private interface MyJacksonView1 {};
private interface MyJacksonView2 {};
private interface MyJacksonView1 {}
private interface MyJacksonView2 {}
@SuppressWarnings("unused")
private static class JacksonViewBean {
@JsonView(MyJacksonView1.class)
@@ -537,4 +565,35 @@ public class RequestResponseBodyMethodProcessorTests {
}
private static class EmptyRequestBodyAdvice implements RequestBodyAdvice {
@Override
public boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
return StringHttpMessageConverter.class.equals(converterType);
}
@Override
public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return "default value for empty body";
}
@Override
public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return inputMessage;
}
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
return body;
}
}
}