Browse Source
This new `HttpMessageWriter` leverages the `ResourceRegionEncoder` to write `ResourceRegion` to HTTP responses, thus supporting HTTP Range requests. Whenever possible, this message writer uses the zero copy support for single range requests. This `HttpMessageWriter` is never used directly, but is used as a delegate by the `ResourceHttpMessageWriter`. When provided with the `BOUNDARY_STRING_HINT`, the `ResourceRegionHttpMessageWriter` adapts its behavior in order to write a single/multiple byte ranges. Issue: SPR-14664pull/1175/head
4 changed files with 479 additions and 4 deletions
@ -0,0 +1,123 @@ |
|||||||
|
/* |
||||||
|
* 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.codec; |
||||||
|
|
||||||
|
import java.io.File; |
||||||
|
import java.io.IOException; |
||||||
|
import java.util.Collections; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.Optional; |
||||||
|
import java.util.OptionalLong; |
||||||
|
|
||||||
|
import org.reactivestreams.Publisher; |
||||||
|
import reactor.core.publisher.Mono; |
||||||
|
|
||||||
|
import org.springframework.core.ResolvableType; |
||||||
|
import org.springframework.core.codec.ResourceRegionEncoder; |
||||||
|
import org.springframework.core.io.Resource; |
||||||
|
import org.springframework.core.io.support.ResourceRegion; |
||||||
|
import org.springframework.http.MediaType; |
||||||
|
import org.springframework.http.ReactiveHttpOutputMessage; |
||||||
|
import org.springframework.http.ZeroCopyHttpOutputMessage; |
||||||
|
import org.springframework.util.ResourceUtils; |
||||||
|
|
||||||
|
/** |
||||||
|
* Implementation of {@link HttpMessageWriter} that can write |
||||||
|
* {@link ResourceRegion ResourceRegion}s. |
||||||
|
* |
||||||
|
* <p>Note that there is no {@link HttpMessageReader} counterpart. |
||||||
|
* |
||||||
|
* @author Brian Clozel |
||||||
|
* @since 5.0 |
||||||
|
*/ |
||||||
|
class ResourceRegionHttpMessageWriter extends EncoderHttpMessageWriter<ResourceRegion> { |
||||||
|
|
||||||
|
public static final String BOUNDARY_STRING_HINT = ResourceRegionHttpMessageWriter.class.getName() + ".boundaryString"; |
||||||
|
|
||||||
|
public ResourceRegionHttpMessageWriter() { |
||||||
|
super(new ResourceRegionEncoder()); |
||||||
|
} |
||||||
|
|
||||||
|
public ResourceRegionHttpMessageWriter(int bufferSize) { |
||||||
|
super(new ResourceRegionEncoder(bufferSize)); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Mono<Void> write(Publisher<? extends ResourceRegion> inputStream, ResolvableType type, |
||||||
|
MediaType contentType, ReactiveHttpOutputMessage outputMessage, Map<String, Object> hints) { |
||||||
|
|
||||||
|
if (hints != null && hints.containsKey(BOUNDARY_STRING_HINT)) { |
||||||
|
String boundary = (String) hints.get(BOUNDARY_STRING_HINT); |
||||||
|
hints.put(ResourceRegionEncoder.BOUNDARY_STRING_HINT, boundary); |
||||||
|
outputMessage.getHeaders() |
||||||
|
.setContentType(MediaType.parseMediaType("multipart/byteranges;boundary=" + boundary)); |
||||||
|
return super.write(inputStream, type, contentType, outputMessage, hints); |
||||||
|
} |
||||||
|
else { |
||||||
|
return Mono.from(inputStream) |
||||||
|
.then(region -> { |
||||||
|
writeSingleResourceRegionHeaders(region, contentType, outputMessage); |
||||||
|
return writeResourceRegion(region, type, outputMessage); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
private void writeSingleResourceRegionHeaders(ResourceRegion region, MediaType contentType, |
||||||
|
ReactiveHttpOutputMessage outputMessage) { |
||||||
|
|
||||||
|
OptionalLong resourceLength = ResourceUtils.contentLength(region.getResource()); |
||||||
|
resourceLength.ifPresent(length -> { |
||||||
|
long start = region.getPosition(); |
||||||
|
long end = start + region.getCount() - 1; |
||||||
|
end = Math.min(end, length - 1); |
||||||
|
outputMessage.getHeaders().add("Content-Range", "bytes " + start + "-" + end + "/" + length); |
||||||
|
outputMessage.getHeaders().setContentLength(end - start + 1); |
||||||
|
}); |
||||||
|
outputMessage.getHeaders().setContentType(contentType); |
||||||
|
} |
||||||
|
|
||||||
|
private Mono<Void> writeResourceRegion(ResourceRegion region, |
||||||
|
ResolvableType type, ReactiveHttpOutputMessage outputMessage) { |
||||||
|
if (outputMessage instanceof ZeroCopyHttpOutputMessage) { |
||||||
|
Optional<File> file = getFile(region.getResource()); |
||||||
|
if (file.isPresent()) { |
||||||
|
ZeroCopyHttpOutputMessage zeroCopyResponse = |
||||||
|
(ZeroCopyHttpOutputMessage) outputMessage; |
||||||
|
|
||||||
|
return zeroCopyResponse.writeWith(file.get(), region.getPosition(), region.getCount()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// non-zero copy fallback, using ResourceRegionEncoder
|
||||||
|
return super.write(Mono.just(region), type, |
||||||
|
outputMessage.getHeaders().getContentType(), outputMessage, Collections.emptyMap()); |
||||||
|
} |
||||||
|
|
||||||
|
private static Optional<File> getFile(Resource resource) { |
||||||
|
if (resource.isFile()) { |
||||||
|
try { |
||||||
|
return Optional.of(resource.getFile()); |
||||||
|
} |
||||||
|
catch (IOException ex) { |
||||||
|
// should not happen
|
||||||
|
} |
||||||
|
} |
||||||
|
return Optional.empty(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -0,0 +1,130 @@ |
|||||||
|
/* |
||||||
|
* 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.codec; |
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.*; |
||||||
|
import static org.junit.Assert.*; |
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets; |
||||||
|
import java.util.Collections; |
||||||
|
|
||||||
|
import org.hamcrest.Matchers; |
||||||
|
import org.junit.Before; |
||||||
|
import org.junit.Rule; |
||||||
|
import org.junit.Test; |
||||||
|
import org.junit.rules.ExpectedException; |
||||||
|
import org.reactivestreams.Publisher; |
||||||
|
import reactor.core.publisher.Flux; |
||||||
|
import reactor.core.publisher.Mono; |
||||||
|
|
||||||
|
import org.springframework.core.ResolvableType; |
||||||
|
import org.springframework.core.io.ByteArrayResource; |
||||||
|
import org.springframework.core.io.Resource; |
||||||
|
import org.springframework.core.io.buffer.DataBuffer; |
||||||
|
import org.springframework.core.io.buffer.DataBufferFactory; |
||||||
|
import org.springframework.core.io.buffer.DataBufferUtils; |
||||||
|
import org.springframework.core.io.buffer.support.DataBufferTestUtils; |
||||||
|
import org.springframework.http.HttpHeaders; |
||||||
|
import org.springframework.http.HttpRange; |
||||||
|
import org.springframework.http.HttpStatus; |
||||||
|
import org.springframework.http.MediaType; |
||||||
|
import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; |
||||||
|
import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; |
||||||
|
import org.springframework.tests.TestSubscriber; |
||||||
|
import org.springframework.util.MimeTypeUtils; |
||||||
|
import org.springframework.web.server.ResponseStatusException; |
||||||
|
|
||||||
|
/** |
||||||
|
* Unit tests for {@link ResourceHttpMessageWriter}. |
||||||
|
* @author Brian Clozel |
||||||
|
*/ |
||||||
|
public class ResourceHttpMessageWriterTests { |
||||||
|
|
||||||
|
private ResourceHttpMessageWriter writer = new ResourceHttpMessageWriter(); |
||||||
|
|
||||||
|
private MockServerHttpRequest request = new MockServerHttpRequest(); |
||||||
|
|
||||||
|
private MockServerHttpResponse response = new MockServerHttpResponse(); |
||||||
|
|
||||||
|
private Resource resource; |
||||||
|
|
||||||
|
@Rule |
||||||
|
public ExpectedException expectedException = ExpectedException.none(); |
||||||
|
|
||||||
|
|
||||||
|
@Before |
||||||
|
public void setUp() throws Exception { |
||||||
|
String content = "Spring Framework test resource content."; |
||||||
|
this.resource = new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void writableMediaTypes() throws Exception { |
||||||
|
assertThat(this.writer.getWritableMediaTypes(), |
||||||
|
containsInAnyOrder(MimeTypeUtils.APPLICATION_OCTET_STREAM, MimeTypeUtils.ALL)); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldWriteResource() throws Exception { |
||||||
|
|
||||||
|
TestSubscriber.subscribe(this.writer.write(Mono.just(resource), null, ResolvableType.forClass(Resource.class), |
||||||
|
MediaType.TEXT_PLAIN, this.request, this.response, Collections.emptyMap())).assertComplete(); |
||||||
|
|
||||||
|
assertThat(this.response.getHeaders().getContentType(), is(MediaType.TEXT_PLAIN)); |
||||||
|
assertThat(this.response.getHeaders().getContentLength(), is(39L)); |
||||||
|
|
||||||
|
Mono<String> result = reduceToString(this.response.getBody(), this.response.bufferFactory()); |
||||||
|
TestSubscriber.subscribe(result).assertComplete().assertValues("Spring Framework test resource content."); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldWriteResourceRange() throws Exception { |
||||||
|
|
||||||
|
this.request.getHeaders().setRange(Collections.singletonList(HttpRange.createByteRange(0, 5))); |
||||||
|
|
||||||
|
TestSubscriber.subscribe(this.writer.write(Mono.just(resource), null, ResolvableType.forClass(Resource.class), |
||||||
|
MediaType.TEXT_PLAIN, this.request, this.response, Collections.emptyMap())).assertComplete(); |
||||||
|
|
||||||
|
assertThat(this.response.getHeaders().getContentType(), is(MediaType.TEXT_PLAIN)); |
||||||
|
assertThat(this.response.getHeaders().getFirst(HttpHeaders.CONTENT_RANGE), is("bytes 0-5/39")); |
||||||
|
assertThat(this.response.getHeaders().getContentLength(), is(6L)); |
||||||
|
|
||||||
|
Mono<String> result = reduceToString(this.response.getBody(), this.response.bufferFactory()); |
||||||
|
TestSubscriber.subscribe(result).assertComplete().assertValues("Spring"); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldThrowErrorInvalidRange() throws Exception { |
||||||
|
this.request.getHeaders().set(HttpHeaders.RANGE, "invalid"); |
||||||
|
|
||||||
|
this.expectedException.expect(ResponseStatusException.class); |
||||||
|
TestSubscriber.subscribe(this.writer.write(Mono.just(resource), null, ResolvableType.forClass(Resource.class), |
||||||
|
MediaType.TEXT_PLAIN, this.request, this.response, Collections.emptyMap())); |
||||||
|
this.expectedException.expect(Matchers.hasProperty("status", is(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE))); |
||||||
|
} |
||||||
|
|
||||||
|
private Mono<String> reduceToString(Publisher<DataBuffer> buffers, DataBufferFactory bufferFactory) { |
||||||
|
|
||||||
|
return Flux.from(buffers) |
||||||
|
.reduce(bufferFactory.allocateBuffer(), (previous, current) -> { |
||||||
|
previous.write(current); |
||||||
|
DataBufferUtils.release(current); |
||||||
|
return previous; |
||||||
|
}) |
||||||
|
.map(buffer -> DataBufferTestUtils.dumpString(buffer, StandardCharsets.UTF_8)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,163 @@ |
|||||||
|
/* |
||||||
|
* 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.codec; |
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.*; |
||||||
|
import static org.junit.Assert.*; |
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets; |
||||||
|
import java.util.Collections; |
||||||
|
import java.util.HashMap; |
||||||
|
import java.util.Map; |
||||||
|
|
||||||
|
import org.junit.Before; |
||||||
|
import org.junit.Rule; |
||||||
|
import org.junit.Test; |
||||||
|
import org.junit.rules.ExpectedException; |
||||||
|
import org.reactivestreams.Publisher; |
||||||
|
import reactor.core.publisher.Flux; |
||||||
|
import reactor.core.publisher.Mono; |
||||||
|
|
||||||
|
import org.springframework.core.ResolvableType; |
||||||
|
import org.springframework.core.io.ByteArrayResource; |
||||||
|
import org.springframework.core.io.Resource; |
||||||
|
import org.springframework.core.io.buffer.DataBuffer; |
||||||
|
import org.springframework.core.io.buffer.DataBufferFactory; |
||||||
|
import org.springframework.core.io.buffer.DataBufferUtils; |
||||||
|
import org.springframework.core.io.buffer.support.DataBufferTestUtils; |
||||||
|
import org.springframework.core.io.support.ResourceRegion; |
||||||
|
import org.springframework.http.HttpHeaders; |
||||||
|
import org.springframework.http.MediaType; |
||||||
|
import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; |
||||||
|
import org.springframework.tests.TestSubscriber; |
||||||
|
import org.springframework.util.MimeTypeUtils; |
||||||
|
import org.springframework.util.StringUtils; |
||||||
|
|
||||||
|
/** |
||||||
|
* Unit tests for {@link ResourceRegionHttpMessageWriter}. |
||||||
|
* @author Brian Clozel |
||||||
|
*/ |
||||||
|
public class ResourceRegionHttpMessageWriterTests { |
||||||
|
|
||||||
|
private ResourceRegionHttpMessageWriter writer = new ResourceRegionHttpMessageWriter(); |
||||||
|
|
||||||
|
private MockServerHttpResponse response = new MockServerHttpResponse(); |
||||||
|
|
||||||
|
private Resource resource; |
||||||
|
|
||||||
|
@Rule |
||||||
|
public ExpectedException expectedException = ExpectedException.none(); |
||||||
|
|
||||||
|
|
||||||
|
@Before |
||||||
|
public void setUp() throws Exception { |
||||||
|
String content = "Spring Framework test resource content."; |
||||||
|
this.resource = new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
@Test |
||||||
|
public void defaultContentType() throws Exception { |
||||||
|
assertEquals(MimeTypeUtils.APPLICATION_OCTET_STREAM, |
||||||
|
this.writer.getDefaultContentType(ResolvableType.forClass(ResourceRegion.class))); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void writableMediaTypes() throws Exception { |
||||||
|
assertThat(this.writer.getWritableMediaTypes(), |
||||||
|
containsInAnyOrder(MimeTypeUtils.APPLICATION_OCTET_STREAM, MimeTypeUtils.ALL)); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldWriteResourceRegion() throws Exception { |
||||||
|
|
||||||
|
ResourceRegion region = new ResourceRegion(this.resource, 0, 6); |
||||||
|
|
||||||
|
TestSubscriber.subscribe(this.writer.write(Mono.just(region), ResolvableType.forClass(ResourceRegion.class), |
||||||
|
MediaType.TEXT_PLAIN, this.response, Collections.emptyMap())).assertComplete(); |
||||||
|
|
||||||
|
assertThat(this.response.getHeaders().getContentType(), is(MediaType.TEXT_PLAIN)); |
||||||
|
assertThat(this.response.getHeaders().getFirst(HttpHeaders.CONTENT_RANGE), is("bytes 0-5/39")); |
||||||
|
assertThat(this.response.getHeaders().getContentLength(), is(6L)); |
||||||
|
|
||||||
|
Mono<String> result = reduceToString(this.response.getBody(), this.response.bufferFactory()); |
||||||
|
TestSubscriber.subscribe(result).assertComplete().assertValues("Spring"); |
||||||
|
} |
||||||
|
|
||||||
|
@Test |
||||||
|
public void shouldWriteMultipleResourceRegions() throws Exception { |
||||||
|
Flux<ResourceRegion> regions = Flux.just( |
||||||
|
new ResourceRegion(this.resource, 0, 6), |
||||||
|
new ResourceRegion(this.resource, 7, 9), |
||||||
|
new ResourceRegion(this.resource, 17, 4), |
||||||
|
new ResourceRegion(this.resource, 22, 17) |
||||||
|
); |
||||||
|
String boundary = MimeTypeUtils.generateMultipartBoundaryString(); |
||||||
|
Map<String, Object> hints = new HashMap<>(1); |
||||||
|
hints.put(ResourceRegionHttpMessageWriter.BOUNDARY_STRING_HINT, boundary); |
||||||
|
|
||||||
|
TestSubscriber.subscribe( |
||||||
|
this.writer.write(regions, ResolvableType.forClass(ResourceRegion.class), |
||||||
|
MediaType.TEXT_PLAIN, this.response, hints)) |
||||||
|
.assertComplete(); |
||||||
|
|
||||||
|
HttpHeaders headers = this.response.getHeaders(); |
||||||
|
assertThat(headers.getContentType().toString(), startsWith("multipart/byteranges;boundary=" + boundary)); |
||||||
|
|
||||||
|
Mono<String> result = reduceToString(this.response.getBody(), this.response.bufferFactory()); |
||||||
|
TestSubscriber |
||||||
|
.subscribe(result).assertNoError() |
||||||
|
.assertComplete() |
||||||
|
.assertValuesWith(content -> { |
||||||
|
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")); |
||||||
|
|
||||||
|
assertThat(ranges[12], is("--" + boundary)); |
||||||
|
assertThat(ranges[13], is("Content-Type: text/plain")); |
||||||
|
assertThat(ranges[14], is("Content-Range: bytes 22-38/39")); |
||||||
|
assertThat(ranges[15], is("resource content.")); |
||||||
|
|
||||||
|
assertThat(ranges[16], is("--" + boundary + "--")); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
private Mono<String> reduceToString(Publisher<DataBuffer> buffers, DataBufferFactory bufferFactory) { |
||||||
|
|
||||||
|
return Flux.from(buffers) |
||||||
|
.reduce(bufferFactory.allocateBuffer(), (previous, current) -> { |
||||||
|
previous.write(current); |
||||||
|
DataBufferUtils.release(current); |
||||||
|
return previous; |
||||||
|
}) |
||||||
|
.map(buffer -> DataBufferTestUtils.dumpString(buffer, StandardCharsets.UTF_8)); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue