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
This commit is contained in:
@@ -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 {
|
||||
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));
|
||||
|
||||
|
||||
@@ -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
|
||||
messageConverters.add(new ByteArrayHttpMessageConverter());
|
||||
messageConverters.add(stringConverter);
|
||||
messageConverters.add(new ResourceHttpMessageConverter());
|
||||
messageConverters.add(new ResourceRegionHttpMessageConverter());
|
||||
messageConverters.add(new SourceHttpMessageConverter<>());
|
||||
messageConverters.add(new AllEncompassingFormHttpMessageConverter());
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
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
|
||||
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}).
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
private HttpMessageConverter<Resource> resourceMessageConverter;
|
||||
|
||||
private HttpMessageConverter<Object> resourceRegionMessageConverter;
|
||||
|
||||
private MethodParameter paramHttpEntity;
|
||||
|
||||
private MethodParameter paramRequestEntity;
|
||||
@@ -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 {
|
||||
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");
|
||||
|
||||
@@ -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;
|
||||
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 {
|
||||
|
||||
private HttpMessageConverter<Resource> resourceMessageConverter;
|
||||
|
||||
private HttpMessageConverter<Object> resourceRegionMessageConverter;
|
||||
|
||||
private RequestResponseBodyMethodProcessor processor;
|
||||
|
||||
private ModelAndViewContainer mavContainer;
|
||||
@@ -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 {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user