diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java index c95529eddff..3dd375ac827 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/AnnotationDrivenBeanDefinitionParser.java @@ -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)); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java index 053418f0b48..ecb0ab9ec20 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupport.java @@ -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()); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java index deaf2b823c8..4ea84aac4aa 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java @@ -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>() { }.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 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 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}). diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java index a705f57fa0a..b96eda1e02d 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandler.java @@ -387,14 +387,8 @@ public class ResourceHttpRequestHandler extends WebContentGenerator try { List 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()); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java index e1a53aba14e..2fef1bd9f71 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/config/annotation/WebMvcConfigurationSupportTests.java @@ -175,7 +175,7 @@ public class WebMvcConfigurationSupportTests { ApplicationContext context = initContext(WebConfig.class); RequestMappingHandlerAdapter adapter = context.getBean(RequestMappingHandlerAdapter.class); List> converters = adapter.getMessageConverters(); - assertEquals(11, converters.size()); + assertEquals(12, converters.size()); converters.stream() .filter(converter -> converter instanceof AbstractJackson2HttpMessageConverter) .forEach(converter -> { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java index 1aeba811aac..2a479f407a8 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessorMockTests.java @@ -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 resourceMessageConverter; + private HttpMessageConverter 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> 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 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 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"); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorMockTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorMockTests.java index 5095b412559..d8251c32d5c 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorMockTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessorMockTests.java @@ -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 resourceMessageConverter; + private HttpMessageConverter 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