Browse Source
This commit adds the necessary infrastructure for the support of HTTP Range requests. The new `ResourceRegionEncoder` can write `ResourceRegion` objects as streams of bytes. The `ResourceRegionEncoder` relies on an encoding hint `BOUNDARY_STRING_HINT`. If present, the encoder infers that multiple `ResourceRegion`s should be encoded and that the provided boundary String should be used to separate ranges by mime boundaries. If that hint is absent, only a single resource region is encoded. Issue: SPR-14664pull/1175/head
6 changed files with 385 additions and 16 deletions
@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
|
||||
/* |
||||
* 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.core.codec; |
||||
|
||||
import java.io.IOException; |
||||
import java.nio.ByteBuffer; |
||||
import java.nio.channels.ReadableByteChannel; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.Map; |
||||
import java.util.OptionalLong; |
||||
|
||||
import org.reactivestreams.Publisher; |
||||
import reactor.core.publisher.Flux; |
||||
import reactor.core.publisher.Mono; |
||||
|
||||
import org.springframework.core.ResolvableType; |
||||
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.support.ResourceRegion; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.util.MimeType; |
||||
import org.springframework.util.MimeTypeUtils; |
||||
import org.springframework.util.ResourceUtils; |
||||
import org.springframework.util.StreamUtils; |
||||
|
||||
/** |
||||
* Encoder for {@link ResourceRegion}s. |
||||
* |
||||
* @author Brian Clozel |
||||
* @since 5.0 |
||||
*/ |
||||
public class ResourceRegionEncoder extends AbstractEncoder<ResourceRegion> { |
||||
|
||||
public static final int DEFAULT_BUFFER_SIZE = StreamUtils.BUFFER_SIZE; |
||||
|
||||
public static final String BOUNDARY_STRING_HINT = ResourceRegionEncoder.class.getName() + ".boundaryString"; |
||||
|
||||
private final int bufferSize; |
||||
|
||||
|
||||
public ResourceRegionEncoder() { |
||||
this(DEFAULT_BUFFER_SIZE); |
||||
} |
||||
|
||||
public ResourceRegionEncoder(int bufferSize) { |
||||
super(MimeTypeUtils.APPLICATION_OCTET_STREAM, MimeTypeUtils.ALL); |
||||
Assert.isTrue(bufferSize > 0, "'bufferSize' must be larger than 0"); |
||||
this.bufferSize = bufferSize; |
||||
} |
||||
|
||||
@Override |
||||
public boolean canEncode(ResolvableType elementType, MimeType mimeType) { |
||||
|
||||
return super.canEncode(elementType, mimeType) |
||||
&& ResourceRegion.class.isAssignableFrom(elementType.getRawClass()); |
||||
} |
||||
|
||||
@Override |
||||
public Flux<DataBuffer> encode(Publisher<? extends ResourceRegion> inputStream, |
||||
DataBufferFactory bufferFactory, ResolvableType elementType, MimeType mimeType, Map<String, Object> hints) { |
||||
|
||||
Assert.notNull(inputStream, "'inputStream' must not be null"); |
||||
Assert.notNull(bufferFactory, "'bufferFactory' must not be null"); |
||||
Assert.notNull(elementType, "'elementType' must not be null"); |
||||
|
||||
if (inputStream instanceof Mono) { |
||||
return ((Mono<? extends ResourceRegion>) inputStream) |
||||
.flatMap(region -> writeResourceRegion(region, bufferFactory)); |
||||
} |
||||
else { |
||||
Assert.notNull(hints, "'hints' must not be null"); |
||||
Assert.isTrue(hints.containsKey(BOUNDARY_STRING_HINT), "'hints' must contain boundaryString hint"); |
||||
final String boundaryString = (String) hints.get(BOUNDARY_STRING_HINT); |
||||
|
||||
byte[] startBoundary = getAsciiBytes("\r\n--" + boundaryString + "\r\n"); |
||||
byte[] contentType = getAsciiBytes("Content-Type: " + mimeType.toString() + "\r\n"); |
||||
|
||||
Flux<DataBuffer> regions = Flux.from(inputStream). |
||||
concatMap(region -> |
||||
Flux.concat( |
||||
getRegionPrefix(bufferFactory, startBoundary, contentType, region), |
||||
writeResourceRegion(region, bufferFactory) |
||||
)); |
||||
return Flux.concat(regions, getRegionSuffix(bufferFactory, boundaryString)); |
||||
} |
||||
} |
||||
|
||||
private Flux<DataBuffer> getRegionPrefix(DataBufferFactory bufferFactory, byte[] startBoundary, |
||||
byte[] contentType, ResourceRegion region) { |
||||
|
||||
return Flux.just( |
||||
bufferFactory.allocateBuffer(startBoundary.length).write(startBoundary), |
||||
bufferFactory.allocateBuffer(contentType.length).write(contentType), |
||||
bufferFactory.wrap(ByteBuffer.wrap(getContentRangeHeader(region))) |
||||
); |
||||
} |
||||
|
||||
private Flux<DataBuffer> writeResourceRegion(ResourceRegion region, DataBufferFactory bufferFactory) { |
||||
try { |
||||
ReadableByteChannel resourceChannel = region.getResource().readableChannel(); |
||||
Flux<DataBuffer> in = DataBufferUtils.read(resourceChannel, bufferFactory, this.bufferSize); |
||||
Flux<DataBuffer> skipped = DataBufferUtils.skipUntilByteCount(in, region.getPosition()); |
||||
return DataBufferUtils.takeUntilByteCount(skipped, region.getCount()); |
||||
} |
||||
catch (IOException exc) { |
||||
return Flux.error(exc); |
||||
} |
||||
} |
||||
|
||||
private Flux<DataBuffer> getRegionSuffix(DataBufferFactory bufferFactory, String boundaryString) { |
||||
byte[] endBoundary = getAsciiBytes("\r\n--" + boundaryString + "--"); |
||||
return Flux.just(bufferFactory.allocateBuffer(endBoundary.length).write(endBoundary)); |
||||
} |
||||
|
||||
private byte[] getAsciiBytes(String in) { |
||||
return in.getBytes(StandardCharsets.US_ASCII); |
||||
} |
||||
|
||||
private byte[] getContentRangeHeader(ResourceRegion region) { |
||||
long start = region.getPosition(); |
||||
long end = start + region.getCount() - 1; |
||||
OptionalLong contentLength = ResourceUtils.contentLength(region.getResource()); |
||||
if (contentLength.isPresent()) { |
||||
return getAsciiBytes("Content-Range: bytes " + start + "-" + end + "/" + contentLength.getAsLong() + "\r\n\r\n"); |
||||
} |
||||
else { |
||||
return getAsciiBytes("Content-Range: bytes " + start + "-" + end + "\r\n\r\n"); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
|
||||
/* |
||||
* 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.core.codec; |
||||
|
||||
import static org.hamcrest.Matchers.*; |
||||
import static org.junit.Assert.*; |
||||
|
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.Collections; |
||||
|
||||
import org.junit.Before; |
||||
import org.junit.Test; |
||||
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.AbstractDataBufferAllocatingTestCase; |
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory; |
||||
import org.springframework.core.io.buffer.support.DataBufferTestUtils; |
||||
import org.springframework.core.io.support.ResourceRegion; |
||||
import org.springframework.tests.TestSubscriber; |
||||
import org.springframework.util.MimeType; |
||||
import org.springframework.util.MimeTypeUtils; |
||||
import org.springframework.util.StringUtils; |
||||
|
||||
/** |
||||
* Test cases for {@link ResourceRegionEncoder} class. |
||||
* |
||||
* @author Brian Clozel |
||||
*/ |
||||
public class ResourceRegionEncoderTests extends AbstractDataBufferAllocatingTestCase { |
||||
|
||||
private ResourceRegionEncoder encoder; |
||||
|
||||
private Resource resource; |
||||
|
||||
@Before |
||||
public void setUp() { |
||||
this.encoder = new ResourceRegionEncoder(); |
||||
String content = "Spring Framework test resource content."; |
||||
this.resource = new ByteArrayResource(content.getBytes(StandardCharsets.UTF_8)); |
||||
this.bufferFactory = new DefaultDataBufferFactory(); |
||||
} |
||||
|
||||
@Test |
||||
public void canEncode() { |
||||
ResolvableType resourceRegion = ResolvableType.forClass(ResourceRegion.class); |
||||
MimeType allMimeType = MimeType.valueOf("*/*"); |
||||
|
||||
assertFalse(this.encoder.canEncode(ResolvableType.forClass(Resource.class), |
||||
MimeTypeUtils.APPLICATION_OCTET_STREAM)); |
||||
assertFalse(this.encoder.canEncode(ResolvableType.forClass(Resource.class), allMimeType)); |
||||
assertTrue(this.encoder.canEncode(resourceRegion, MimeTypeUtils.APPLICATION_OCTET_STREAM)); |
||||
assertTrue(this.encoder.canEncode(resourceRegion, allMimeType)); |
||||
} |
||||
|
||||
@Test |
||||
public void shouldEncodeResourceRegion() throws Exception { |
||||
|
||||
ResourceRegion region = new ResourceRegion(this.resource, 0, 6); |
||||
Flux<DataBuffer> result = this.encoder.encode(Mono.just(region), this.bufferFactory, |
||||
ResolvableType.forClass(ResourceRegion.class), MimeTypeUtils.APPLICATION_OCTET_STREAM |
||||
, Collections.emptyMap()); |
||||
|
||||
TestSubscriber.subscribe(result) |
||||
.assertNoError() |
||||
.assertComplete() |
||||
.assertValuesWith(stringConsumer("Spring")); |
||||
} |
||||
|
||||
@Test |
||||
public void shouldEncodeMultipleResourceRegions() 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(); |
||||
|
||||
Flux<DataBuffer> result = this.encoder.encode(regions, this.bufferFactory, |
||||
ResolvableType.forClass(ResourceRegion.class), |
||||
MimeType.valueOf("text/plain"), |
||||
Collections.singletonMap(ResourceRegionEncoder.BOUNDARY_STRING_HINT, boundary) |
||||
); |
||||
|
||||
Mono<DataBuffer> reduced = result |
||||
.reduce(bufferFactory.allocateBuffer(), (previous, current) -> { |
||||
previous.write(current); |
||||
DataBufferUtils.release(current); |
||||
return previous; |
||||
}); |
||||
|
||||
TestSubscriber |
||||
.subscribe(reduced) |
||||
.assertNoError() |
||||
.assertComplete() |
||||
.assertValuesWith(dataBuffer -> { |
||||
String content = DataBufferTestUtils.dumpString(dataBuffer, StandardCharsets.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")); |
||||
|
||||
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 + "--")); |
||||
}); |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue