001/* 002 * Copyright 2002-2019 the original author or authors. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * https://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package org.springframework.http; 018 019import java.io.IOException; 020import java.util.ArrayList; 021import java.util.Collection; 022import java.util.Collections; 023import java.util.List; 024import java.util.StringJoiner; 025 026import org.springframework.core.io.InputStreamResource; 027import org.springframework.core.io.Resource; 028import org.springframework.core.io.support.ResourceRegion; 029import org.springframework.lang.Nullable; 030import org.springframework.util.Assert; 031import org.springframework.util.CollectionUtils; 032import org.springframework.util.ObjectUtils; 033import org.springframework.util.StringUtils; 034 035/** 036 * Represents an HTTP (byte) range for use with the HTTP {@code "Range"} header. 037 * 038 * @author Arjen Poutsma 039 * @author Juergen Hoeller 040 * @since 4.2 041 * @see <a href="https://tools.ietf.org/html/rfc7233">HTTP/1.1: Range Requests</a> 042 * @see HttpHeaders#setRange(List) 043 * @see HttpHeaders#getRange() 044 */ 045public abstract class HttpRange { 046 047 /** Maximum ranges per request. */ 048 private static final int MAX_RANGES = 100; 049 050 private static final String BYTE_RANGE_PREFIX = "bytes="; 051 052 053 /** 054 * Turn a {@code Resource} into a {@link ResourceRegion} using the range 055 * information contained in the current {@code HttpRange}. 056 * @param resource the {@code Resource} to select the region from 057 * @return the selected region of the given {@code Resource} 058 * @since 4.3 059 */ 060 public ResourceRegion toResourceRegion(Resource resource) { 061 // Don't try to determine contentLength on InputStreamResource - cannot be read afterwards... 062 // Note: custom InputStreamResource subclasses could provide a pre-calculated content length! 063 Assert.isTrue(resource.getClass() != InputStreamResource.class, 064 "Cannot convert an InputStreamResource to a ResourceRegion"); 065 long contentLength = getLengthFor(resource); 066 long start = getRangeStart(contentLength); 067 long end = getRangeEnd(contentLength); 068 Assert.isTrue(start < contentLength, "'position' exceeds the resource length " + contentLength); 069 return new ResourceRegion(resource, start, end - start + 1); 070 } 071 072 /** 073 * Return the start of the range given the total length of a representation. 074 * @param length the length of the representation 075 * @return the start of this range for the representation 076 */ 077 public abstract long getRangeStart(long length); 078 079 /** 080 * Return the end of the range (inclusive) given the total length of a representation. 081 * @param length the length of the representation 082 * @return the end of the range for the representation 083 */ 084 public abstract long getRangeEnd(long length); 085 086 087 /** 088 * Create an {@code HttpRange} from the given position to the end. 089 * @param firstBytePos the first byte position 090 * @return a byte range that ranges from {@code firstPos} till the end 091 * @see <a href="https://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a> 092 */ 093 public static HttpRange createByteRange(long firstBytePos) { 094 return new ByteRange(firstBytePos, null); 095 } 096 097 /** 098 * Create a {@code HttpRange} from the given fist to last position. 099 * @param firstBytePos the first byte position 100 * @param lastBytePos the last byte position 101 * @return a byte range that ranges from {@code firstPos} till {@code lastPos} 102 * @see <a href="https://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a> 103 */ 104 public static HttpRange createByteRange(long firstBytePos, long lastBytePos) { 105 return new ByteRange(firstBytePos, lastBytePos); 106 } 107 108 /** 109 * Create an {@code HttpRange} that ranges over the last given number of bytes. 110 * @param suffixLength the number of bytes for the range 111 * @return a byte range that ranges over the last {@code suffixLength} number of bytes 112 * @see <a href="https://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a> 113 */ 114 public static HttpRange createSuffixRange(long suffixLength) { 115 return new SuffixByteRange(suffixLength); 116 } 117 118 /** 119 * Parse the given, comma-separated string into a list of {@code HttpRange} objects. 120 * <p>This method can be used to parse an {@code Range} header. 121 * @param ranges the string to parse 122 * @return the list of ranges 123 * @throws IllegalArgumentException if the string cannot be parsed 124 * or if the number of ranges is greater than 100 125 */ 126 public static List<HttpRange> parseRanges(@Nullable String ranges) { 127 if (!StringUtils.hasLength(ranges)) { 128 return Collections.emptyList(); 129 } 130 if (!ranges.startsWith(BYTE_RANGE_PREFIX)) { 131 throw new IllegalArgumentException("Range '" + ranges + "' does not start with 'bytes='"); 132 } 133 ranges = ranges.substring(BYTE_RANGE_PREFIX.length()); 134 135 String[] tokens = StringUtils.tokenizeToStringArray(ranges, ","); 136 if (tokens.length > MAX_RANGES) { 137 throw new IllegalArgumentException("Too many ranges: " + tokens.length); 138 } 139 List<HttpRange> result = new ArrayList<>(tokens.length); 140 for (String token : tokens) { 141 result.add(parseRange(token)); 142 } 143 return result; 144 } 145 146 private static HttpRange parseRange(String range) { 147 Assert.hasLength(range, "Range String must not be empty"); 148 int dashIdx = range.indexOf('-'); 149 if (dashIdx > 0) { 150 long firstPos = Long.parseLong(range.substring(0, dashIdx)); 151 if (dashIdx < range.length() - 1) { 152 Long lastPos = Long.parseLong(range.substring(dashIdx + 1)); 153 return new ByteRange(firstPos, lastPos); 154 } 155 else { 156 return new ByteRange(firstPos, null); 157 } 158 } 159 else if (dashIdx == 0) { 160 long suffixLength = Long.parseLong(range.substring(1)); 161 return new SuffixByteRange(suffixLength); 162 } 163 else { 164 throw new IllegalArgumentException("Range '" + range + "' does not contain \"-\""); 165 } 166 } 167 168 /** 169 * Convert each {@code HttpRange} into a {@code ResourceRegion}, selecting the 170 * appropriate segment of the given {@code Resource} using HTTP Range information. 171 * @param ranges the list of ranges 172 * @param resource the resource to select the regions from 173 * @return the list of regions for the given resource 174 * @throws IllegalArgumentException if the sum of all ranges exceeds the resource length 175 * @since 4.3 176 */ 177 public static List<ResourceRegion> toResourceRegions(List<HttpRange> ranges, Resource resource) { 178 if (CollectionUtils.isEmpty(ranges)) { 179 return Collections.emptyList(); 180 } 181 List<ResourceRegion> regions = new ArrayList<>(ranges.size()); 182 for (HttpRange range : ranges) { 183 regions.add(range.toResourceRegion(resource)); 184 } 185 if (ranges.size() > 1) { 186 long length = getLengthFor(resource); 187 long total = 0; 188 for (ResourceRegion region : regions) { 189 total += region.getCount(); 190 } 191 if (total >= length) { 192 throw new IllegalArgumentException("The sum of all ranges (" + total + 193 ") should be less than the resource length (" + length + ")"); 194 } 195 } 196 return regions; 197 } 198 199 private static long getLengthFor(Resource resource) { 200 try { 201 long contentLength = resource.contentLength(); 202 Assert.isTrue(contentLength > 0, "Resource content length should be > 0"); 203 return contentLength; 204 } 205 catch (IOException ex) { 206 throw new IllegalArgumentException("Failed to obtain Resource content length", ex); 207 } 208 } 209 210 /** 211 * Return a string representation of the given list of {@code HttpRange} objects. 212 * <p>This method can be used to for an {@code Range} header. 213 * @param ranges the ranges to create a string of 214 * @return the string representation 215 */ 216 public static String toString(Collection<HttpRange> ranges) { 217 Assert.notEmpty(ranges, "Ranges Collection must not be empty"); 218 StringJoiner builder = new StringJoiner(", ", BYTE_RANGE_PREFIX, ""); 219 for (HttpRange range : ranges) { 220 builder.add(range.toString()); 221 } 222 return builder.toString(); 223 } 224 225 226 /** 227 * Represents an HTTP/1.1 byte range, with a first and optional last position. 228 * @see <a href="https://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a> 229 * @see HttpRange#createByteRange(long) 230 * @see HttpRange#createByteRange(long, long) 231 */ 232 private static class ByteRange extends HttpRange { 233 234 private final long firstPos; 235 236 @Nullable 237 private final Long lastPos; 238 239 public ByteRange(long firstPos, @Nullable Long lastPos) { 240 assertPositions(firstPos, lastPos); 241 this.firstPos = firstPos; 242 this.lastPos = lastPos; 243 } 244 245 private void assertPositions(long firstBytePos, @Nullable Long lastBytePos) { 246 if (firstBytePos < 0) { 247 throw new IllegalArgumentException("Invalid first byte position: " + firstBytePos); 248 } 249 if (lastBytePos != null && lastBytePos < firstBytePos) { 250 throw new IllegalArgumentException("firstBytePosition=" + firstBytePos + 251 " should be less then or equal to lastBytePosition=" + lastBytePos); 252 } 253 } 254 255 @Override 256 public long getRangeStart(long length) { 257 return this.firstPos; 258 } 259 260 @Override 261 public long getRangeEnd(long length) { 262 if (this.lastPos != null && this.lastPos < length) { 263 return this.lastPos; 264 } 265 else { 266 return length - 1; 267 } 268 } 269 270 @Override 271 public boolean equals(@Nullable Object other) { 272 if (this == other) { 273 return true; 274 } 275 if (!(other instanceof ByteRange)) { 276 return false; 277 } 278 ByteRange otherRange = (ByteRange) other; 279 return (this.firstPos == otherRange.firstPos && 280 ObjectUtils.nullSafeEquals(this.lastPos, otherRange.lastPos)); 281 } 282 283 @Override 284 public int hashCode() { 285 return (ObjectUtils.nullSafeHashCode(this.firstPos) * 31 + 286 ObjectUtils.nullSafeHashCode(this.lastPos)); 287 } 288 289 @Override 290 public String toString() { 291 StringBuilder builder = new StringBuilder(); 292 builder.append(this.firstPos); 293 builder.append('-'); 294 if (this.lastPos != null) { 295 builder.append(this.lastPos); 296 } 297 return builder.toString(); 298 } 299 } 300 301 302 /** 303 * Represents an HTTP/1.1 suffix byte range, with a number of suffix bytes. 304 * @see <a href="https://tools.ietf.org/html/rfc7233#section-2.1">Byte Ranges</a> 305 * @see HttpRange#createSuffixRange(long) 306 */ 307 private static class SuffixByteRange extends HttpRange { 308 309 private final long suffixLength; 310 311 public SuffixByteRange(long suffixLength) { 312 if (suffixLength < 0) { 313 throw new IllegalArgumentException("Invalid suffix length: " + suffixLength); 314 } 315 this.suffixLength = suffixLength; 316 } 317 318 @Override 319 public long getRangeStart(long length) { 320 if (this.suffixLength < length) { 321 return length - this.suffixLength; 322 } 323 else { 324 return 0; 325 } 326 } 327 328 @Override 329 public long getRangeEnd(long length) { 330 return length - 1; 331 } 332 333 @Override 334 public boolean equals(@Nullable Object other) { 335 if (this == other) { 336 return true; 337 } 338 if (!(other instanceof SuffixByteRange)) { 339 return false; 340 } 341 SuffixByteRange otherRange = (SuffixByteRange) other; 342 return (this.suffixLength == otherRange.suffixLength); 343 } 344 345 @Override 346 public int hashCode() { 347 return Long.hashCode(this.suffixLength); 348 } 349 350 @Override 351 public String toString() { 352 return "-" + this.suffixLength; 353 } 354 } 355 356}