diff --git a/spring-web/src/main/java/org/springframework/http/HttpRangeResource.java b/spring-web/src/main/java/org/springframework/http/HttpRangeResource.java new file mode 100644 index 0000000000..0e209a63ff --- /dev/null +++ b/spring-web/src/main/java/org/springframework/http/HttpRangeResource.java @@ -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. + * + *

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 httpRanges; + + private final Resource resource; + + public HttpRangeResource(List 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 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(); + } +} diff --git a/spring-web/src/main/java/org/springframework/http/converter/ResourceHttpMessageConverter.java b/spring-web/src/main/java/org/springframework/http/converter/ResourceHttpMessageConverter.java index 3d7db2fba5..f7c9fe0279 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/ResourceHttpMessageConverter.java +++ b/spring-web/src/main/java/org/springframework/http/converter/ResourceHttpMessageConverter.java @@ -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. * *

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. * + *

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 { @@ -64,7 +78,7 @@ public class ResourceHttpMessageConverter extends AbstractHttpMessageConverter 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 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 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(); } diff --git a/spring-web/src/test/java/org/springframework/http/converter/ResourceHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/ResourceHttpMessageConverterTests.java index 10752f22ea..b94508af62 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/ResourceHttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/ResourceHttpMessageConverterTests.java @@ -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 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 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 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 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 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 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()); + } + } diff --git a/spring-web/src/test/resources/org/springframework/http/converter/byterangeresource.txt b/spring-web/src/test/resources/org/springframework/http/converter/byterangeresource.txt new file mode 100644 index 0000000000..84bbb9ddaf --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/converter/byterangeresource.txt @@ -0,0 +1 @@ +Spring Framework test resource content. \ No newline at end of file diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java index a169dc795d..8aa17b53ec 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/HttpEntityMethodProcessor.java @@ -26,9 +26,12 @@ import java.util.Map; import org.springframework.core.MethodParameter; import org.springframework.core.ResolvableType; +import org.springframework.core.io.Resource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpRange; +import org.springframework.http.HttpRangeResource; import org.springframework.http.HttpStatus; import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; @@ -194,6 +197,19 @@ public class HttpEntityMethodProcessor extends AbstractMessageConverterMethodPro return; } } + if(inputMessage.getHeaders().containsKey(HttpHeaders.RANGE) && + Resource.class.isAssignableFrom(body.getClass())) { + try { + List httpRanges = inputMessage.getHeaders().getRange(); + Resource bodyResource = (Resource) body; + body = new HttpRangeResource(httpRanges, bodyResource); + outputMessage.setStatusCode(HttpStatus.PARTIAL_CONTENT); + } catch (IllegalArgumentException exc) { + outputMessage.setStatusCode(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + outputMessage.flush(); + return; + } + } // Try even with null body. ResponseBodyAdvice could get involved. writeWithMessageConverters(body, returnType, inputMessage, outputMessage); diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java index b69911a832..5780b2d11c 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestResponseBodyMethodProcessor.java @@ -24,10 +24,16 @@ import javax.servlet.http.HttpServletRequest; import org.springframework.core.Conventions; import org.springframework.core.MethodParameter; import org.springframework.core.annotation.AnnotationUtils; +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.HttpStatus; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.http.converter.HttpMessageNotWritableException; import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; import org.springframework.validation.BindingResult; import org.springframework.web.HttpMediaTypeNotAcceptableException; import org.springframework.web.HttpMediaTypeNotSupportedException; @@ -164,9 +170,25 @@ public class RequestResponseBodyMethodProcessor extends AbstractMessageConverter throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException { mavContainer.setRequestHandled(true); + ServletServerHttpRequest inputMessage = createInputMessage(webRequest); + ServletServerHttpResponse outputMessage = createOutputMessage(webRequest); + + if(inputMessage.getHeaders().containsKey(HttpHeaders.RANGE) && + Resource.class.isAssignableFrom(returnValue.getClass())) { + try { + List httpRanges = inputMessage.getHeaders().getRange(); + Resource bodyResource = (Resource) returnValue; + returnValue = new HttpRangeResource(httpRanges, bodyResource); + outputMessage.setStatusCode(HttpStatus.PARTIAL_CONTENT); + } catch (IllegalArgumentException exc) { + outputMessage.setStatusCode(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE); + outputMessage.flush(); + return; + } + } // Try even with null return value. ResponseBodyAdvice could get involved. - writeWithMessageConverters(returnValue, returnType, webRequest); + writeWithMessageConverters(returnValue, returnType, inputMessage, outputMessage); } } 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 b13d7fbc32..ae195b02d4 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 @@ -16,15 +16,12 @@ package org.springframework.web.servlet.resource; -import java.io.FileNotFoundException; import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; import java.net.URLDecoder; import java.util.ArrayList; import java.util.List; + import javax.servlet.ServletException; -import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -36,15 +33,15 @@ import org.springframework.core.io.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpRange; +import org.springframework.http.HttpRangeResource; import org.springframework.http.MediaType; +import org.springframework.http.converter.ResourceHttpMessageConverter; import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; import org.springframework.util.CollectionUtils; -import org.springframework.util.MimeTypeUtils; import org.springframework.util.ObjectUtils; import org.springframework.util.ResourceUtils; -import org.springframework.util.StreamUtils; import org.springframework.util.StringUtils; import org.springframework.web.HttpMediaTypeNotAcceptableException; import org.springframework.web.HttpRequestHandler; @@ -94,16 +91,14 @@ public class ResourceHttpRequestHandler extends WebContentGenerator private static final Log logger = LogFactory.getLog(ResourceHttpRequestHandler.class); - private static final boolean jafPresent = ClassUtils.isPresent( - "javax.activation.FileTypeMap", ResourceHttpRequestHandler.class.getClassLoader()); - - private final List locations = new ArrayList(4); private final List resourceResolvers = new ArrayList(4); private final List resourceTransformers = new ArrayList(4); + private ResourceHttpMessageConverter resourceHttpMessageConverter; + private ContentNegotiationManager contentNegotiationManager; private CorsConfiguration corsConfiguration; @@ -165,6 +160,20 @@ public class ResourceHttpRequestHandler extends WebContentGenerator return this.resourceTransformers; } + /** + * Configure the {@link ResourceHttpMessageConverter} to use. + *

By default a {@link ResourceHttpMessageConverter} will be configured. + * @since 4.3.0 + */ + public void setResourceHttpMessageConverter(ResourceHttpMessageConverter resourceHttpMessageConverter) { + this.resourceHttpMessageConverter = resourceHttpMessageConverter; + } + + public ResourceHttpMessageConverter getResourceHttpMessageConverter() { + return resourceHttpMessageConverter; + } + + /** * Configure a {@code ContentNegotiationManager} to determine the media types * for resources being served. If the manager contains a path @@ -177,7 +186,7 @@ public class ResourceHttpRequestHandler extends WebContentGenerator * settings is used to create the manager. See the Javadoc of * {@code ContentNegotiationManagerFactoryBean} for details * @param contentNegotiationManager the manager to use - * @since 4.3 + * @since 4.3.0 */ public void setContentNegotiationManager(ContentNegotiationManager contentNegotiationManager) { this.contentNegotiationManager = contentNegotiationManager; @@ -215,6 +224,9 @@ public class ResourceHttpRequestHandler extends WebContentGenerator if (this.contentNegotiationManager == null) { this.contentNegotiationManager = initContentNegotiationManager(); } + if( this.resourceHttpMessageConverter == null) { + this.resourceHttpMessageConverter = new ResourceHttpMessageConverter(); + } } /** @@ -226,7 +238,7 @@ public class ResourceHttpRequestHandler extends WebContentGenerator if (CollectionUtils.isEmpty(this.locations)) { return; } - for (int i = getResourceResolvers().size()-1; i >= 0; i--) { + for (int i = getResourceResolvers().size() - 1; i >= 0; i--) { if (getResourceResolvers().get(i) instanceof PathResourceResolver) { PathResourceResolver pathResolver = (PathResourceResolver) getResourceResolvers().get(i); if (ObjectUtils.isEmpty(pathResolver.getAllowedLocations())) { @@ -310,12 +322,26 @@ public class ResourceHttpRequestHandler extends WebContentGenerator return; } + ServletServerHttpResponse outputMessage = new ServletServerHttpResponse(response); if (request.getHeader(HttpHeaders.RANGE) == null) { setHeaders(response, resource, mediaType); - writeContent(response, resource); + this.resourceHttpMessageConverter.write(resource, mediaType, outputMessage); } else { - writePartialContent(request, response, resource, mediaType); + ServletServerHttpRequest inputMessage = new ServletServerHttpRequest(request); + try { + List httpRanges = inputMessage.getHeaders().getRange(); + HttpRangeResource rangeResource = new HttpRangeResource(httpRanges, resource); + response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); + this.resourceHttpMessageConverter.write(rangeResource, mediaType, outputMessage); + } + catch (IllegalArgumentException ex) { + Long contentLength = resource.contentLength(); + if (contentLength != null) { + response.addHeader("Content-Range", "bytes */" + resource.contentLength()); + } + response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); + } } } @@ -507,140 +533,6 @@ public class ResourceHttpRequestHandler extends WebContentGenerator response.setHeader(HttpHeaders.ACCEPT_RANGES, "bytes"); } - /** - * Write the actual content out to the given servlet response, - * streaming the resource's content. - * @param response current servlet response - * @param resource the identified resource (never {@code null}) - * @throws IOException in case of errors while writing the content - */ - protected void writeContent(HttpServletResponse response, Resource resource) throws IOException { - try { - InputStream in = resource.getInputStream(); - try { - StreamUtils.copy(in, response.getOutputStream()); - } - 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 request current servlet request - * @param response current servlet response - * @param resource the identified resource (never {@code null}) - * @param contentType the content type - * @throws IOException in case of errors while writing the content - */ - protected void writePartialContent(HttpServletRequest request, HttpServletResponse response, - Resource resource, MediaType contentType) throws IOException { - - long length = resource.contentLength(); - - List ranges; - try { - HttpHeaders headers = new ServletServerHttpRequest(request).getHeaders(); - ranges = headers.getRange(); - } - catch (IllegalArgumentException ex) { - response.addHeader("Content-Range", "bytes */" + length); - response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE); - return; - } - - response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); - - if (ranges.size() == 1) { - HttpRange range = ranges.get(0); - - long start = range.getRangeStart(length); - long end = range.getRangeEnd(length); - long rangeLength = end - start + 1; - - setHeaders(response, resource, contentType); - response.addHeader("Content-Range", "bytes " + start + "-" + end + "/" + length); - response.setContentLength((int) rangeLength); - - InputStream in = resource.getInputStream(); - try { - copyRange(in, response.getOutputStream(), start, end); - } - finally { - try { - in.close(); - } - catch (IOException ex) { - // ignore - } - } - } - else { - String boundaryString = MimeTypeUtils.generateMultipartBoundaryString(); - response.setContentType("multipart/byteranges; boundary=" + boundaryString); - - ServletOutputStream out = response.getOutputStream(); - - for (HttpRange range : ranges) { - long start = range.getRangeStart(length); - long end = range.getRangeEnd(length); - - InputStream in = resource.getInputStream(); - - // Writing MIME header. - out.println(); - out.println("--" + boundaryString); - if (contentType != null) { - out.println("Content-Type: " + contentType); - } - out.println("Content-Range: bytes " + start + "-" + end + "/" + length); - out.println(); - - // Printing content - copyRange(in, out, start, end); - } - out.println(); - out.print("--" + boundaryString + "--"); - } - } - - 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; - } - } - } - - @Override public String toString() { return "ResourceHttpRequestHandler [locations=" + getLocations() + ", resolvers=" + getResourceResolvers() + "]"; 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 1dca78ed4a..8e50998299 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 @@ -21,7 +21,6 @@ import java.net.URI; import java.nio.charset.Charset; import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.List; @@ -33,11 +32,14 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; import org.springframework.core.MethodParameter; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpInputMessage; import org.springframework.http.HttpMethod; import org.springframework.http.HttpOutputMessage; +import org.springframework.http.HttpRangeResource; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.RequestEntity; @@ -55,13 +57,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; -import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.eq; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.isA; -import static org.mockito.BDDMockito.mock; -import static org.mockito.BDDMockito.reset; -import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.*; import static org.springframework.web.servlet.HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE; /** @@ -72,6 +68,7 @@ import static org.springframework.web.servlet.HandlerMapping.PRODUCIBLE_MEDIA_TY * * @author Arjen Poutsma * @author Rossen Stoyanchev + * @author Brian Clozel */ public class HttpEntityMethodProcessorMockTests { @@ -79,7 +76,9 @@ public class HttpEntityMethodProcessorMockTests { private HttpEntityMethodProcessor processor; - private HttpMessageConverter messageConverter; + private HttpMessageConverter stringHttpMessageConverter; + + private HttpMessageConverter resourceMessageConverter; private MethodParameter paramHttpEntity; private MethodParameter paramRequestEntity; @@ -87,6 +86,7 @@ public class HttpEntityMethodProcessorMockTests { private MethodParameter paramInt; private MethodParameter returnTypeResponseEntity; private MethodParameter returnTypeResponseEntityProduces; + private MethodParameter returnTypeResponseEntityResource; private MethodParameter returnTypeHttpEntity; private MethodParameter returnTypeHttpEntitySubclass; private MethodParameter returnTypeInt; @@ -106,12 +106,16 @@ public class HttpEntityMethodProcessorMockTests { dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); - messageConverter = mock(HttpMessageConverter.class); - given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + stringHttpMessageConverter = mock(HttpMessageConverter.class); + 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(messageConverter); + converters.add(stringHttpMessageConverter); + converters.add(resourceMessageConverter); processor = new HttpEntityMethodProcessor(converters); - reset(messageConverter); + reset(stringHttpMessageConverter); + reset(resourceMessageConverter); Method handle1 = getClass().getMethod("handle1", HttpEntity.class, ResponseEntity.class, Integer.TYPE, RequestEntity.class); @@ -125,6 +129,7 @@ public class HttpEntityMethodProcessorMockTests { returnTypeHttpEntity = new MethodParameter(getClass().getMethod("handle2", HttpEntity.class), -1); returnTypeHttpEntitySubclass = new MethodParameter(getClass().getMethod("handle2x", HttpEntity.class), -1); returnTypeInt = new MethodParameter(getClass().getMethod("handle3"), -1); + returnTypeResponseEntityResource = new MethodParameter(getClass().getMethod("handle5"), -1); mavContainer = new ModelAndViewContainer(); servletRequest = new MockHttpServletRequest("GET", "/foo"); @@ -159,8 +164,8 @@ public class HttpEntityMethodProcessorMockTests { servletRequest.addHeader("Content-Type", contentType.toString()); servletRequest.setContent(body.getBytes(Charset.forName("UTF-8"))); - given(messageConverter.canRead(String.class, contentType)).willReturn(true); - given(messageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(body); + given(stringHttpMessageConverter.canRead(String.class, contentType)).willReturn(true); + given(stringHttpMessageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(body); Object result = processor.resolveArgument(paramHttpEntity, mavContainer, webRequest, null); @@ -181,8 +186,8 @@ public class HttpEntityMethodProcessorMockTests { servletRequest.setRequestURI("/path"); servletRequest.setContent(body.getBytes(Charset.forName("UTF-8"))); - given(messageConverter.canRead(String.class, contentType)).willReturn(true); - given(messageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(body); + given(stringHttpMessageConverter.canRead(String.class, contentType)).willReturn(true); + given(stringHttpMessageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(body); Object result = processor.resolveArgument(paramRequestEntity, mavContainer, webRequest, null); @@ -201,8 +206,8 @@ public class HttpEntityMethodProcessorMockTests { servletRequest.setMethod("POST"); servletRequest.addHeader("Content-Type", contentType.toString()); - given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(contentType)); - given(messageConverter.canRead(String.class, contentType)).willReturn(false); + given(stringHttpMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(contentType)); + given(stringHttpMessageConverter.canRead(String.class, contentType)).willReturn(false); processor.resolveArgument(paramHttpEntity, mavContainer, webRequest, null); @@ -230,7 +235,7 @@ public class HttpEntityMethodProcessorMockTests { processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); assertTrue(mavContainer.isRequestHandled()); - verify(messageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class)); + verify(stringHttpMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class)); } @Test @@ -241,12 +246,12 @@ public class HttpEntityMethodProcessorMockTests { servletRequest.addHeader("Accept", "text/*"); servletRequest.setAttribute(PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, Collections.singleton(MediaType.TEXT_HTML)); - given(messageConverter.canWrite(String.class, MediaType.TEXT_HTML)).willReturn(true); + given(stringHttpMessageConverter.canWrite(String.class, MediaType.TEXT_HTML)).willReturn(true); processor.handleReturnValue(returnValue, returnTypeResponseEntityProduces, mavContainer, webRequest); assertTrue(mavContainer.isRequestHandled()); - verify(messageConverter).write(eq(body), eq(MediaType.TEXT_HTML), isA(HttpOutputMessage.class)); + verify(stringHttpMessageConverter).write(eq(body), eq(MediaType.TEXT_HTML), isA(HttpOutputMessage.class)); } @SuppressWarnings("unchecked") @@ -262,15 +267,15 @@ public class HttpEntityMethodProcessorMockTests { given(advice.beforeBodyWrite(any(), any(), any(), any(), any(), any())).willReturn("Foo"); HttpEntityMethodProcessor processor = new HttpEntityMethodProcessor( - Collections.singletonList(messageConverter), null, Collections.singletonList(advice)); + Collections.singletonList(stringHttpMessageConverter), null, Collections.singletonList(advice)); - reset(messageConverter); - given(messageConverter.canWrite(String.class, MediaType.TEXT_HTML)).willReturn(true); + reset(stringHttpMessageConverter); + given(stringHttpMessageConverter.canWrite(String.class, MediaType.TEXT_HTML)).willReturn(true); processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); assertTrue(mavContainer.isRequestHandled()); - verify(messageConverter).write(eq("Foo"), eq(MediaType.TEXT_HTML), isA(HttpOutputMessage.class)); + verify(stringHttpMessageConverter).write(eq("Foo"), eq(MediaType.TEXT_HTML), isA(HttpOutputMessage.class)); } @Test(expected = HttpMediaTypeNotAcceptableException.class) @@ -281,9 +286,9 @@ public class HttpEntityMethodProcessorMockTests { MediaType accepted = MediaType.APPLICATION_ATOM_XML; servletRequest.addHeader("Accept", accepted.toString()); - given(messageConverter.canWrite(String.class, null)).willReturn(true); - given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); - given(messageConverter.canWrite(String.class, accepted)).willReturn(false); + given(stringHttpMessageConverter.canWrite(String.class, null)).willReturn(true); + given(stringHttpMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(stringHttpMessageConverter.canWrite(String.class, accepted)).willReturn(false); processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); @@ -298,9 +303,9 @@ public class HttpEntityMethodProcessorMockTests { MediaType accepted = MediaType.TEXT_PLAIN; servletRequest.addHeader("Accept", accepted.toString()); - given(messageConverter.canWrite(String.class, null)).willReturn(true); - given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); - given(messageConverter.canWrite(String.class, accepted)).willReturn(false); + given(stringHttpMessageConverter.canWrite(String.class, null)).willReturn(true); + given(stringHttpMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(stringHttpMessageConverter.canWrite(String.class, accepted)).willReturn(false); processor.handleReturnValue(returnValue, returnTypeResponseEntityProduces, mavContainer, webRequest); @@ -340,7 +345,7 @@ public class HttpEntityMethodProcessorMockTests { processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); ArgumentCaptor outputMessage = ArgumentCaptor.forClass(HttpOutputMessage.class); - verify(messageConverter).write(eq("body"), eq(MediaType.TEXT_PLAIN), outputMessage.capture()); + verify(stringHttpMessageConverter).write(eq("body"), eq(MediaType.TEXT_PLAIN), outputMessage.capture()); assertTrue(mavContainer.isRequestHandled()); assertEquals("headerValue", outputMessage.getValue().getHeaders().get("header").get(0)); } @@ -519,50 +524,58 @@ public class HttpEntityMethodProcessorMockTests { } @Test - public void varyHeader() throws Exception { - String[] entityValues = {"Accept-Language", "User-Agent"}; - String[] existingValues = {}; - String[] expected = {"Accept-Language, User-Agent"}; - testVaryHeader(entityValues, existingValues, expected); + public void handleReturnTypeResource() throws Exception { + ResponseEntity returnValue = ResponseEntity + .ok(new ByteArrayResource("Content".getBytes(Charset.forName("UTF-8")))); + + given(resourceMessageConverter.canWrite(ByteArrayResource.class, null)).willReturn(true); + given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL)); + given(resourceMessageConverter.canWrite(ByteArrayResource.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(true); + + processor.handleReturnValue(returnValue, returnTypeResponseEntityResource, mavContainer, webRequest); + + then(resourceMessageConverter).should(times(1)).write(any(ByteArrayResource.class), + eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class)); + assertEquals(200, servletResponse.getStatus()); } @Test - public void varyHeaderWithExistingWildcard() throws Exception { - String[] entityValues = {"Accept-Language"}; - String[] existingValues = {"*"}; - String[] expected = {"*"}; - testVaryHeader(entityValues, existingValues, expected); + public void handleReturnTypeResourceByteRange() throws Exception { + Resource resource = new ByteArrayResource("Content".getBytes(Charset.forName("UTF-8"))); + ResponseEntity returnValue = ResponseEntity.ok(resource); + servletRequest.addHeader("Range", "bytes=0-5"); + + given(resourceMessageConverter.canWrite(HttpRangeResource.class, null)).willReturn(true); + given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL)); + given(resourceMessageConverter.canWrite(HttpRangeResource.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(true); + + processor.handleReturnValue(returnValue, returnTypeResponseEntityResource, mavContainer, webRequest); + + then(resourceMessageConverter).should(times(1)).write(any(ByteArrayResource.class), + eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class)); + assertEquals(206, servletResponse.getStatus()); } @Test - public void varyHeaderWithExistingCommaValues() throws Exception { - String[] entityValues = {"Accept-Language", "User-Agent"}; - String[] existingValues = {"Accept-Encoding", "Accept-Language"}; - String[] expected = {"Accept-Encoding", "Accept-Language", "User-Agent"}; - testVaryHeader(entityValues, existingValues, expected); - } + public void handleReturnTypeResourceIllegalByteRange() throws Exception { + Resource resource = new ByteArrayResource("Content".getBytes(Charset.forName("UTF-8"))); + ResponseEntity returnValue = ResponseEntity.ok(resource); + servletRequest.addHeader("Range", "illegal"); - @Test - public void varyHeaderWithExistingCommaSeparatedValues() throws Exception { - String[] entityValues = {"Accept-Language", "User-Agent"}; - String[] existingValues = {"Accept-Encoding, Accept-Language"}; - String[] expected = {"Accept-Encoding, Accept-Language", "User-Agent"}; - testVaryHeader(entityValues, existingValues, expected); - } + given(resourceMessageConverter.canWrite(ByteArrayResource.class, null)).willReturn(true); + given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL)); - @Test - public void handleReturnValueVaryHeader() throws Exception { - String[] entityValues = {"Accept-Language", "User-Agent"}; - String[] existingValues = {"Accept-Encoding, Accept-Language"}; - String[] expected = {"Accept-Encoding, Accept-Language", "User-Agent"}; - testVaryHeader(entityValues, existingValues, expected); - } + processor.handleReturnValue(returnValue, returnTypeResponseEntityResource, mavContainer, webRequest); + then(resourceMessageConverter).should(never()).write(any(ByteArrayResource.class), + eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class)); + assertEquals(416, servletResponse.getStatus()); + } private void initStringMessageConversion(MediaType accepted) { - given(messageConverter.canWrite(String.class, null)).willReturn(true); - given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); - given(messageConverter.canWrite(String.class, accepted)).willReturn(true); + given(stringHttpMessageConverter.canWrite(String.class, null)).willReturn(true); + given(stringHttpMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(stringHttpMessageConverter.canWrite(String.class, accepted)).willReturn(true); } private void assertResponseNotModified() { @@ -575,23 +588,9 @@ public class HttpEntityMethodProcessorMockTests { assertTrue(mavContainer.isRequestHandled()); assertEquals(HttpStatus.OK.value(), servletResponse.getStatus()); ArgumentCaptor outputMessage = ArgumentCaptor.forClass(HttpOutputMessage.class); - verify(messageConverter).write(eq(body), eq(MediaType.TEXT_PLAIN), outputMessage.capture()); + verify(stringHttpMessageConverter).write(eq(body), eq(MediaType.TEXT_PLAIN), outputMessage.capture()); } - private void testVaryHeader(String[] entityValues, String[] existingValues, String[] expected) throws Exception { - ResponseEntity returnValue = ResponseEntity.ok().varyBy(entityValues).body("Foo"); - for (String value : existingValues) { - servletResponse.addHeader("Vary", value); - } - initStringMessageConversion(MediaType.TEXT_PLAIN); - processor.handleReturnValue(returnValue, returnTypeResponseEntity, mavContainer, webRequest); - - assertTrue(mavContainer.isRequestHandled()); - assertEquals(Arrays.asList(expected), servletResponse.getHeaders("Vary")); - verify(messageConverter).write(eq("Foo"), eq(MediaType.TEXT_PLAIN), isA(HttpOutputMessage.class)); - } - - @SuppressWarnings("unused") public ResponseEntity handle1(HttpEntity httpEntity, ResponseEntity entity, int i, RequestEntity requestEntity) { @@ -620,8 +619,11 @@ public class HttpEntityMethodProcessorMockTests { return null; } + @SuppressWarnings("unused") + public ResponseEntity handle5() {return null;} + @SuppressWarnings("unused") public static class CustomHttpEntity extends HttpEntity { } -} +} \ No newline at end of file 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 8a4cef7a14..c8cddcc149 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 @@ -32,8 +32,11 @@ import org.junit.Before; 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.HttpInputMessage; import org.springframework.http.HttpOutputMessage; +import org.springframework.http.HttpRangeResource; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -65,7 +68,9 @@ public class RequestResponseBodyMethodProcessorMockTests { private RequestResponseBodyMethodProcessor processor; - private HttpMessageConverter messageConverter; + private HttpMessageConverter stringMessageConverter; + + private HttpMessageConverter resourceMessageConverter; private MethodParameter paramRequestBodyString; private MethodParameter paramInt; @@ -74,6 +79,7 @@ public class RequestResponseBodyMethodProcessorMockTests { private MethodParameter returnTypeString; private MethodParameter returnTypeInt; private MethodParameter returnTypeStringProduces; + private MethodParameter returnTypeResource; private ModelAndViewContainer mavContainer; @@ -81,14 +87,19 @@ public class RequestResponseBodyMethodProcessorMockTests { private MockHttpServletRequest servletRequest; + private MockHttpServletResponse servletResponse; + @SuppressWarnings("unchecked") @Before public void setUp() throws Exception { - messageConverter = mock(HttpMessageConverter.class); - given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + stringMessageConverter = mock(HttpMessageConverter.class); + given(stringMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); - processor = new RequestResponseBodyMethodProcessor(Collections.>singletonList(messageConverter)); + resourceMessageConverter = mock(HttpMessageConverter.class); + given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL)); + + processor = new RequestResponseBodyMethodProcessor(Arrays.asList(stringMessageConverter, resourceMessageConverter)); Method methodHandle1 = getClass().getMethod("handle1", String.class, Integer.TYPE); paramRequestBodyString = new MethodParameter(methodHandle1, 0); @@ -96,6 +107,7 @@ public class RequestResponseBodyMethodProcessorMockTests { returnTypeString = new MethodParameter(methodHandle1, -1); returnTypeInt = new MethodParameter(getClass().getMethod("handle2"), -1); returnTypeStringProduces = new MethodParameter(getClass().getMethod("handle3"), -1); + returnTypeResource = new MethodParameter(getClass().getMethod("handle6"), -1); paramValidBean = new MethodParameter(getClass().getMethod("handle4", SimpleBean.class), 0); paramStringNotRequired = new MethodParameter(getClass().getMethod("handle5", String.class), 0); @@ -103,7 +115,8 @@ public class RequestResponseBodyMethodProcessorMockTests { servletRequest = new MockHttpServletRequest(); servletRequest.setMethod("POST"); - webRequest = new ServletWebRequest(servletRequest, new MockHttpServletResponse()); + servletResponse = new MockHttpServletResponse(); + webRequest = new ServletWebRequest(servletRequest, servletResponse); } @Test @@ -126,8 +139,8 @@ public class RequestResponseBodyMethodProcessorMockTests { String body = "Foo"; servletRequest.setContent(body.getBytes(Charset.forName("UTF-8"))); - given(messageConverter.canRead(String.class, contentType)).willReturn(true); - given(messageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(body); + given(stringMessageConverter.canRead(String.class, contentType)).willReturn(true); + given(stringMessageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(body); Object result = processor.resolveArgument(paramRequestBodyString, mavContainer, webRequest, new ValidatingBinderFactory()); @@ -174,7 +187,7 @@ public class RequestResponseBodyMethodProcessorMockTests { servletRequest.addHeader("Content-Type", contentType.toString()); servletRequest.setContent("payload".getBytes(Charset.forName("UTF-8"))); - given(messageConverter.canRead(String.class, contentType)).willReturn(false); + given(stringMessageConverter.canRead(String.class, contentType)).willReturn(false); processor.resolveArgument(paramRequestBodyString, mavContainer, webRequest, null); } @@ -182,7 +195,7 @@ public class RequestResponseBodyMethodProcessorMockTests { @Test(expected = HttpMediaTypeNotSupportedException.class) public void resolveArgumentNoContentType() throws Exception { servletRequest.setContent("payload".getBytes(Charset.forName("UTF-8"))); - given(messageConverter.canRead(String.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(false); + given(stringMessageConverter.canRead(String.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(false); processor.resolveArgument(paramRequestBodyString, mavContainer, webRequest, null); } @@ -199,8 +212,8 @@ public class RequestResponseBodyMethodProcessorMockTests { public void resolveArgumentRequiredNoContent() throws Exception { servletRequest.setContentType(MediaType.TEXT_PLAIN_VALUE); servletRequest.setContent(new byte[0]); - given(messageConverter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true); - given(messageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(null); + given(stringMessageConverter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + given(stringMessageConverter.read(eq(String.class), isA(HttpInputMessage.class))).willReturn(null); assertNull(processor.resolveArgument(paramRequestBodyString, mavContainer, webRequest, new ValidatingBinderFactory())); } @@ -208,7 +221,7 @@ public class RequestResponseBodyMethodProcessorMockTests { public void resolveArgumentNotRequiredNoContent() throws Exception { servletRequest.setContentType("text/plain"); servletRequest.setContent(new byte[0]); - given(messageConverter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + given(stringMessageConverter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true); assertNull(processor.resolveArgument(paramStringNotRequired, mavContainer, webRequest, new ValidatingBinderFactory())); } @@ -216,8 +229,8 @@ public class RequestResponseBodyMethodProcessorMockTests { @Test public void resolveArgumentNotRequiredNoContentNoContentType() throws Exception { servletRequest.setContent(new byte[0]); - given(messageConverter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true); - given(messageConverter.canRead(String.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(false); + given(stringMessageConverter.canRead(String.class, MediaType.TEXT_PLAIN)).willReturn(true); + given(stringMessageConverter.canRead(String.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(false); assertNull(processor.resolveArgument(paramStringNotRequired, mavContainer, webRequest, new ValidatingBinderFactory())); } @@ -225,7 +238,7 @@ public class RequestResponseBodyMethodProcessorMockTests { public void resolveArgumentNotGetRequests() throws Exception { servletRequest.setMethod("GET"); servletRequest.setContent(new byte[0]); - given(messageConverter.canRead(String.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(false); + given(stringMessageConverter.canRead(String.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(false); assertNull(processor.resolveArgument(paramStringNotRequired, mavContainer, webRequest, new ValidatingBinderFactory())); } @@ -235,14 +248,14 @@ public class RequestResponseBodyMethodProcessorMockTests { servletRequest.addHeader("Accept", accepted.toString()); String body = "Foo"; - given(messageConverter.canWrite(String.class, null)).willReturn(true); - given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); - given(messageConverter.canWrite(String.class, accepted)).willReturn(true); + given(stringMessageConverter.canWrite(String.class, null)).willReturn(true); + given(stringMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(stringMessageConverter.canWrite(String.class, accepted)).willReturn(true); processor.handleReturnValue(body, returnTypeString, mavContainer, webRequest); assertTrue("The requestHandled flag wasn't set", mavContainer.isRequestHandled()); - verify(messageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class)); + verify(stringMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class)); } @Test @@ -252,12 +265,12 @@ public class RequestResponseBodyMethodProcessorMockTests { servletRequest.addHeader("Accept", "text/*"); servletRequest.setAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE, Collections.singleton(MediaType.TEXT_HTML)); - given(messageConverter.canWrite(String.class, MediaType.TEXT_HTML)).willReturn(true); + given(stringMessageConverter.canWrite(String.class, MediaType.TEXT_HTML)).willReturn(true); processor.handleReturnValue(body, returnTypeStringProduces, mavContainer, webRequest); assertTrue(mavContainer.isRequestHandled()); - verify(messageConverter).write(eq(body), eq(MediaType.TEXT_HTML), isA(HttpOutputMessage.class)); + verify(stringMessageConverter).write(eq(body), eq(MediaType.TEXT_HTML), isA(HttpOutputMessage.class)); } @@ -266,9 +279,9 @@ public class RequestResponseBodyMethodProcessorMockTests { MediaType accepted = MediaType.APPLICATION_ATOM_XML; servletRequest.addHeader("Accept", accepted.toString()); - given(messageConverter.canWrite(String.class, null)).willReturn(true); - given(messageConverter.getSupportedMediaTypes()).willReturn(Arrays.asList(MediaType.TEXT_PLAIN)); - given(messageConverter.canWrite(String.class, accepted)).willReturn(false); + given(stringMessageConverter.canWrite(String.class, null)).willReturn(true); + given(stringMessageConverter.getSupportedMediaTypes()).willReturn(Arrays.asList(MediaType.TEXT_PLAIN)); + given(stringMessageConverter.canWrite(String.class, accepted)).willReturn(false); processor.handleReturnValue("Foo", returnTypeString, mavContainer, webRequest); } @@ -278,13 +291,59 @@ public class RequestResponseBodyMethodProcessorMockTests { MediaType accepted = MediaType.TEXT_PLAIN; servletRequest.addHeader("Accept", accepted.toString()); - given(messageConverter.canWrite(String.class, null)).willReturn(true); - given(messageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); - given(messageConverter.canWrite(String.class, accepted)).willReturn(false); + given(stringMessageConverter.canWrite(String.class, null)).willReturn(true); + given(stringMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.TEXT_PLAIN)); + given(stringMessageConverter.canWrite(String.class, accepted)).willReturn(false); processor.handleReturnValue("Foo", returnTypeStringProduces, mavContainer, webRequest); } + @Test + public void handleReturnTypeResource() throws Exception { + Resource returnValue = new ByteArrayResource("Content".getBytes(Charset.forName("UTF-8"))); + + given(resourceMessageConverter.canWrite(ByteArrayResource.class, null)).willReturn(true); + given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL)); + given(resourceMessageConverter.canWrite(ByteArrayResource.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(true); + + processor.handleReturnValue(returnValue, returnTypeResource, mavContainer, webRequest); + + then(resourceMessageConverter).should(times(1)).write(any(ByteArrayResource.class), + eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class)); + assertEquals(200, servletResponse.getStatus()); + } + + @Test + public void handleReturnTypeResourceByteRange() throws Exception { + Resource returnValue = new ByteArrayResource("Content".getBytes(Charset.forName("UTF-8"))); + servletRequest.addHeader("Range", "bytes=0-5"); + + given(resourceMessageConverter.canWrite(HttpRangeResource.class, null)).willReturn(true); + given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL)); + given(resourceMessageConverter.canWrite(HttpRangeResource.class, MediaType.APPLICATION_OCTET_STREAM)).willReturn(true); + + processor.handleReturnValue(returnValue, returnTypeResource, mavContainer, webRequest); + + then(resourceMessageConverter).should(times(1)).write(any(ByteArrayResource.class), + eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class)); + assertEquals(206, servletResponse.getStatus()); + } + + @Test + public void handleReturnTypeResourceIllegalByteRange() throws Exception { + Resource returnValue = new ByteArrayResource("Content".getBytes(Charset.forName("UTF-8"))); + servletRequest.addHeader("Range", "illegal"); + + given(resourceMessageConverter.canWrite(ByteArrayResource.class, null)).willReturn(true); + given(resourceMessageConverter.getSupportedMediaTypes()).willReturn(Collections.singletonList(MediaType.ALL)); + + processor.handleReturnValue(returnValue, returnTypeResource, mavContainer, webRequest); + + then(resourceMessageConverter).should(never()).write(any(ByteArrayResource.class), + eq(MediaType.APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class)); + assertEquals(416, servletResponse.getStatus()); + } + // SPR-9841 @Test @@ -295,14 +354,14 @@ public class RequestResponseBodyMethodProcessorMockTests { servletRequest.addHeader("Accept", accepted); - given(messageConverter.canWrite(String.class, null)).willReturn(true); - given(messageConverter.getSupportedMediaTypes()).willReturn(supported); - given(messageConverter.canWrite(String.class, accepted)).willReturn(true); + given(stringMessageConverter.canWrite(String.class, null)).willReturn(true); + given(stringMessageConverter.getSupportedMediaTypes()).willReturn(supported); + given(stringMessageConverter.canWrite(String.class, accepted)).willReturn(true); processor.handleReturnValue(body, returnTypeStringProduces, mavContainer, webRequest); assertTrue(mavContainer.isRequestHandled()); - verify(messageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class)); + verify(stringMessageConverter).write(eq(body), eq(accepted), isA(HttpOutputMessage.class)); } @@ -331,6 +390,10 @@ public class RequestResponseBodyMethodProcessorMockTests { public void handle5(@RequestBody(required=false) String s) { } + @SuppressWarnings("unused") + @ResponseBody + public Resource handle6() {return null;} + private final class ValidatingBinderFactory implements WebDataBinderFactory { @Override public WebDataBinder createBinder(NativeWebRequest webRequest, Object target, String objectName) throws Exception { diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java index f904a3829c..3083810f32 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/resource/ResourceHttpRequestHandlerTests.java @@ -17,12 +17,8 @@ package org.springframework.web.servlet.resource; import static org.junit.Assert.*; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.*; -import java.io.FileNotFoundException; import java.io.IOException; -import java.io.InputStream; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; @@ -531,47 +527,6 @@ public class ResourceHttpRequestHandlerTests { assertEquals("t.", ranges[11]); } - // SPR-12999 - @Test @SuppressWarnings("unchecked") - public void writeContentNotGettingInputStream() throws Exception { - Resource resource = mock(Resource.class); - given(resource.getInputStream()).willThrow(FileNotFoundException.class); - - this.handler.writeContent(this.response, resource); - - assertEquals(200, this.response.getStatus()); - assertEquals(0, this.response.getContentLength()); - } - - // SPR-12999 - @Test - public void writeContentNotClosingInputStream() throws Exception { - 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(); - - this.handler.writeContent(this.response, resource); - - assertEquals(200, this.response.getStatus()); - assertEquals(0, this.response.getContentLength()); - } - - // SPR-13620 - @Test @SuppressWarnings("unchecked") - public void writeContentInputStreamThrowingNullPointerException() throws Exception { - Resource resource = mock(Resource.class); - InputStream in = mock(InputStream.class); - given(resource.getInputStream()).willReturn(in); - given(in.read(any())).willThrow(NullPointerException.class); - - this.handler.writeContent(this.response, resource); - - assertEquals(200, this.response.getStatus()); - assertEquals(0, this.response.getContentLength()); - } - // SPR-14005 @Test public void doOverwriteExistingCacheControlHeaders() throws Exception {