Support HTTP range requests in Controllers

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

This commit improves the ResourceHttpMessageConverter that
now supports partial writes of Resources.
For this, the `HttpEntityMethodProcessor` and
`RequestResponseBodyMethodProcessor` now wrap resources with HTTP
range information in a `HttpRangeResource`, if necessary. The
message converter handle those types and knows how to handle 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-13834
This commit is contained in:
Brian Clozel
2016-03-22 19:06:36 +01:00
parent 15fe8279e6
commit c7bd3b8440
10 changed files with 700 additions and 327 deletions

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2002-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.http;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URL;
import java.util.List;
import org.springframework.core.io.Resource;
import org.springframework.util.Assert;
/**
* Holder that combines a {@link Resource} descriptor with
* {@link HttpRange} information to be used for reading
* selected parts of the resource.
*
* <p>Used as an argument for partial conversion operations in
* {@link org.springframework.http.converter.ResourceHttpMessageConverter}.
*
* @author Brian Clozel
* @since 4.3.0
*/
public class HttpRangeResource implements Resource {
private final List<HttpRange> httpRanges;
private final Resource resource;
public HttpRangeResource(List<HttpRange> httpRanges, Resource resource) {
Assert.notEmpty(httpRanges, "list of HTTP Ranges should not be empty");
this.httpRanges = httpRanges;
this.resource = resource;
}
/**
* Return the list of HTTP (byte) ranges describing the requested
* parts of the Resource, as provided by the HTTP Range request.
*/
public final List<HttpRange> getHttpRanges() {
return httpRanges;
}
@Override
public boolean exists() {
return resource.exists();
}
@Override
public boolean isReadable() {
return resource.isReadable();
}
@Override
public boolean isOpen() {
return resource.isOpen();
}
@Override
public URL getURL() throws IOException {
return resource.getURL();
}
@Override
public URI getURI() throws IOException {
return resource.getURI();
}
@Override
public File getFile() throws IOException {
return resource.getFile();
}
@Override
public long contentLength() throws IOException {
return resource.contentLength();
}
@Override
public long lastModified() throws IOException {
return resource.lastModified();
}
@Override
public Resource createRelative(String relativePath) throws IOException {
return resource.createRelative(relativePath);
}
@Override
public String getFilename() {
return resource.getFilename();
}
@Override
public String getDescription() {
return resource.getDescription();
}
@Override
public InputStream getInputStream() throws IOException {
return resource.getInputStream();
}
}

View File

@@ -16,8 +16,12 @@
package org.springframework.http.converter;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.List;
import javax.activation.FileTypeMap;
import javax.activation.MimetypesFileTypeMap;
@@ -25,23 +29,33 @@ import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.InputStreamResource;
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.HttpRange;
import org.springframework.http.HttpRangeResource;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.util.ClassUtils;
import org.springframework.util.MimeTypeUtils;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
/**
* Implementation of {@link HttpMessageConverter} that can read and write {@link Resource Resources}.
* Implementation of {@link HttpMessageConverter} that can read and write {@link Resource Resources}
* and supports byte range requests.
*
* <p>By default, this converter can read all media types. The Java Activation Framework (JAF) -
* if available - is used to determine the {@code Content-Type} of written resources.
* If JAF is not available, {@code application/octet-stream} is used.
*
* <p>This converter supports HTTP byte range requests and can write partial content, when provided
* with an {@link HttpRangeResource} instance containing the required Range information.
*
* @author Arjen Poutsma
* @author Juergen Hoeller
* @author Kazuki Shimizu
* @author Brian Clozel
* @since 3.0.2
*/
public class ResourceHttpMessageConverter extends AbstractHttpMessageConverter<Resource> {
@@ -64,7 +78,7 @@ public class ResourceHttpMessageConverter extends AbstractHttpMessageConverter<R
protected Resource readInternal(Class<? extends Resource> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
if (InputStreamResource.class == clazz){
if (InputStreamResource.class == clazz) {
return new InputStreamResource(inputMessage.getBody());
}
else if (clazz.isAssignableFrom(ByteArrayResource.class)) {
@@ -94,6 +108,9 @@ public class ResourceHttpMessageConverter extends AbstractHttpMessageConverter<R
return null;
}
long contentLength = resource.contentLength();
if (contentLength > Integer.MAX_VALUE) {
throw new IOException("Resource content too long (beyond Integer.MAX_VALUE): " + resource);
}
return (contentLength < 0 ? null : contentLength);
}
@@ -101,18 +118,140 @@ public class ResourceHttpMessageConverter extends AbstractHttpMessageConverter<R
protected void writeInternal(Resource resource, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
InputStream in = resource.getInputStream();
outputMessage.getHeaders().add(HttpHeaders.ACCEPT_RANGES, "bytes");
if (resource instanceof HttpRangeResource) {
writePartialContent((HttpRangeResource) resource, outputMessage);
}
else {
writeContent(resource, outputMessage);
}
}
protected void writeContent(Resource resource, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException {
try {
StreamUtils.copy(in, outputMessage.getBody());
}
finally {
InputStream in = resource.getInputStream();
try {
in.close();
StreamUtils.copy(in, outputMessage.getBody());
}
catch (IOException ex) {
catch (NullPointerException ex) {
// ignore, see SPR-13620
}
finally {
try {
in.close();
}
catch (Throwable ex) {
// ignore, see SPR-12999
}
}
}
catch (FileNotFoundException ex) {
// ignore, see SPR-12999
}
}
/**
* Write parts of the resource as indicated by the request {@code Range} header.
* @param resource the identified resource (never {@code null})
* @param outputMessage current servlet response
* @throws IOException in case of errors while writing the content
*/
protected void writePartialContent(HttpRangeResource resource, HttpOutputMessage outputMessage) throws IOException {
Assert.notNull(resource, "Resource should not be null");
List<HttpRange> ranges = resource.getHttpRanges();
HttpHeaders responseHeaders = outputMessage.getHeaders();
MediaType contentType = responseHeaders.getContentType();
Long length = getContentLength(resource, contentType);
if (ranges.size() == 1) {
HttpRange range = ranges.get(0);
long start = range.getRangeStart(length);
long end = range.getRangeEnd(length);
long rangeLength = end - start + 1;
responseHeaders.add("Content-Range", "bytes " + start + "-" + end + "/" + length);
responseHeaders.setContentLength((int) rangeLength);
InputStream in = resource.getInputStream();
try {
copyRange(in, outputMessage.getBody(), start, end);
}
finally {
try {
in.close();
}
catch (IOException ex) {
// ignore
}
}
}
else {
String boundaryString = MimeTypeUtils.generateMultipartBoundaryString();
responseHeaders.set(HttpHeaders.CONTENT_TYPE, "multipart/byteranges; boundary=" + boundaryString);
OutputStream out = outputMessage.getBody();
for (HttpRange range : ranges) {
long start = range.getRangeStart(length);
long end = range.getRangeEnd(length);
InputStream in = resource.getInputStream();
// Writing MIME header.
println(out);
print(out, "--" + boundaryString);
println(out);
if (contentType != null) {
print(out, "Content-Type: " + contentType.toString());
println(out);
}
print(out, "Content-Range: bytes " + start + "-" + end + "/" + length);
println(out);
println(out);
// Printing content
copyRange(in, out, start, end);
}
println(out);
print(out, "--" + boundaryString + "--");
}
}
private static void println(OutputStream os) throws IOException {
os.write('\r');
os.write('\n');
}
private static void print(OutputStream os, String buf) throws IOException {
os.write(buf.getBytes("US-ASCII"));
}
private void copyRange(InputStream in, OutputStream out, long start, long end) throws IOException {
long skipped = in.skip(start);
if (skipped < start) {
throw new IOException("Skipped only " + skipped + " bytes out of " + start + " required.");
}
long bytesToCopy = end - start + 1;
byte buffer[] = new byte[StreamUtils.BUFFER_SIZE];
while (bytesToCopy > 0) {
int bytesRead = in.read(buffer);
if (bytesRead <= bytesToCopy) {
out.write(buffer, 0, bytesRead);
bytesToCopy -= bytesRead;
}
else {
out.write(buffer, 0, (int) bytesToCopy);
bytesToCopy = 0;
}
if (bytesRead == -1) {
break;
}
}
outputMessage.getBody().flush();
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2015 the original author or authors.
* Copyright 2002-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,28 +16,41 @@
package org.springframework.http.converter;
import static org.hamcrest.core.Is.*;
import static org.hamcrest.core.IsInstanceOf.*;
import static org.junit.Assert.*;
import static org.mockito.BDDMockito.*;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.List;
import org.hamcrest.Matchers;
import org.junit.Test;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.InputStreamResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpRange;
import org.springframework.http.HttpRangeResource;
import org.springframework.http.MediaType;
import org.springframework.http.MockHttpInputMessage;
import org.springframework.http.MockHttpOutputMessage;
import org.springframework.util.FileCopyUtils;
import static org.hamcrest.core.Is.*;
import static org.hamcrest.core.IsInstanceOf.*;
import static org.junit.Assert.*;
import org.springframework.util.StringUtils;
/**
* @author Arjen Poutsma
* @author Kazuki Shimizu
* @author Brian Clozel
*/
public class ResourceHttpMessageConverterTests {
@@ -45,18 +58,18 @@ public class ResourceHttpMessageConverterTests {
@Test
public void canRead() {
public void canReadResource() {
assertTrue(converter.canRead(Resource.class, new MediaType("application", "octet-stream")));
}
@Test
public void canWrite() {
public void canWriteResource() {
assertTrue(converter.canWrite(Resource.class, new MediaType("application", "octet-stream")));
assertTrue(converter.canWrite(Resource.class, MediaType.ALL));
}
@Test
public void read() throws IOException {
public void shouldReadImageResource() throws IOException {
byte[] body = FileCopyUtils.copyToByteArray(getClass().getResourceAsStream("logo.jpg"));
MockHttpInputMessage inputMessage = new MockHttpInputMessage(body);
inputMessage.getHeaders().setContentType(MediaType.IMAGE_JPEG);
@@ -65,7 +78,7 @@ public class ResourceHttpMessageConverterTests {
}
@Test // SPR-13443
public void readWithInputStreamResource() throws IOException {
public void shouldReadInputStreamResource() throws IOException {
try (InputStream body = getClass().getResourceAsStream("logo.jpg") ) {
MockHttpInputMessage inputMessage = new MockHttpInputMessage(body);
inputMessage.getHeaders().setContentType(MediaType.IMAGE_JPEG);
@@ -76,7 +89,7 @@ public class ResourceHttpMessageConverterTests {
}
@Test
public void write() throws IOException {
public void shouldWriteImageResource() throws IOException {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource body = new ClassPathResource("logo.jpg", getClass());
converter.write(body, null, outputMessage);
@@ -85,6 +98,116 @@ public class ResourceHttpMessageConverterTests {
assertEquals("Invalid content-length", body.getFile().length(), outputMessage.getHeaders().getContentLength());
}
@Test
public void shouldWritePartialContentByteRange() throws Exception {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource body = new ClassPathResource("byterangeresource.txt", getClass());
List<HttpRange> httpRangeList = HttpRange.parseRanges("bytes=0-5");
converter.write(new HttpRangeResource(httpRangeList, body), MediaType.TEXT_PLAIN, outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
assertThat(headers.getContentType(), is(MediaType.TEXT_PLAIN));
assertThat(headers.getContentLength(), is(6L));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).size(), is(1));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).get(0), is("bytes 0-5/39"));
assertThat(outputMessage.getBodyAsString(Charset.forName("UTF-8")), is("Spring"));
}
@Test
public void shouldWritePartialContentByteRangeNoEnd() throws Exception {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource body = new ClassPathResource("byterangeresource.txt", getClass());
List<HttpRange> httpRangeList = HttpRange.parseRanges("bytes=7-");
converter.write(new HttpRangeResource(httpRangeList, body), MediaType.TEXT_PLAIN, outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
assertThat(headers.getContentType(), is(MediaType.TEXT_PLAIN));
assertThat(headers.getContentLength(), is(32L));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).size(), is(1));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).get(0), is("bytes 7-38/39"));
assertThat(outputMessage.getBodyAsString(Charset.forName("UTF-8")), is("Framework test resource content."));
}
@Test
public void shouldWritePartialContentByteRangeLargeEnd() throws Exception {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource body = new ClassPathResource("byterangeresource.txt", getClass());
List<HttpRange> httpRangeList = HttpRange.parseRanges("bytes=7-10000");
converter.write(new HttpRangeResource(httpRangeList, body), MediaType.TEXT_PLAIN, outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
assertThat(headers.getContentType(), is(MediaType.TEXT_PLAIN));
assertThat(headers.getContentLength(), is(32L));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).size(), is(1));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).get(0), is("bytes 7-38/39"));
assertThat(outputMessage.getBodyAsString(Charset.forName("UTF-8")), is("Framework test resource content."));
}
@Test
public void shouldWritePartialContentSuffixRange() throws Exception {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource body = new ClassPathResource("byterangeresource.txt", getClass());
List<HttpRange> httpRangeList = HttpRange.parseRanges("bytes=-8");
converter.write(new HttpRangeResource(httpRangeList, body), MediaType.TEXT_PLAIN, outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
assertThat(headers.getContentType(), is(MediaType.TEXT_PLAIN));
assertThat(headers.getContentLength(), is(8L));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).size(), is(1));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).get(0), is("bytes 31-38/39"));
assertThat(outputMessage.getBodyAsString(Charset.forName("UTF-8")), is("content."));
}
@Test
public void shouldWritePartialContentSuffixRangeLargeSuffix() throws Exception {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource body = new ClassPathResource("byterangeresource.txt", getClass());
List<HttpRange> httpRangeList = HttpRange.parseRanges("bytes=-50");
converter.write(new HttpRangeResource(httpRangeList, body), MediaType.TEXT_PLAIN, outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
assertThat(headers.getContentType(), is(MediaType.TEXT_PLAIN));
assertThat(headers.getContentLength(), is(39L));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).size(), is(1));
assertThat(headers.get(HttpHeaders.CONTENT_RANGE).get(0), is("bytes 0-38/39"));
assertThat(outputMessage.getBodyAsString(Charset.forName("UTF-8")), is("Spring Framework test resource content."));
}
@Test
public void partialContentMultipleByteRanges() throws Exception {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource body = new ClassPathResource("byterangeresource.txt", getClass());
List<HttpRange> httpRangeList = HttpRange.parseRanges("bytes=0-5, 7-15, 17-20");
converter.write(new HttpRangeResource(httpRangeList, body), MediaType.TEXT_PLAIN, outputMessage);
HttpHeaders headers = outputMessage.getHeaders();
assertThat(headers.getContentType().toString(), Matchers.startsWith("multipart/byteranges;boundary="));
String boundary = "--" + headers.getContentType().toString().substring(30);
String content = outputMessage.getBodyAsString(Charset.forName("UTF-8"));
String[] ranges = StringUtils.tokenizeToStringArray(content, "\r\n", false, true);
assertThat(ranges[0], is(boundary));
assertThat(ranges[1], is("Content-Type: text/plain"));
assertThat(ranges[2], is("Content-Range: bytes 0-5/39"));
assertThat(ranges[3], is("Spring"));
assertThat(ranges[4], is(boundary));
assertThat(ranges[5], is("Content-Type: text/plain"));
assertThat(ranges[6], is("Content-Range: bytes 7-15/39"));
assertThat(ranges[7], is("Framework"));
assertThat(ranges[8], is(boundary));
assertThat(ranges[9], is("Content-Type: text/plain"));
assertThat(ranges[10], is("Content-Range: bytes 17-20/39"));
assertThat(ranges[11], is("test"));
}
@Test // SPR-10848
public void writeByteArrayNullMediaType() throws IOException {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
@@ -94,4 +217,45 @@ public class ResourceHttpMessageConverterTests {
assertTrue(Arrays.equals(byteArray, outputMessage.getBodyAsBytes()));
}
// SPR-12999
@Test @SuppressWarnings("unchecked")
public void writeContentNotGettingInputStream() throws Exception {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource resource = mock(Resource.class);
given(resource.getInputStream()).willThrow(FileNotFoundException.class);
converter.write(resource, MediaType.APPLICATION_OCTET_STREAM, outputMessage);
assertEquals(0, outputMessage.getHeaders().getContentLength());
}
// SPR-12999
@Test
public void writeContentNotClosingInputStream() throws Exception {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource resource = mock(Resource.class);
InputStream inputStream = mock(InputStream.class);
given(resource.getInputStream()).willReturn(inputStream);
given(inputStream.read(any())).willReturn(-1);
doThrow(new NullPointerException()).when(inputStream).close();
converter.write(resource, MediaType.APPLICATION_OCTET_STREAM, outputMessage);
assertEquals(0, outputMessage.getHeaders().getContentLength());
}
// SPR-13620
@Test @SuppressWarnings("unchecked")
public void writeContentInputStreamThrowingNullPointerException() throws Exception {
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
Resource resource = mock(Resource.class);
InputStream in = mock(InputStream.class);
given(resource.getInputStream()).willReturn(in);
given(in.read(any())).willThrow(NullPointerException.class);
converter.write(resource, MediaType.APPLICATION_OCTET_STREAM, outputMessage);
assertEquals(0, outputMessage.getHeaders().getContentLength());
}
}

View File

@@ -0,0 +1 @@
Spring Framework test resource content.