Skip to content

Commit 582014e

Browse files
committed
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
1 parent d20b3cf commit 582014e

File tree

7 files changed

+118
-17
lines changed

7 files changed

+118
-17
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
4545
import org.springframework.http.converter.HttpMessageConverter;
4646
import org.springframework.http.converter.ResourceHttpMessageConverter;
47+
import org.springframework.http.converter.ResourceRegionHttpMessageConverter;
4748
import org.springframework.http.converter.StringHttpMessageConverter;
4849
import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter;
4950
import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter;
@@ -579,6 +580,7 @@ private ManagedList<?> getMessageConverters(Element element, @Nullable Object so
579580
messageConverters.add(stringConverterDef);
580581

581582
messageConverters.add(createConverterDefinition(ResourceHttpMessageConverter.class, source));
583+
messageConverters.add(createConverterDefinition(ResourceRegionHttpMessageConverter.class, source));
582584
messageConverters.add(createConverterDefinition(SourceHttpMessageConverter.class, source));
583585
messageConverters.add(createConverterDefinition(AllEncompassingFormHttpMessageConverter.class, source));
584586

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import org.springframework.http.converter.ByteArrayHttpMessageConverter;
4242
import org.springframework.http.converter.HttpMessageConverter;
4343
import org.springframework.http.converter.ResourceHttpMessageConverter;
44+
import org.springframework.http.converter.ResourceRegionHttpMessageConverter;
4445
import org.springframework.http.converter.StringHttpMessageConverter;
4546
import org.springframework.http.converter.cbor.MappingJackson2CborHttpMessageConverter;
4647
import org.springframework.http.converter.feed.AtomFeedHttpMessageConverter;
@@ -790,6 +791,7 @@ protected final void addDefaultHttpMessageConverters(List<HttpMessageConverter<?
790791
messageConverters.add(new ByteArrayHttpMessageConverter());
791792
messageConverters.add(stringConverter);
792793
messageConverters.add(new ResourceHttpMessageConverter());
794+
messageConverters.add(new ResourceRegionHttpMessageConverter());
793795
messageConverters.add(new SourceHttpMessageConverter<>());
794796
messageConverters.add(new AllEncompassingFormHttpMessageConverter());
795797

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,15 @@
3030
import javax.servlet.http.HttpServletResponse;
3131

3232
import org.springframework.core.MethodParameter;
33+
import org.springframework.core.ParameterizedTypeReference;
3334
import org.springframework.core.ResolvableType;
35+
import org.springframework.core.io.Resource;
36+
import org.springframework.core.io.support.ResourceRegion;
3437
import org.springframework.http.HttpEntity;
3538
import org.springframework.http.HttpHeaders;
3639
import org.springframework.http.HttpOutputMessage;
40+
import org.springframework.http.HttpRange;
41+
import org.springframework.http.HttpStatus;
3742
import org.springframework.http.MediaType;
3843
import org.springframework.http.converter.GenericHttpMessageConverter;
3944
import org.springframework.http.converter.HttpMessageConverter;
@@ -75,6 +80,9 @@ public abstract class AbstractMessageConverterMethodProcessor extends AbstractMe
7580

7681
private static final MediaType MEDIA_TYPE_APPLICATION = new MediaType("application");
7782

83+
private static final Type RESOURCE_REGION_LIST_TYPE =
84+
new ParameterizedTypeReference<List<ResourceRegion>>() { }.getType();
85+
7886

7987
private static final UrlPathHelper decodingUrlPathHelper = new UrlPathHelper();
8088

@@ -183,6 +191,24 @@ protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter
183191
valueType = getReturnValueType(outputValue, returnType);
184192
declaredType = getGenericType(returnType);
185193
}
194+
195+
if (isResourceType(value, returnType)) {
196+
outputMessage.getHeaders().set(HttpHeaders.ACCEPT_RANGES, "bytes");
197+
if (value != null && inputMessage.getHeaders().getFirst(HttpHeaders.RANGE) != null) {
198+
Resource resource = (Resource) value;
199+
try {
200+
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
201+
outputMessage.getServletResponse().setStatus(HttpStatus.PARTIAL_CONTENT.value());
202+
outputValue = HttpRange.toResourceRegions(httpRanges, resource);
203+
valueType = outputValue.getClass();
204+
declaredType = RESOURCE_REGION_LIST_TYPE;
205+
}
206+
catch (IllegalArgumentException ex) {
207+
outputMessage.getHeaders().set(HttpHeaders.CONTENT_RANGE, "bytes */" + resource.contentLength());
208+
outputMessage.getServletResponse().setStatus(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE.value());
209+
}
210+
}
211+
}
186212

187213
HttpServletRequest request = inputMessage.getServletRequest();
188214
List<MediaType> requestedMediaTypes = getAcceptableMediaTypes(request);
@@ -266,6 +292,13 @@ protected Class<?> getReturnValueType(@Nullable Object value, MethodParameter re
266292
return (value != null ? value.getClass() : returnType.getParameterType());
267293
}
268294

295+
/**
296+
* Return whether the returned value or the declared return type extend {@link Resource}
297+
*/
298+
protected boolean isResourceType(@Nullable Object value, MethodParameter returnType) {
299+
return Resource.class.isAssignableFrom(value != null ? value.getClass() : returnType.getParameterType());
300+
}
301+
269302
/**
270303
* Return the generic type of the {@code returnType} (or of the nested type
271304
* if it is an {@link HttpEntity}).

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

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -387,14 +387,8 @@ public void handleRequest(HttpServletRequest request, HttpServletResponse respon
387387
try {
388388
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
389389
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
390-
if (httpRanges.size() == 1) {
391-
ResourceRegion resourceRegion = httpRanges.get(0).toResourceRegion(resource);
392-
this.resourceRegionHttpMessageConverter.write(resourceRegion, mediaType, outputMessage);
393-
}
394-
else {
395390
this.resourceRegionHttpMessageConverter.write(
396391
HttpRange.toResourceRegions(httpRanges, resource), mediaType, outputMessage);
397-
}
398392
}
399393
catch (IllegalArgumentException ex) {
400394
response.setHeader("Content-Range", "bytes */" + resource.contentLength());

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ public void requestMappingHandlerAdapter() throws Exception {
175175
ApplicationContext context = initContext(WebConfig.class);
176176
RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class);
177177
List<HttpMessageConverter<?>> converters = adapter.getMessageConverters();
178-
assertEquals(11, converters.size());
178+
assertEquals(12, converters.size());
179179
converters.stream()
180180
.filter(converter -> converter instanceof AbstractJackson2HttpMessageConverter)
181181
.forEach(converter -> {

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

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,9 @@
2020
import java.net.URI;
2121
import java.nio.charset.StandardCharsets;
2222
import java.time.ZoneId;
23-
import java.util.ArrayList;
23+
import java.util.Arrays;
2424
import java.util.Collections;
2525
import java.util.Date;
26-
import java.util.List;
2726

2827
import org.junit.Before;
2928
import org.junit.Rule;
@@ -82,6 +81,8 @@ public class HttpEntityMethodProcessorMockTests {
8281

8382
private HttpMessageConverter<Resource> resourceMessageConverter;
8483

84+
private HttpMessageConverter<Object> resourceRegionMessageConverter;
85+
8586
private MethodParameter paramHttpEntity;
8687

8788
private MethodParameter paramRequestEntity;
@@ -119,12 +120,11 @@ public void setup() throws Exception {
119120
given(stringHttpMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN));
120121
resourceMessageConverter = mock(HttpMessageConverter.class);
121122
given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
122-
List<HttpMessageConverter<?>> converters = new ArrayList<>();
123-
converters.add(stringHttpMessageConverter);
124-
converters.add(resourceMessageConverter);
125-
processor = new HttpEntityMethodProcessor(converters);
126-
reset(stringHttpMessageConverter);
127-
reset(resourceMessageConverter);
123+
resourceRegionMessageConverter = mock(HttpMessageConverter.class);
124+
given(resourceRegionMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
125+
126+
processor = new HttpEntityMethodProcessor(
127+
Arrays.asList(stringHttpMessageConverter, resourceMessageConverter, resourceRegionMessageConverter));
128128

129129
Method handle1 = getClass().getMethod("handle1", HttpEntity.class, ResponseEntity.class,
130130
Integer.TYPE, RequestEntity.class);
@@ -497,6 +497,39 @@ public void shouldHandleResource() throws Exception {
497497
assertEquals(200, servletResponse.getStatus());
498498
}
499499

500+
@Test
501+
public void shouldHandleResourceByteRange() throws Exception {
502+
ResponseEntity<Resource> returnValue = ResponseEntity
503+
.ok(new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8)));
504+
servletRequest.addHeader("Range", "bytes=0-5");
505+
506+
given(resourceRegionMessageConverter.canWrite(any(), eq(null))).willReturn(true);
507+
given(resourceRegionMessageConverter.canWrite(any(), eq(MediaType.APPLICATION_OCTET_STREAM))).willReturn(true);
508+
509+
processor.handleReturnValue(returnValue, returnTypeResponseEntityResource, mavContainer, webRequest);
510+
511+
then(resourceRegionMessageConverter).should(times(1)).write(
512+
anyCollection(), eq(MediaType.APPLICATION_OCTET_STREAM),
513+
argThat(outputMessage -> outputMessage.getHeaders().getFirst(HttpHeaders.ACCEPT_RANGES) == "bytes"));
514+
assertEquals(206, servletResponse.getStatus());
515+
}
516+
517+
@Test
518+
public void handleReturnTypeResourceIllegalByteRange() throws Exception {
519+
ResponseEntity<Resource> returnValue = ResponseEntity
520+
.ok(new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8)));
521+
servletRequest.addHeader("Range", "illegal");
522+
523+
given(resourceRegionMessageConverter.canWrite(any(), eq(null))).willReturn(true);
524+
given(resourceRegionMessageConverter.canWrite(any(), eq(MediaType.APPLICATION_OCTET_STREAM))).willReturn(true);
525+
526+
processor.handleReturnValue(returnValue, returnTypeResponseEntityResource, mavContainer, webRequest);
527+
528+
then(resourceRegionMessageConverter).should(never()).write(
529+
anyCollection(), eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class));
530+
assertEquals(416, servletResponse.getStatus());
531+
}
532+
500533
@Test //SPR-14767
501534
public void shouldHandleValidatorHeadersInPutResponses() throws Exception {
502535
servletRequest.setMethod("PUT");

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.Collections;
2323
import java.util.List;
2424
import java.util.Optional;
25+
2526
import javax.validation.Valid;
2627
import javax.validation.constraints.NotNull;
2728

@@ -31,6 +32,7 @@
3132
import org.springframework.core.MethodParameter;
3233
import org.springframework.core.io.ByteArrayResource;
3334
import org.springframework.core.io.Resource;
35+
import org.springframework.http.HttpHeaders;
3436
import org.springframework.http.HttpInputMessage;
3537
import org.springframework.http.HttpOutputMessage;
3638
import org.springframework.http.MediaType;
@@ -71,6 +73,8 @@ public class RequestResponseBodyMethodProcessorMockTests {
7173

7274
private HttpMessageConverter<Resource> resourceMessageConverter;
7375

76+
private HttpMessageConverter<Object> resourceRegionMessageConverter;
77+
7478
private RequestResponseBodyMethodProcessor processor;
7579

7680
private ModelAndViewContainer mavContainer;
@@ -97,11 +101,13 @@ public class RequestResponseBodyMethodProcessorMockTests {
97101
public void setup() throws Exception {
98102
stringMessageConverter = mock(HttpMessageConverter.class);
99103
given(stringMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN));
100-
101104
resourceMessageConverter = mock(HttpMessageConverter.class);
102105
given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
106+
resourceRegionMessageConverter = mock(HttpMessageConverter.class);
107+
given(resourceRegionMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL));
103108

104-
processor = new RequestResponseBodyMethodProcessor(Arrays.asList(stringMessageConverter, resourceMessageConverter));
109+
processor = new RequestResponseBodyMethodProcessor(
110+
Arrays.asList(stringMessageConverter, resourceMessageConverter, resourceRegionMessageConverter));
105111

106112
mavContainer = new ModelAndViewContainer();
107113
servletRequest = new MockHttpServletRequest();
@@ -364,6 +370,37 @@ public void handleReturnValueMediaTypeSuffix() throws Exception {
364370
verify(stringMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class));
365371
}
366372

373+
@Test
374+
public void handleReturnTypeResourceByteRange() throws Exception {
375+
Resource returnValue = new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8));
376+
servletRequest.addHeader("Range", "bytes=0-5");
377+
378+
given(resourceRegionMessageConverter.canWrite(any(), eq(null))).willReturn(true);
379+
given(resourceRegionMessageConverter.canWrite(any(), eq(MediaType.APPLICATION_OCTET_STREAM))).willReturn(true);
380+
381+
processor.handleReturnValue(returnValue, returnTypeResource, mavContainer, webRequest);
382+
383+
then(resourceRegionMessageConverter).should(times(1)).write(
384+
anyCollection(), eq(MediaType.APPLICATION_OCTET_STREAM),
385+
argThat(outputMessage -> outputMessage.getHeaders().getFirst(HttpHeaders.ACCEPT_RANGES) == "bytes"));
386+
assertEquals(206, servletResponse.getStatus());
387+
}
388+
389+
@Test
390+
public void handleReturnTypeResourceIllegalByteRange() throws Exception {
391+
Resource returnValue = new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8));
392+
servletRequest.addHeader("Range", "illegal");
393+
394+
given(resourceRegionMessageConverter.canWrite(any(), eq(null))).willReturn(true);
395+
given(resourceRegionMessageConverter.canWrite(any(), eq(MediaType.APPLICATION_OCTET_STREAM))).willReturn(true);
396+
397+
processor.handleReturnValue(returnValue, returnTypeResource, mavContainer, webRequest);
398+
399+
then(resourceRegionMessageConverter).should(never()).write(
400+
anyCollection(), eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class));
401+
assertEquals(416, servletResponse.getStatus());
402+
}
403+
367404

368405
@SuppressWarnings("unused")
369406
@ResponseBody

0 commit comments

Comments
 (0)