Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions spring-web/src/main/java/org/springframework/http/HttpRange.java
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,46 @@ public static List<HttpRange> parseRanges(@Nullable String ranges) {
return result;
}

/**
* Parse ranges from the given request headers, taking an {@code If-Range}
* header into account based on the given response headers.
* <p>If the request does not contain an {@code If-Range} header, or if its
* value matches the {@code ETag} or {@code Last-Modified} response header,
* this delegates to {@link #parseRanges(String)}. Otherwise, an empty list
* is returned to indicate that the {@code Range} header should be ignored.
* @param requestHeaders the request headers
* @param responseHeaders the response headers for the selected representation
* @return the parsed ranges, or an empty list if the {@code Range} header
* should be ignored
* @throws IllegalArgumentException if the range cannot be parsed
* or if the number of ranges is greater than 100
* @since 7.1
* @see <a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.5">RFC 9110, Section 13.1.5</a>
*/
public static List<HttpRange> parseRanges(HttpHeaders requestHeaders, HttpHeaders responseHeaders) {
Assert.notNull(requestHeaders, "Request headers must not be null");
Assert.notNull(responseHeaders, "Response headers must not be null");

List<String> ifRangeValues = requestHeaders.get(HttpHeaders.IF_RANGE);
if (ifRangeValues != null) {
if (ifRangeValues.size() != 1 || !matchIfRange(ifRangeValues.get(0), responseHeaders)) {
return Collections.emptyList();
}
}
return parseRanges(requestHeaders.getFirst(HttpHeaders.RANGE));
}

private static boolean matchIfRange(@Nullable String ifRange, HttpHeaders responseHeaders) {
if (ifRange == null) {
return false;
}
ifRange = ifRange.trim();
if (ifRange.startsWith("\"")) {
return ifRange.equals(responseHeaders.getETag());
}
return ifRange.equals(responseHeaders.getFirst(HttpHeaders.LAST_MODIFIED));
}

private static HttpRange parseRange(String range) {
Assert.hasLength(range, "Range String must not be empty");
int dashIdx = range.indexOf('-');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ public Mono<Void> write(Publisher<? extends Resource> inputStream, @Nullable Res

List<HttpRange> ranges;
try {
ranges = request.getHeaders().getRange();
ranges = HttpRange.parseRanges(request.getHeaders(), response.getHeaders());
}
catch (IllegalArgumentException ex) {
return handleInvalidRange(response);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,74 @@ void parseRanges() {
assertThat(ranges.get(2).getRangeEnd(1000)).isEqualTo(999);
}

@Test
void parseRangesWithMatchingIfRangeETag() {
HttpHeaders requestHeaders = new HttpHeaders();
requestHeaders.setRange(List.of(HttpRange.createByteRange(0, 1)));
requestHeaders.set(HttpHeaders.IF_RANGE, "\"current\"");
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setETag("\"current\"");

assertThat(HttpRange.parseRanges(requestHeaders, responseHeaders)).hasSize(1);
}

@Test
void parseRangesWithNonMatchingIfRangeETag() {
HttpHeaders requestHeaders = new HttpHeaders();
requestHeaders.setRange(List.of(HttpRange.createByteRange(0, 1)));
requestHeaders.set(HttpHeaders.IF_RANGE, "\"stale\"");
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setETag("\"current\"");

assertThat(HttpRange.parseRanges(requestHeaders, responseHeaders)).isEmpty();
}

@Test
void parseRangesWithWeakIfRangeETag() {
HttpHeaders requestHeaders = new HttpHeaders();
requestHeaders.setRange(List.of(HttpRange.createByteRange(0, 1)));
requestHeaders.set(HttpHeaders.IF_RANGE, "W/\"current\"");
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setETag("W/\"current\"");

assertThat(HttpRange.parseRanges(requestHeaders, responseHeaders)).isEmpty();
}

@Test
void parseRangesWithMatchingIfRangeDate() {
long lastModified = 1660000000000L;
HttpHeaders requestHeaders = new HttpHeaders();
requestHeaders.setRange(List.of(HttpRange.createByteRange(0, 1)));
requestHeaders.setDate(HttpHeaders.IF_RANGE, lastModified);
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setLastModified(lastModified);

assertThat(HttpRange.parseRanges(requestHeaders, responseHeaders)).hasSize(1);
}

@Test
void parseRangesWithNonMatchingIfRangeDate() {
long lastModified = 1660000000000L;
HttpHeaders requestHeaders = new HttpHeaders();
requestHeaders.setRange(List.of(HttpRange.createByteRange(0, 1)));
requestHeaders.setDate(HttpHeaders.IF_RANGE, lastModified - 1000);
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setLastModified(lastModified);

assertThat(HttpRange.parseRanges(requestHeaders, responseHeaders)).isEmpty();
}

@Test
void invalidRangeIsIgnoredWhenIfRangeDoesNotMatch() {
HttpHeaders requestHeaders = new HttpHeaders();
requestHeaders.set(HttpHeaders.RANGE, "invalid");
requestHeaders.set(HttpHeaders.IF_RANGE, "\"stale\"");
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setETag("\"current\"");

assertThat(HttpRange.parseRanges(requestHeaders, responseHeaders)).isEmpty();
}

@Test
void parseRangesValidations() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ void writeSingleRegion() {
StepVerifier.create(this.response.getBodyAsString()).expectNext("Spring").expectComplete().verify();
}

@Test
void ignoreRangeWhenIfRangeETagDoesNotMatch() {
this.response.getHeaders().setETag("\"current\"");

testWrite(get("/").range(of(0, 5)).header(HttpHeaders.IF_RANGE, "\"stale\"").build());

assertThat(this.response.getHeaders().getFirst(HttpHeaders.CONTENT_RANGE)).isNull();
assertThat(this.response.getHeaders().getContentLength()).isEqualTo(39L);
StepVerifier.create(this.response.getBodyAsString())
.expectNext("Spring Framework test resource content.")
.expectComplete()
.verify();
}

@Test
void writeMultipleRegions() {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,24 @@ void partialContentByteRange() {
assertResponseBody(exchange, "So");
}

@Test
void ignoreRangeWhenIfRangeETagDoesNotMatch() {
this.handler.setEtagGenerator(resource -> "\"current\"");
MockServerHttpRequest request = MockServerHttpRequest.get("")
.header(HttpHeaders.RANGE, "bytes=0-1")
.header(HttpHeaders.IF_RANGE, "\"stale\"")
.build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
setPathWithinHandlerMapping(exchange, "foo.txt");
setBestMachingPattern(exchange, "/**");
this.handler.handle(exchange).block(TIMEOUT);

assertThat(exchange.getResponse().getHeaders().getETag()).isEqualTo("\"current\"");
assertThat(exchange.getResponse().getHeaders().getFirst(HttpHeaders.CONTENT_RANGE)).isNull();
assertThat(exchange.getResponse().getHeaders().getContentLength()).isEqualTo(10);
assertResponseBody(exchange, "Some text.");
}

@Test
void partialContentByteRangeNoEnd() {
MockServerHttpRequest request = MockServerHttpRequest.get("").header("range", "bytes=9-").build();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import org.springframework.http.converter.GenericHttpMessageConverter;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.SmartHttpMessageConverter;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.http.server.ServletServerHttpResponse;
import org.springframework.util.Assert;
import org.springframework.util.LinkedMultiValueMap;
Expand Down Expand Up @@ -284,11 +285,14 @@ protected void writeEntityWithMessageConverters(Object entity, HttpServletReques
if (rangeHeader != null) {
Resource resource = (Resource) entity;
try {
List<HttpRange> httpRanges = HttpRange.parseRanges(rangeHeader);
serverResponse.getServletResponse().setStatus(HttpStatus.PARTIAL_CONTENT.value());
entity = HttpRange.toResourceRegions(httpRanges, resource);
entityClass = entity.getClass();
entityType = RESOURCE_REGION_LIST_TYPE;
List<HttpRange> httpRanges = HttpRange.parseRanges(
new ServletServerHttpRequest(request).getHeaders(), serverResponse.getHeaders());
if (!httpRanges.isEmpty()) {
serverResponse.getServletResponse().setStatus(HttpStatus.PARTIAL_CONTENT.value());
entity = HttpRange.toResourceRegions(httpRanges, resource);
entityClass = entity.getClass();
entityType = RESOURCE_REGION_LIST_TYPE;
}
}
catch (IllegalArgumentException ex) {
serverResponse.getHeaders().set(HttpHeaders.CONTENT_RANGE, "bytes */" + resource.contentLength());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,14 @@ protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter
outputMessage.getServletResponse().getStatus() == 200) {
Resource resource = (Resource) value;
try {
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
outputMessage.getServletResponse().setStatus(HttpStatus.PARTIAL_CONTENT.value());
body = HttpRange.toResourceRegions(httpRanges, resource);
valueType = body.getClass();
targetType = RESOURCE_REGION_LIST_TYPE;
List<HttpRange> httpRanges =
HttpRange.parseRanges(inputMessage.getHeaders(), outputMessage.getHeaders());
if (!httpRanges.isEmpty()) {
outputMessage.getServletResponse().setStatus(HttpStatus.PARTIAL_CONTENT.value());
body = HttpRange.toResourceRegions(httpRanges, resource);
valueType = body.getClass();
targetType = RESOURCE_REGION_LIST_TYPE;
}
}
catch (IllegalArgumentException ex) {
outputMessage.getHeaders().set(HttpHeaders.CONTENT_RANGE, "bytes */" + resource.contentLength());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -554,31 +554,34 @@ public void handleRequest(HttpServletRequest request, HttpServletResponse respon

// Content phase
ServletServerHttpResponse outputMessage = new ServletServerHttpResponse(response);
if (request.getHeader(HttpHeaders.RANGE) == null) {
Assert.state(this.resourceHttpMessageConverter != null, "Converter not initialized");
if (HttpMethod.HEAD.matches(request.getMethod())) {
this.resourceHttpMessageConverter.addDefaultHeaders(outputMessage, resource, mediaType);
outputMessage.flush();
}
else {
this.resourceHttpMessageConverter.write(resource, mediaType, outputMessage);
}
}
else {
Assert.state(this.resourceRegionHttpMessageConverter != null, "Converter not initialized");
if (request.getHeader(HttpHeaders.RANGE) != null) {
ServletServerHttpRequest inputMessage = new ServletServerHttpRequest(request);
try {
List<HttpRange> httpRanges = inputMessage.getHeaders().getRange();
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
this.resourceRegionHttpMessageConverter.write(
HttpRange.toResourceRegions(httpRanges, resource), mediaType, outputMessage);
List<HttpRange> httpRanges = HttpRange.parseRanges(inputMessage.getHeaders(), outputMessage.getHeaders());
if (!httpRanges.isEmpty()) {
Assert.state(this.resourceRegionHttpMessageConverter != null, "Converter not initialized");
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
this.resourceRegionHttpMessageConverter.write(
HttpRange.toResourceRegions(httpRanges, resource), mediaType, outputMessage);
return;
}
}
catch (IllegalArgumentException ex) {
response.setContentType(null);
response.setHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + resource.contentLength());
response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
}

Assert.state(this.resourceHttpMessageConverter != null, "Converter not initialized");
if (HttpMethod.HEAD.matches(request.getMethod())) {
this.resourceHttpMessageConverter.addDefaultHeaders(outputMessage, resource, mediaType);
outputMessage.flush();
}
else {
this.resourceHttpMessageConverter.write(resource, mediaType, outputMessage);
}
}

protected @Nullable Resource getResource(HttpServletRequest request) throws IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,50 @@ void getRange() throws IOException, ServletException {
assertThat(servletResponse.getHeader(HttpHeaders.ACCEPT_RANGES)).isEqualTo("bytes");
}

@Test
void ignoreRangeWhenIfRangeETagDoesNotMatch() throws IOException, ServletException {
ResourceHandlerFunction handlerFunction =
new ResourceHandlerFunction(this.resource, (resource, headers) -> headers.setETag("\"current\""));
MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", "/", true);
servletRequest.addHeader(HttpHeaders.RANGE, "bytes=0-5");
servletRequest.addHeader(HttpHeaders.IF_RANGE, "\"stale\"");
ServerRequest request = new DefaultServerRequest(servletRequest, Collections.singletonList(this.messageConverter));

ServerResponse response = handlerFunction.handle(request);
MockHttpServletResponse servletResponse = new MockHttpServletResponse();
ModelAndView mav = response.writeTo(servletRequest, servletResponse, this.context);

assertThat(mav).isNull();
assertThat(servletResponse.getStatus()).isEqualTo(200);
assertThat(servletResponse.getHeader(HttpHeaders.ETAG)).isEqualTo("\"current\"");
assertThat(servletResponse.getHeader(HttpHeaders.CONTENT_RANGE)).isNull();
assertThat(servletResponse.getContentAsByteArray()).isEqualTo(Files.readAllBytes(this.resource.getFile().toPath()));
}

@Test
void getRangeWhenIfRangeETagMatches() throws IOException, ServletException {
ResourceHandlerFunction handlerFunction =
new ResourceHandlerFunction(this.resource, (resource, headers) -> headers.setETag("\"current\""));
MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", "/", true);
servletRequest.addHeader(HttpHeaders.RANGE, "bytes=0-5");
servletRequest.addHeader(HttpHeaders.IF_RANGE, "\"current\"");
ServerRequest request = new DefaultServerRequest(servletRequest, Collections.singletonList(this.messageConverter));

ServerResponse response = handlerFunction.handle(request);
MockHttpServletResponse servletResponse = new MockHttpServletResponse();
ModelAndView mav = response.writeTo(servletRequest, servletResponse, this.context);

assertThat(mav).isNull();
assertThat(servletResponse.getStatus()).isEqualTo(206);
assertThat(servletResponse.getHeader(HttpHeaders.CONTENT_RANGE))
.isEqualTo("bytes 0-5/" + this.resource.contentLength());
byte[] expectedBytes = new byte[6];
try (InputStream inputStream = this.resource.getInputStream()) {
inputStream.read(expectedBytes);
}
assertThat(servletResponse.getContentAsByteArray()).isEqualTo(expectedBytes);
}

@Test
void getInvalidRange() throws IOException, ServletException {
MockHttpServletRequest servletRequest = PathPatternsTestUtils.initRequest("GET", "/", true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,43 @@ void shouldHandleResourceByteRange() throws Exception {
assertThat(servletResponse.getStatus()).isEqualTo(206);
}

@Test
void shouldIgnoreResourceByteRangeWhenIfRangeETagDoesNotMatch() throws Exception {
ByteArrayResource resource = new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8));
ResponseEntity<Resource> returnValue = ResponseEntity.ok().eTag("\"current\"").body(resource);
servletRequest.addHeader(HttpHeaders.RANGE, "bytes=0-5");
servletRequest.addHeader(HttpHeaders.IF_RANGE, "\"stale\"");

given(resourceMessageConverter.canWrite(ByteArrayResource.class, null)).willReturn(true);
given(resourceMessageConverter.getSupportedMediaTypes(any())).willReturn(Collections.singletonList(MediaType.ALL));
given(resourceMessageConverter.canWrite(ByteArrayResource.class, APPLICATION_OCTET_STREAM)).willReturn(true);

processor.handleReturnValue(returnValue, returnTypeResponseEntityResource, mavContainer, webRequest);

then(resourceMessageConverter).should(times(1)).write(
isA(ByteArrayResource.class), eq(APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class));
then(resourceRegionMessageConverter).should(never()).write(anyCollection(), any(), any());
assertThat(servletResponse.getStatus()).isEqualTo(200);
assertThat(servletResponse.getHeader(HttpHeaders.CONTENT_RANGE)).isNull();
}

@Test
void shouldHandleResourceByteRangeWhenIfRangeETagMatches() throws Exception {
ResponseEntity<Resource> returnValue = ResponseEntity.ok().eTag("\"current\"")
.body(new ByteArrayResource("Content".getBytes(StandardCharsets.UTF_8)));
servletRequest.addHeader(HttpHeaders.RANGE, "bytes=0-5");
servletRequest.addHeader(HttpHeaders.IF_RANGE, "\"current\"");

given(resourceRegionMessageConverter.canWrite(any(), eq(null))).willReturn(true);
given(resourceRegionMessageConverter.canWrite(any(), eq(APPLICATION_OCTET_STREAM))).willReturn(true);

processor.handleReturnValue(returnValue, returnTypeResponseEntityResource, mavContainer, webRequest);

then(resourceRegionMessageConverter).should(times(1)).write(
anyCollection(), eq(APPLICATION_OCTET_STREAM), any(HttpOutputMessage.class));
assertThat(servletResponse.getStatus()).isEqualTo(206);
}

@Test
void handleReturnTypeResourceIllegalByteRange() throws Exception {
ResponseEntity<Resource> returnValue = ResponseEntity
Expand Down
Loading