From c7bd3b844012a9bb214ca7203f110979c0890c00 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Tue, 22 Mar 2016 19:06:36 +0100 Subject: [PATCH] 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 handler() { } Issue: SPR-13834 --- .../http/HttpRangeResource.java | 119 +++++++++++ .../ResourceHttpMessageConverter.java | 157 ++++++++++++++- .../ResourceHttpMessageConverterTests.java | 184 ++++++++++++++++- .../http/converter/byterangeresource.txt | 1 + .../annotation/HttpEntityMethodProcessor.java | 16 ++ .../RequestResponseBodyMethodProcessor.java | 24 ++- .../resource/ResourceHttpRequestHandler.java | 190 ++++-------------- .../HttpEntityMethodProcessorMockTests.java | 166 +++++++-------- ...tResponseBodyMethodProcessorMockTests.java | 125 +++++++++--- .../ResourceHttpRequestHandlerTests.java | 45 ----- 10 files changed, 700 insertions(+), 327 deletions(-) create mode 100644 spring-web/src/main/java/org/springframework/http/HttpRangeResource.java create mode 100644 spring-web/src/test/resources/org/springframework/http/converter/byterangeresource.txt 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 {