Support byte ranges in ResourceHttpRequestHandler
This commit introduces support for HTTP byte ranges in the ResourceHttpRequestHandler. This support consists of a number of changes: - Parsing of HTTP Range headers in HttpHeaders, using a new HttpRange class and inner ByteRange/SuffixByteRange subclasses. - MIME boundary generation moved from FormHttpMessageConverter to MimeTypeUtils. - writePartialContent() method introduced in ResourceHttpRequestHandler, handling the byte range logic - Additional partial content tests added to ResourceHttpRequestHandlerTests. Issue: SPR-10805
This commit is contained in:
parent
0e7eecfe34
commit
da48739628
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright 2002-2014 the original author or authors.
|
||||
* Copyright 2002-2015 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,6 +16,7 @@
|
|||
|
||||
package org.springframework.util;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.UnsupportedCharsetException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
|
|
@ -25,6 +26,7 @@ import java.util.Iterator;
|
|||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
|
||||
import org.springframework.util.MimeType.SpecificityComparator;
|
||||
|
||||
|
|
@ -37,6 +39,17 @@ import org.springframework.util.MimeType.SpecificityComparator;
|
|||
*/
|
||||
public abstract class MimeTypeUtils {
|
||||
|
||||
private static final byte[] BOUNDARY_CHARS =
|
||||
new byte[] {'-', '_', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', 'g',
|
||||
'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A',
|
||||
'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U',
|
||||
'V', 'W', 'X', 'Y', 'Z'};
|
||||
|
||||
private static final Random RND = new Random();
|
||||
|
||||
private static Charset US_ASCII = Charset.forName("US-ASCII");
|
||||
|
||||
|
||||
/**
|
||||
* Public constant mime type that includes all media ranges (i.e. "*/*").
|
||||
*/
|
||||
|
|
@ -319,6 +332,25 @@ public abstract class MimeTypeUtils {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random MIME boundary as bytes, often used in multipart mime types.
|
||||
*/
|
||||
public static byte[] generateMultipartBoundary() {
|
||||
byte[] boundary = new byte[RND.nextInt(11) + 30];
|
||||
for (int i = 0; i < boundary.length; i++) {
|
||||
boundary[i] = BOUNDARY_CHARS[RND.nextInt(BOUNDARY_CHARS.length)];
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random MIME boundary as String, often used in multipart mime types.
|
||||
*/
|
||||
public static String generateMultipartBoundaryString() {
|
||||
return new String(generateMultipartBoundary(), US_ASCII);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Comparator used by {@link #sortBySpecificity(List)}.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
/*
|
||||
* Copyright 2002-2014 the original author or authors.
|
||||
* Copyright 2002-2015 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
|
||||
* 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,
|
||||
|
|
@ -744,6 +744,23 @@ public class HttpHeaders implements MultiValueMap<String, String>, Serializable
|
|||
return getFirst(PRAGMA);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the (new) value of the {@code Range} header.
|
||||
*/
|
||||
public void setRange(List<HttpRange> ranges) {
|
||||
String value = HttpRange.toString(ranges);
|
||||
set(RANGE, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the {@code Range} header.
|
||||
* <p>Returns an empty list when the range is unknown.
|
||||
*/
|
||||
public List<HttpRange> getRange() {
|
||||
String value = getFirst(RANGE);
|
||||
return HttpRange.parseRanges(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the (new) value of the {@code Upgrade} header.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,309 @@
|
|||
/*
|
||||
* Copyright 2002-2015 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.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* Represents an HTTP (byte) range, as used in the {@code Range} header.
|
||||
*
|
||||
* @author Arjen Poutsma
|
||||
* @see <a href="http://tools.ietf.org/html/rfc7233">HTTP/1.1: Range Requests</a>
|
||||
* @see HttpHeaders#setRange(List)
|
||||
* @see HttpHeaders#getRange()
|
||||
* @since 4.2
|
||||
*/
|
||||
public abstract class HttpRange {
|
||||
|
||||
private static final String BYTE_RANGE_PREFIX = "bytes=";
|
||||
|
||||
|
||||
/**
|
||||
* Creates a {@code HttpRange} that ranges from the given position to the end of the
|
||||
* representation.
|
||||
* @param firstBytePos the first byte position
|
||||
* @return a byte range that ranges from {@code firstBytePos} till the end
|
||||
* @see <a href="http://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a>
|
||||
*/
|
||||
public static HttpRange createByteRange(long firstBytePos) {
|
||||
return new ByteRange(firstBytePos, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@code HttpRange} that ranges from the given fist position to the given
|
||||
* last position.
|
||||
* @param firstBytePos the first byte position
|
||||
* @param lastBytePos the last byte position
|
||||
* @return a byte range that ranges from {@code firstBytePos} till {@code lastBytePos}
|
||||
* @see <a href="http://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a>
|
||||
*/
|
||||
public static HttpRange createByteRange(long firstBytePos, long lastBytePos) {
|
||||
Assert.isTrue(firstBytePos <= lastBytePos,
|
||||
"\"firstBytePost\" should be " + "less then or equal to \"lastBytePos\"");
|
||||
return new ByteRange(firstBytePos, lastBytePos);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a {@code HttpRange} that ranges over the last given number of bytes.
|
||||
* @param suffixLength the number of bytes
|
||||
* @return a byte range that ranges over the last {@code suffixLength} number of bytes
|
||||
* @see <a href="http://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a>
|
||||
*/
|
||||
public static HttpRange createSuffixRange(long suffixLength) {
|
||||
return new SuffixByteRange(suffixLength);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Return the start of this range, given the total length of the representation.
|
||||
* @param length the length of the representation.
|
||||
* @return the start of this range
|
||||
*/
|
||||
public abstract long getRangeStart(long length);
|
||||
|
||||
/**
|
||||
* Return the end of this range (inclusive), given the total length of the
|
||||
* representation.
|
||||
* @param length the length of the representation.
|
||||
* @return the end of this range
|
||||
*/
|
||||
public abstract long getRangeEnd(long length);
|
||||
|
||||
|
||||
/**
|
||||
* Parse the given, comma-separated string into a list of {@code HttpRange} objects.
|
||||
* <p>This method can be used to parse an {@code Range} header.
|
||||
* @param ranges the string to parse
|
||||
* @return the list of ranges
|
||||
* @throws IllegalArgumentException if the string cannot be parsed
|
||||
*/
|
||||
public static List<HttpRange> parseRanges(String ranges) {
|
||||
if (!StringUtils.hasLength(ranges)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
if (!ranges.startsWith(BYTE_RANGE_PREFIX)) {
|
||||
throw new IllegalArgumentException("Range \"" + ranges + "\" does not " +
|
||||
"start with \"" + BYTE_RANGE_PREFIX + "\"");
|
||||
}
|
||||
ranges = ranges.substring(BYTE_RANGE_PREFIX.length());
|
||||
|
||||
String[] tokens = ranges.split(",\\s*");
|
||||
List<HttpRange> result = new ArrayList<HttpRange>(tokens.length);
|
||||
for (String token : tokens) {
|
||||
result.add(parseRange(token));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static HttpRange parseRange(String range) {
|
||||
if (range == null) {
|
||||
return null;
|
||||
}
|
||||
int dashIdx = range.indexOf('-');
|
||||
if (dashIdx < 0) {
|
||||
throw new IllegalArgumentException("Range '\"" + range + "\" does not" +
|
||||
"contain \"-\"");
|
||||
}
|
||||
else if (dashIdx > 0) {
|
||||
// standard byte range, i.e. "bytes=0-500"
|
||||
long firstPos = Long.parseLong(range.substring(0, dashIdx));
|
||||
ByteRange byteRange;
|
||||
if (dashIdx < range.length() - 1) {
|
||||
long lastPos =
|
||||
Long.parseLong(range.substring(dashIdx + 1, range.length()));
|
||||
byteRange = new ByteRange(firstPos, lastPos);
|
||||
}
|
||||
else {
|
||||
byteRange = new ByteRange(firstPos, null);
|
||||
}
|
||||
if (!byteRange.validate()) {
|
||||
throw new IllegalArgumentException("Invalid Range \"" + range + "\"");
|
||||
}
|
||||
return byteRange;
|
||||
}
|
||||
else { // dashIdx == 0
|
||||
// suffix byte range, i.e. "bytes=-500"
|
||||
long suffixLength = Long.parseLong(range.substring(1));
|
||||
return new SuffixByteRange(suffixLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a string representation of the given list of {@code HttpRange} objects.
|
||||
* <p>This method can be used to for an {@code Range} header.
|
||||
* @param ranges the ranges to create a string of
|
||||
* @return the string representation
|
||||
*/
|
||||
public static String toString(Collection<HttpRange> ranges) {
|
||||
StringBuilder builder = new StringBuilder(BYTE_RANGE_PREFIX);
|
||||
for (Iterator<HttpRange> iterator = ranges.iterator(); iterator.hasNext(); ) {
|
||||
HttpRange range = iterator.next();
|
||||
range.appendTo(builder);
|
||||
if (iterator.hasNext()) {
|
||||
builder.append(", ");
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
appendTo(builder);
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
abstract void appendTo(StringBuilder builder);
|
||||
|
||||
/**
|
||||
* Represents an HTTP/1.1 byte range, with a first and optional last position.
|
||||
* @see <a href="http://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a>
|
||||
* @see HttpRange#createByteRange(long)
|
||||
* @see HttpRange#createByteRange(long, long)
|
||||
*/
|
||||
private static class ByteRange extends HttpRange {
|
||||
|
||||
private final long firstPos;
|
||||
|
||||
private final Long lastPos;
|
||||
|
||||
private ByteRange(long firstPos, Long lastPos) {
|
||||
this.firstPos = firstPos;
|
||||
this.lastPos = lastPos;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getRangeStart(long length) {
|
||||
return this.firstPos;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getRangeEnd(long length) {
|
||||
if (this.lastPos != null && this.lastPos < length) {
|
||||
return this.lastPos;
|
||||
}
|
||||
else {
|
||||
return length - 1;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
void appendTo(StringBuilder builder) {
|
||||
builder.append(this.firstPos);
|
||||
builder.append('-');
|
||||
if (this.lastPos != null) {
|
||||
builder.append(this.lastPos);
|
||||
}
|
||||
}
|
||||
|
||||
boolean validate() {
|
||||
if (this.firstPos < 0) {
|
||||
return false;
|
||||
}
|
||||
if (this.lastPos == null) {
|
||||
return true;
|
||||
}
|
||||
else {
|
||||
return this.firstPos <= this.lastPos;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof ByteRange)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ByteRange other = (ByteRange) o;
|
||||
|
||||
return this.firstPos == other.firstPos &&
|
||||
ObjectUtils.nullSafeEquals(this.lastPos, other.lastPos);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int hashCode = ObjectUtils.nullSafeHashCode(this.firstPos);
|
||||
hashCode = 31 * hashCode + ObjectUtils.nullSafeHashCode(this.lastPos);
|
||||
return hashCode;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an HTTP/1.1 suffix byte range, with a number of suffix bytes.
|
||||
* @see <a href="http://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a>
|
||||
* @see HttpRange#createSuffixRange(long)
|
||||
*/
|
||||
private static class SuffixByteRange extends HttpRange {
|
||||
|
||||
private final long suffixLength;
|
||||
|
||||
private SuffixByteRange(long suffixLength) {
|
||||
this.suffixLength = suffixLength;
|
||||
}
|
||||
|
||||
@Override
|
||||
void appendTo(StringBuilder builder) {
|
||||
builder.append('-');
|
||||
builder.append(this.suffixLength);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getRangeStart(long length) {
|
||||
if (this.suffixLength < length) {
|
||||
return length - this.suffixLength;
|
||||
}
|
||||
else {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getRangeEnd(long length) {
|
||||
return length - 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof SuffixByteRange)) {
|
||||
return false;
|
||||
}
|
||||
SuffixByteRange other = (SuffixByteRange) o;
|
||||
return this.suffixLength == other.suffixLength;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return ObjectUtils.hashCode(this.suffixLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright 2002-2014 the original author or authors.
|
||||
* Copyright 2002-2015 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.
|
||||
|
|
@ -276,6 +276,7 @@ public class MediaType extends MimeType implements Serializable {
|
|||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void checkParameters(String attribute, String value) {
|
||||
super.checkParameters(attribute, value);
|
||||
if (PARAM_QUALITY_FACTOR.equals(attribute)) {
|
||||
|
|
@ -400,9 +401,8 @@ public class MediaType extends MimeType implements Serializable {
|
|||
/**
|
||||
* Return a string representation of the given list of {@code MediaType} objects.
|
||||
* <p>This method can be used to for an {@code Accept} or {@code Content-Type} header.
|
||||
* @param mediaTypes the string to parse
|
||||
* @return the list of media types
|
||||
* @throws IllegalArgumentException if the String cannot be parsed
|
||||
* @param mediaTypes the media types to create a string representation for
|
||||
* @return the string representation
|
||||
*/
|
||||
public static String toString(Collection<MediaType> mediaTypes) {
|
||||
return MimeTypeUtils.toString(mediaTypes);
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import org.springframework.http.MediaType;
|
|||
import org.springframework.http.StreamingHttpOutputMessage;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MimeTypeUtils;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
|
@ -89,12 +90,6 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue
|
|||
|
||||
public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
|
||||
|
||||
private static final byte[] BOUNDARY_CHARS =
|
||||
new byte[] {'-', '_', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', 'g',
|
||||
'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A',
|
||||
'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U',
|
||||
'V', 'W', 'X', 'Y', 'Z'};
|
||||
|
||||
|
||||
private Charset charset = DEFAULT_CHARSET;
|
||||
|
||||
|
|
@ -365,15 +360,11 @@ public class FormHttpMessageConverter implements HttpMessageConverter<MultiValue
|
|||
|
||||
/**
|
||||
* Generate a multipart boundary.
|
||||
* <p>The default implementation returns a random boundary.
|
||||
* Can be overridden in subclasses.
|
||||
* <p>This implementation delegates to
|
||||
* {@link MimeTypeUtils#generateMultipartBoundary()}.
|
||||
*/
|
||||
protected byte[] generateMultipartBoundary() {
|
||||
byte[] boundary = new byte[this.random.nextInt(11) + 30];
|
||||
for (int i = 0; i < boundary.length; i++) {
|
||||
boundary[i] = BOUNDARY_CHARS[this.random.nextInt(BOUNDARY_CHARS.length)];
|
||||
}
|
||||
return boundary;
|
||||
return MimeTypeUtils.generateMultipartBoundary();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
/*
|
||||
* Copyright 2002-2014 the original author or authors.
|
||||
* Copyright 2002-2015 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
|
||||
* 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,
|
||||
|
|
@ -30,11 +30,11 @@ import java.util.Locale;
|
|||
import java.util.TimeZone;
|
||||
|
||||
import org.hamcrest.Matchers;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertThat;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* @author Arjen Poutsma
|
||||
*/
|
||||
|
|
@ -266,4 +266,16 @@ public class HttpHeadersTests {
|
|||
assertThat(headers.getAllow(), Matchers.emptyCollectionOf(HttpMethod.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void range() {
|
||||
List<HttpRange> ranges = new ArrayList<>();
|
||||
ranges.add(HttpRange.createByteRange(0, 499));
|
||||
ranges.add(HttpRange.createByteRange(9500));
|
||||
ranges.add(HttpRange.createSuffixRange(500));
|
||||
|
||||
headers.setRange(ranges);
|
||||
assertEquals("Invalid Range header", ranges, headers.getRange());
|
||||
assertEquals("Invalid Range header", "bytes=0-499, 9500-, -500", headers.getFirst("Range"));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,12 +18,14 @@ package org.springframework.web.servlet.resource;
|
|||
|
||||
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.activation.FileTypeMap;
|
||||
import javax.activation.MimetypesFileTypeMap;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.ServletOutputStream;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
|
|
@ -33,10 +35,14 @@ import org.apache.commons.logging.LogFactory;
|
|||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpRange;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.server.ServletServerHttpRequest;
|
||||
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;
|
||||
|
|
@ -79,6 +85,7 @@ import org.springframework.web.servlet.support.WebContentGenerator;
|
|||
* @author Keith Donald
|
||||
* @author Jeremy Grelle
|
||||
* @author Juergen Hoeller
|
||||
* @author Arjen Poutsma
|
||||
* @since 3.0.4
|
||||
*/
|
||||
public class ResourceHttpRequestHandler extends WebContentGenerator implements HttpRequestHandler, InitializingBean {
|
||||
|
|
@ -213,6 +220,12 @@ public class ResourceHttpRequestHandler extends WebContentGenerator implements H
|
|||
return;
|
||||
}
|
||||
|
||||
// header phase
|
||||
if (new ServletWebRequest(request, response).checkNotModified(resource.lastModified())) {
|
||||
logger.trace("Resource not modified - returning 304");
|
||||
return;
|
||||
}
|
||||
|
||||
// check the resource's media type
|
||||
MediaType mediaType = getMediaType(resource);
|
||||
if (mediaType != null) {
|
||||
|
|
@ -226,19 +239,20 @@ public class ResourceHttpRequestHandler extends WebContentGenerator implements H
|
|||
}
|
||||
}
|
||||
|
||||
// header phase
|
||||
if (new ServletWebRequest(request, response).checkNotModified(resource.lastModified())) {
|
||||
logger.trace("Resource not modified - returning 304");
|
||||
return;
|
||||
}
|
||||
setHeaders(response, resource, mediaType);
|
||||
|
||||
// content phase
|
||||
if (METHOD_HEAD.equals(request.getMethod())) {
|
||||
setHeaders(response, resource, mediaType);
|
||||
logger.trace("HEAD request - skipping content");
|
||||
return;
|
||||
}
|
||||
writeContent(response, resource);
|
||||
|
||||
if (request.getHeader("Range") == null) {
|
||||
setHeaders(response, resource, mediaType);
|
||||
writeContent(response, resource);
|
||||
}
|
||||
else {
|
||||
writePartialContent(request, response, resource, mediaType);
|
||||
}
|
||||
}
|
||||
|
||||
protected Resource getResource(HttpServletRequest request) throws IOException {
|
||||
|
|
@ -413,6 +427,121 @@ public class ResourceHttpRequestHandler extends WebContentGenerator implements H
|
|||
in.close();
|
||||
}
|
||||
catch (IOException ex) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write partial content out to the given servlet response,
|
||||
* streaming parts of the resource's content, as indicated by the request's
|
||||
* {@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 resourceLength = resource.contentLength();
|
||||
|
||||
List<HttpRange> ranges;
|
||||
try {
|
||||
HttpHeaders requestHeaders =
|
||||
new ServletServerHttpRequest(request).getHeaders();
|
||||
ranges = requestHeaders.getRange();
|
||||
} catch (IllegalArgumentException ex) {
|
||||
response.addHeader("Content-Range", "bytes */" + resourceLength);
|
||||
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 rangeStart = range.getRangeStart(resourceLength);
|
||||
long rangeEnd = range.getRangeEnd(resourceLength);
|
||||
long rangeLength = rangeEnd - rangeStart + 1;
|
||||
|
||||
setHeaders(response, resource, contentType);
|
||||
response.addHeader("Content-Range", "bytes "
|
||||
+ rangeStart + "-"
|
||||
+ rangeEnd + "/"
|
||||
+ resourceLength);
|
||||
response.setContentLength((int) rangeLength);
|
||||
|
||||
InputStream in = resource.getInputStream();
|
||||
try {
|
||||
copyRange(in, response.getOutputStream(), rangeStart, rangeEnd);
|
||||
}
|
||||
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 rangeStart = range.getRangeStart(resourceLength);
|
||||
long rangeEnd = range.getRangeEnd(resourceLength);
|
||||
|
||||
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 " + rangeStart + "-" +
|
||||
rangeEnd + "/" + resourceLength);
|
||||
out.println();
|
||||
|
||||
// Printing content
|
||||
copyRange(in, out, rangeStart, rangeEnd);
|
||||
|
||||
}
|
||||
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("Could only skip " + 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 < buffer.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright 2002-2014 the original author or authors.
|
||||
* Copyright 2002-2015 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.
|
||||
|
|
@ -22,6 +22,7 @@ import java.util.Arrays;
|
|||
import java.util.List;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
|
|
@ -31,11 +32,10 @@ import org.springframework.core.io.UrlResource;
|
|||
import org.springframework.mock.web.test.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.test.MockHttpServletResponse;
|
||||
import org.springframework.mock.web.test.MockServletContext;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Unit tests for ResourceHttpRequestHandler.
|
||||
*
|
||||
|
|
@ -277,6 +277,121 @@ public class ResourceHttpRequestHandlerTests {
|
|||
assertEquals(404, this.response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialContentByteRange() throws Exception {
|
||||
this.request.addHeader("Range", "bytes=0-1");
|
||||
this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt");
|
||||
this.handler.handleRequest(this.request, this.response);
|
||||
|
||||
assertEquals(206, this.response.getStatus());
|
||||
assertEquals("text/plain", this.response.getContentType());
|
||||
assertEquals(2, this.response.getContentLength());
|
||||
assertEquals("bytes 0-1/10",
|
||||
this.response.getHeader("Content-Range"));
|
||||
assertEquals("So", this.response.getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialContentByteRangeNoEnd() throws Exception {
|
||||
this.request.addHeader("Range", "bytes=9-");
|
||||
this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt");
|
||||
this.handler.handleRequest(this.request, this.response);
|
||||
|
||||
assertEquals(206, this.response.getStatus());
|
||||
assertEquals("text/plain", this.response.getContentType());
|
||||
assertEquals(1, this.response.getContentLength());
|
||||
assertEquals("bytes 9-9/10",
|
||||
this.response.getHeader("Content-Range"));
|
||||
assertEquals(".", this.response.getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialContentByteRangeLargeEnd() throws Exception {
|
||||
this.request.addHeader("Range", "bytes=9-10000");
|
||||
this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt");
|
||||
this.handler.handleRequest(this.request, this.response);
|
||||
|
||||
assertEquals(206, this.response.getStatus());
|
||||
assertEquals("text/plain", this.response.getContentType());
|
||||
assertEquals(1, this.response.getContentLength());
|
||||
assertEquals("bytes 9-9/10",
|
||||
this.response.getHeader("Content-Range"));
|
||||
assertEquals(".", this.response.getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialContentSuffixRange() throws Exception {
|
||||
this.request.addHeader("Range", "bytes=-1");
|
||||
this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt");
|
||||
this.handler.handleRequest(this.request, this.response);
|
||||
|
||||
assertEquals(206, this.response.getStatus());
|
||||
assertEquals("text/plain", this.response.getContentType());
|
||||
assertEquals(1, this.response.getContentLength());
|
||||
assertEquals("bytes 9-9/10",
|
||||
this.response.getHeader("Content-Range"));
|
||||
assertEquals(".", this.response.getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialContentSuffixRangeLargeSuffix() throws Exception {
|
||||
this.request.addHeader("Range", "bytes=-11");
|
||||
this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt");
|
||||
this.handler.handleRequest(this.request, this.response);
|
||||
|
||||
assertEquals(206, this.response.getStatus());
|
||||
assertEquals("text/plain", this.response.getContentType());
|
||||
assertEquals(10, this.response.getContentLength());
|
||||
assertEquals("bytes 0-9/10",
|
||||
this.response.getHeader("Content-Range"));
|
||||
assertEquals("Some text.", this.response.getContentAsString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialContentInvalidRangeHeader() throws Exception {
|
||||
this.request.addHeader("Range", "bytes= foo bar");
|
||||
this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE,
|
||||
"foo.txt");
|
||||
this.handler.handleRequest(this.request, this.response);
|
||||
|
||||
assertEquals(416, this.response.getStatus());
|
||||
assertEquals("bytes */10",
|
||||
this.response.getHeader("Content-Range"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void partialContentMultipleByteRanges() throws Exception {
|
||||
this.request.addHeader("Range", "bytes=0-1, 4-5, 8-9");
|
||||
this.request.setAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE, "foo.txt");
|
||||
this.handler.handleRequest(this.request, this.response);
|
||||
|
||||
assertEquals(206, this.response.getStatus());
|
||||
assertTrue(this.response.getContentType()
|
||||
.startsWith("multipart/byteranges; boundary="));
|
||||
|
||||
String boundary = "--" + this.response.getContentType().substring(31);
|
||||
|
||||
String[] ranges = StringUtils.tokenizeToStringArray(this.response.getContentAsString(),
|
||||
"\r\n", false, true);
|
||||
|
||||
assertEquals(boundary, ranges[0]);
|
||||
assertEquals("Content-Type: text/plain", ranges[1]);
|
||||
assertEquals("Content-Range: bytes 0-1/10", ranges[2]);
|
||||
assertEquals("So", ranges[3]);
|
||||
|
||||
assertEquals(boundary, ranges[4]);
|
||||
assertEquals("Content-Type: text/plain", ranges[5]);
|
||||
assertEquals("Content-Range: bytes 4-5/10", ranges[6]);
|
||||
assertEquals(" t", ranges[7]);
|
||||
|
||||
assertEquals(boundary, ranges[8]);
|
||||
assertEquals("Content-Type: text/plain", ranges[9]);
|
||||
assertEquals("Content-Range: bytes 8-9/10", ranges[10]);
|
||||
assertEquals("t.", ranges[11]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private long headerAsLong(String responseHeaderName) {
|
||||
return Long.valueOf(this.response.getHeader(responseHeaderName));
|
||||
|
|
|
|||
Loading…
Reference in New Issue