Browse Source

Support HTTP range requests in MVC Controllers

Prior to this commit, HTTP Range requests were only supported by the
ResourceHttpRequestHandler when serving static resources.

This commit improves the `HttpEntityMethodProcessor` and
the `RequestResponseBodyMethodProcessor`. They now extract
`ResourceRegion`s from the `Resource` instance returned by the
Controller and let the Resource-related message converters
handle the writing of the resource (including partial writes).

Controller methods can now handle Range requests for
return types that extend Resource or HttpEntity:

    @RequestMapping("/example/video.mp4")
    public Resource handler() { }

    @RequestMapping("/example/video.mp4")
    public HttpEntity<Resource> handler() { }

Issue: SPR-15789, SPR-13834
pull/1508/head
Brian Clozel 9 years ago
parent
commit
582014e944
  1. 2
      spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java
  2. 2
      spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java
  3. 33
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java
  4. 6
      spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java
  5. 2
      spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java
  6. 49
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java
  7. 41
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorMockTests.java

2
spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java

@ -44,6 +44,7 @@ import org.springframework.http.MediaType; @@ -44,6 +44,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.converter.ResourceRegionHttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter;
import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter;
@ -579,6 +580,7 @@ class AnnotationDrivenBeanDefinitionParser implements BeanDefinitionParser { @@ -579,6 +580,7 @@ class AnnotationDrivenBeanDefinitionParser implements BeanDefinitionParser {
messageConverters.add(stringConverterDef);
messageConverters.add(createConverterDefinition(ResourceHttpMessageConverter.class, source));
messageConverters.add(createConverterDefinition(ResourceRegionHttpMessageConverter.class, source));
messageConverters.add(createConverterDefinition(SourceHttpMessageConverter.class, source));
messageConverters.add(createConverterDefinition(AllEncompassingFormHttpMessageConverter.class, source));

2
spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java

@ -41,6 +41,7 @@ import org.springframework.http.MediaType; @@ -41,6 +41,7 @@ import org.springframework.http.MediaType;
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.ResourceHttpMessageConverter;
import org.springframework.http.converter.ResourceRegionHttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter;
import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter;
@ -790,6 +791,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv @@ -790,6 +791,7 @@ public class WebMvcConfigurationSupport implements ApplicationContextAware, Serv
messageConverters.add(new ByteArrayHttpMessageConverter());
messageConverters.add(stringConverter);
messageConverters.add(new ResourceHttpMessageConverter());
messageConverters.add(new ResourceRegionHttpMessageConverter());
messageConverters.add(new SourceHttpMessageConverter<>());
messageConverters.add(new AllEncompassingFormHttpMessageConverter());

33
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java

@ -30,10 +30,15 @@ import javax.servlet.http.HttpServletRequest; @@ -30,10 +30,15 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.core.MethodParameter;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.core.ResolvableType;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.ResourceRegion;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.HttpRange;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
@ -75,6 +80,9 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe @@ -75,6 +80,9 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
private static final MediaType MEDIA_TYPE_APPLICATION = new MediaType("application");
private static final Type RESOURCE_REGION_LIST_TYPE =
new ParameterizedTypeReference<List<ResourceRegion>>() { }.getType();
private static final UrlPathHelper decodingUrlPathHelper = new UrlPathHelper();
@ -183,6 +191,24 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe @@ -183,6 +191,24 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
valueType = getReturnValueType(outputValue, returnType);
declaredType = getGenericType(returnType);
}
if (isResourceType(value, returnType)) {
outputMessage.getHeaders().set(HttpHeaders.ACCEPT_RANGES, "bytes");
if (value != null && inputMessage.getHeaders().getFirst(HttpHeaders.RANGE) != null) {
Resource resource = (Resource) value;
try {
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
outputMessage.getServletResponse().setStatus(HttpStatus.PARTIAL_CONTENT.value());
outputValue = HttpRange.toResourceRegions(httpRanges, resource);
valueType = outputValue.getClass();
declaredType = RESOURCE_REGION_LIST_TYPE;
}
catch (IllegalArgumentException ex) {
outputMessage.getHeaders().set(HttpHeaders.CONTENT_RANGE, "bytes */" + resource.contentLength());
outputMessage.getServletResponse().setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value());
}
}
}
HttpServletRequest request = inputMessage.getServletRequest();
List<MediaType> requestedMediaTypes = getAcceptableMediaTypes(request);
@ -266,6 +292,13 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe @@ -266,6 +292,13 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
return (value != null ? value.getClass() : returnType.getParameterType());
}
/**
* Return whether the returned value or the declared return type extend {@link Resource}
*/
protected boolean isResourceType(@Nullable Object value, MethodParameter returnType) {
return Resource.class.isAssignableFrom(value != null ? value.getClass() : returnType.getParameterType());
}
/**
* Return the generic type of the {@code returnType} (or of the nested type
* if it is an {@link HttpEntity}).

6
spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java

@ -387,14 +387,8 @@ public class ResourceHttpRequestHandler extends WebContentGenerator @@ -387,14 +387,8 @@ public class ResourceHttpRequestHandler extends WebContentGenerator
try {
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
if (httpRanges.size() == 1) {
ResourceRegion resourceRegion = httpRanges.get(0).toResourceRegion(resource);
this.resourceRegionHttpMessageConverter.write(resourceRegion, mediaType, outputMessage);
}
else {
this.resourceRegionHttpMessageConverter.write(
HttpRange.toResourceRegions(httpRanges, resource), mediaType, outputMessage);
}
}
catch (IllegalArgumentException ex) {
response.setHeader("Content-Range", "bytes */" + resource.contentLength());

2
spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java

@ -175,7 +175,7 @@ public class WebMvcConfigurationSupportTests { @@ -175,7 +175,7 @@ public class WebMvcConfigurationSupportTests {
ApplicationContext context = initContext(WebConfig.class);
RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class);
List<HttpMessageConverter<?>> converters = adapter.getMessageConverters();
assertEquals(11, converters.size());
assertEquals(12, converters.size());
converters.stream()
.filter(converter -> converter instanceof AbstractJackson2HttpMessageConverter)
.forEach(converter -> {

49
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java

@ -20,10 +20,9 @@ import java.lang.reflect.Method; @@ -20,10 +20,9 @@ import java.lang.reflect.Method;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import org.junit.Before;
import org.junit.Rule;
@ -82,6 +81,8 @@ public class HttpEntityMethodProcessorMockTests { @@ -82,6 +81,8 @@ public class HttpEntityMethodProcessorMockTests {
private HttpMessageConverter<Resource> resourceMessageConverter;
private HttpMessageConverter<Object> resourceRegionMessageConverter;
private MethodParameter paramHttpEntity;
private MethodParameter paramRequestEntity;
@ -119,12 +120,11 @@ public class HttpEntityMethodProcessorMockTests { @@ -119,12 +120,11 @@ public class HttpEntityMethodProcessorMockTests {
given(stringHttpMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN));
resourceMessageConverter = mock(HttpMessageConverter.class);
given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
List<HttpMessageConverter<?>> converters = new ArrayList<>();
converters.add(stringHttpMessageConverter);
converters.add(resourceMessageConverter);
processor = new HttpEntityMethodProcessor(converters);
reset(stringHttpMessageConverter);
reset(resourceMessageConverter);
resourceRegionMessageConverter = mock(HttpMessageConverter.class);
given(resourceRegionMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
processor = new HttpEntityMethodProcessor(
Arrays.asList(stringHttpMessageConverter, resourceMessageConverter, resourceRegionMessageConverter));
Method handle1 = getClass().getMethod("handle1", HttpEntity.class, ResponseEntity.class,
Integer.TYPE, RequestEntity.class);
@ -497,6 +497,39 @@ public class HttpEntityMethodProcessorMockTests { @@ -497,6 +497,39 @@ public class HttpEntityMethodProcessorMockTests {
assertEquals(200, servletResponse.getStatus());
}
@Test
public void shouldHandleResourceByteRange() throws Exception {
ResponseEntity<Resource> returnValue = ResponseEntity
.ok(new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8)));
servletRequest.addHeader("Range", "bytes=0-5");
given(resourceRegionMessageConverter.canWrite(any(), eq(null))).willReturn(true);
given(resourceRegionMessageConverter.canWrite(any(), eq(MediaType.APPLICATION_OCTET_STREAM))).willReturn(true);
processor.handleReturnValue(returnValue, returnTypeResponseEntityResource, mavContainer, webRequest);
then(resourceRegionMessageConverter).should(times(1)).write(
anyCollection(), eq(MediaType.APPLICATION_OCTET_STREAM),
argThat(outputMessage -> outputMessage.getHeaders().getFirst(HttpHeaders.ACCEPT_RANGES) == "bytes"));
assertEquals(206, servletResponse.getStatus());
}
@Test
public void handleReturnTypeResourceIllegalByteRange() throws Exception {
ResponseEntity<Resource> returnValue = ResponseEntity
.ok(new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8)));
servletRequest.addHeader("Range", "illegal");
given(resourceRegionMessageConverter.canWrite(any(), eq(null))).willReturn(true);
given(resourceRegionMessageConverter.canWrite(any(), eq(MediaType.APPLICATION_OCTET_STREAM))).willReturn(true);
processor.handleReturnValue(returnValue, returnTypeResponseEntityResource, mavContainer, webRequest);
then(resourceRegionMessageConverter).should(never()).write(
anyCollection(), eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class));
assertEquals(416, servletResponse.getStatus());
}
@Test //SPR-14767
public void shouldHandleValidatorHeadersInPutResponses() throws Exception {
servletRequest.setMethod("PUT");

41
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorMockTests.java

@ -22,6 +22,7 @@ import java.util.Arrays; @@ -22,6 +22,7 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
@ -31,6 +32,7 @@ import org.junit.Test; @@ -31,6 +32,7 @@ import org.junit.Test;
import org.springframework.core.MethodParameter;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
@ -71,6 +73,8 @@ public class RequestResponseBodyMethodProcessorMockTests { @@ -71,6 +73,8 @@ public class RequestResponseBodyMethodProcessorMockTests {
private HttpMessageConverter<Resource> resourceMessageConverter;
private HttpMessageConverter<Object> resourceRegionMessageConverter;
private RequestResponseBodyMethodProcessor processor;
private ModelAndViewContainer mavContainer;
@ -97,11 +101,13 @@ public class RequestResponseBodyMethodProcessorMockTests { @@ -97,11 +101,13 @@ public class RequestResponseBodyMethodProcessorMockTests {
public void setup() throws Exception {
stringMessageConverter = mock(HttpMessageConverter.class);
given(stringMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN));
resourceMessageConverter = mock(HttpMessageConverter.class);
given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
resourceRegionMessageConverter = mock(HttpMessageConverter.class);
given(resourceRegionMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
processor = new RequestResponseBodyMethodProcessor(Arrays.asList(stringMessageConverter, resourceMessageConverter));
processor = new RequestResponseBodyMethodProcessor(
Arrays.asList(stringMessageConverter, resourceMessageConverter, resourceRegionMessageConverter));
mavContainer = new ModelAndViewContainer();
servletRequest = new MockHttpServletRequest();
@ -364,6 +370,37 @@ public class RequestResponseBodyMethodProcessorMockTests { @@ -364,6 +370,37 @@ public class RequestResponseBodyMethodProcessorMockTests {
verify(stringMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class));
}
@Test
public void handleReturnTypeResourceByteRange() throws Exception {
Resource returnValue = new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8));
servletRequest.addHeader("Range", "bytes=0-5");
given(resourceRegionMessageConverter.canWrite(any(), eq(null))).willReturn(true);
given(resourceRegionMessageConverter.canWrite(any(), eq(MediaType.APPLICATION_OCTET_STREAM))).willReturn(true);
processor.handleReturnValue(returnValue, returnTypeResource, mavContainer, webRequest);
then(resourceRegionMessageConverter).should(times(1)).write(
anyCollection(), eq(MediaType.APPLICATION_OCTET_STREAM),
argThat(outputMessage -> outputMessage.getHeaders().getFirst(HttpHeaders.ACCEPT_RANGES) == "bytes"));
assertEquals(206, servletResponse.getStatus());
}
@Test
public void handleReturnTypeResourceIllegalByteRange() throws Exception {
Resource returnValue = new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8));
servletRequest.addHeader("Range", "illegal");
given(resourceRegionMessageConverter.canWrite(any(), eq(null))).willReturn(true);
given(resourceRegionMessageConverter.canWrite(any(), eq(MediaType.APPLICATION_OCTET_STREAM))).willReturn(true);
processor.handleReturnValue(returnValue, returnTypeResource, mavContainer, webRequest);
then(resourceRegionMessageConverter).should(never()).write(
anyCollection(), eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class));
assertEquals(416, servletResponse.getStatus());
}
@SuppressWarnings("unused")
@ResponseBody

Loading…
Cancel
Save