Skip to content

Commit

Permalink
Support HTTP range requests in MVC Controllers
Browse files Browse the repository at this point in the history
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
  • Loading branch information
bclozel committed Aug 24, 2017
1 parent d20b3cf commit 582014e
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
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;
Expand Down Expand Up @@ -579,6 +580,7 @@ private ManagedList<?> getMessageConverters(Element element, @Nullable Object so
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));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
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;
Expand Down Expand Up @@ -790,6 +791,7 @@ protected final void addDefaultHttpMessageConverters(List<HttpMessageConverter<?
messageConverters.add(new ByteArrayHttpMessageConverter());
messageConverters.add(stringConverter);
messageConverters.add(new ResourceHttpMessageConverter());
messageConverters.add(new ResourceRegionHttpMessageConverter());
messageConverters.add(new SourceHttpMessageConverter<>());
messageConverters.add(new AllEncompassingFormHttpMessageConverter());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,15 @@
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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -183,6 +191,24 @@ protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter
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);
Expand Down Expand Up @@ -266,6 +292,13 @@ protected Class<?> getReturnValueType(@Nullable Object value, MethodParameter re
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}).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,14 +387,8 @@ public void handleRequest(HttpServletRequest request, HttpServletResponse respon
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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ public void requestMappingHandlerAdapter() throws Exception {
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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,9 @@
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;
Expand Down Expand Up @@ -82,6 +81,8 @@ public class HttpEntityMethodProcessorMockTests {

private HttpMessageConverter<Resource> resourceMessageConverter;

private HttpMessageConverter<Object> resourceRegionMessageConverter;

private MethodParameter paramHttpEntity;

private MethodParameter paramRequestEntity;
Expand Down Expand Up @@ -119,12 +120,11 @@ public void setup() throws Exception {
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);
Expand Down Expand Up @@ -497,6 +497,39 @@ public void shouldHandleResource() throws Exception {
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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.Collections;
import java.util.List;
import java.util.Optional;

import javax.validation.Valid;
import javax.validation.constraints.NotNull;

Expand All @@ -31,6 +32,7 @@
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;
Expand Down Expand Up @@ -71,6 +73,8 @@ public class RequestResponseBodyMethodProcessorMockTests {

private HttpMessageConverter<Resource> resourceMessageConverter;

private HttpMessageConverter<Object> resourceRegionMessageConverter;

private RequestResponseBodyMethodProcessor processor;

private ModelAndViewContainer mavContainer;
Expand All @@ -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();
Expand Down Expand Up @@ -364,6 +370,37 @@ public void handleReturnValueMediaTypeSuffix() throws Exception {
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
Expand Down

0 comments on commit 582014e

Please sign in to comment.