Rossen Stoyanchev
5 years ago
8 changed files with 425 additions and 155 deletions
@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
|
||||
/* |
||||
* Copyright 2002-2020 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 |
||||
* |
||||
* https://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.multipart; |
||||
|
||||
import java.nio.charset.Charset; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.HashMap; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
|
||||
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.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.codec.LoggingCodecSupport; |
||||
import org.springframework.lang.Nullable; |
||||
import org.springframework.util.MimeTypeUtils; |
||||
import org.springframework.util.MultiValueMap; |
||||
|
||||
/** |
||||
* Support class for multipart HTTP message writers. |
||||
* |
||||
* @author Rossen Stoyanchev |
||||
* @since 5.3 |
||||
*/ |
||||
public class MultipartWriterSupport extends LoggingCodecSupport { |
||||
|
||||
/** THe default charset used by the writer. */ |
||||
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; |
||||
|
||||
protected final List<MediaType> supportedMediaTypes; |
||||
|
||||
protected Charset charset = DEFAULT_CHARSET; |
||||
|
||||
|
||||
/** |
||||
* Constructor with the list of supported media types. |
||||
*/ |
||||
protected MultipartWriterSupport(List<MediaType> supportedMediaTypes) { |
||||
this.supportedMediaTypes = supportedMediaTypes; |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Return the configured charset for part headers. |
||||
*/ |
||||
public Charset getCharset() { |
||||
return this.charset; |
||||
} |
||||
|
||||
public List<MediaType> getWritableMediaTypes() { |
||||
return this.supportedMediaTypes; |
||||
} |
||||
|
||||
|
||||
public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) { |
||||
if (MultiValueMap.class.isAssignableFrom(elementType.toClass())) { |
||||
if (mediaType == null) { |
||||
return true; |
||||
} |
||||
for (MediaType supportedMediaType : this.supportedMediaTypes) { |
||||
if (supportedMediaType.isCompatibleWith(mediaType)) { |
||||
return true; |
||||
} |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
/** |
||||
* Generate a multipart boundary. |
||||
* <p>By default delegates to {@link MimeTypeUtils#generateMultipartBoundary()}. |
||||
*/ |
||||
protected byte[] generateMultipartBoundary() { |
||||
return MimeTypeUtils.generateMultipartBoundary(); |
||||
} |
||||
|
||||
/** |
||||
* Prepare the {@code MediaType} to use by adding "boundary" and "charset" |
||||
* parameters to the given {@code mediaType} or "mulitpart/form-data" |
||||
* otherwise by default. |
||||
*/ |
||||
protected MediaType getMultipartMediaType(@Nullable MediaType mediaType, byte[] boundary) { |
||||
Map<String, String> params = new HashMap<>(); |
||||
if (mediaType != null) { |
||||
params.putAll(mediaType.getParameters()); |
||||
} |
||||
params.put("boundary", new String(boundary, StandardCharsets.US_ASCII)); |
||||
params.put("charset", getCharset().name()); |
||||
|
||||
mediaType = (mediaType != null ? mediaType : MediaType.MULTIPART_FORM_DATA); |
||||
mediaType = new MediaType(mediaType, params); |
||||
return mediaType; |
||||
} |
||||
|
||||
protected Mono<DataBuffer> generateBoundaryLine(byte[] boundary, DataBufferFactory bufferFactory) { |
||||
return Mono.fromCallable(() -> { |
||||
DataBuffer buffer = bufferFactory.allocateBuffer(boundary.length + 4); |
||||
buffer.write((byte)'-'); |
||||
buffer.write((byte)'-'); |
||||
buffer.write(boundary); |
||||
buffer.write((byte)'\r'); |
||||
buffer.write((byte)'\n'); |
||||
return buffer; |
||||
}); |
||||
} |
||||
|
||||
protected Mono<DataBuffer> generateNewLine(DataBufferFactory bufferFactory) { |
||||
return Mono.fromCallable(() -> { |
||||
DataBuffer buffer = bufferFactory.allocateBuffer(2); |
||||
buffer.write((byte)'\r'); |
||||
buffer.write((byte)'\n'); |
||||
return buffer; |
||||
}); |
||||
} |
||||
|
||||
protected Mono<DataBuffer> generateLastLine(byte[] boundary, DataBufferFactory bufferFactory) { |
||||
return Mono.fromCallable(() -> { |
||||
DataBuffer buffer = bufferFactory.allocateBuffer(boundary.length + 6); |
||||
buffer.write((byte)'-'); |
||||
buffer.write((byte)'-'); |
||||
buffer.write(boundary); |
||||
buffer.write((byte)'-'); |
||||
buffer.write((byte)'-'); |
||||
buffer.write((byte)'\r'); |
||||
buffer.write((byte)'\n'); |
||||
return buffer; |
||||
}); |
||||
} |
||||
|
||||
protected Mono<DataBuffer> generatePartHeaders(HttpHeaders headers, DataBufferFactory bufferFactory) { |
||||
return Mono.fromCallable(() -> { |
||||
DataBuffer buffer = bufferFactory.allocateBuffer(); |
||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) { |
||||
byte[] headerName = entry.getKey().getBytes(getCharset()); |
||||
for (String headerValueString : entry.getValue()) { |
||||
byte[] headerValue = headerValueString.getBytes(getCharset()); |
||||
buffer.write(headerName); |
||||
buffer.write((byte)':'); |
||||
buffer.write((byte)' '); |
||||
buffer.write(headerValue); |
||||
buffer.write((byte)'\r'); |
||||
buffer.write((byte)'\n'); |
||||
} |
||||
} |
||||
buffer.write((byte)'\r'); |
||||
buffer.write((byte)'\n'); |
||||
return buffer; |
||||
}); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
/* |
||||
* Copyright 2002-2020 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 |
||||
* |
||||
* https://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.multipart; |
||||
|
||||
import java.util.Map; |
||||
|
||||
import org.reactivestreams.Publisher; |
||||
import reactor.core.publisher.Flux; |
||||
import reactor.core.publisher.Mono; |
||||
|
||||
import org.springframework.core.ResolvableType; |
||||
import org.springframework.core.codec.Hints; |
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferFactory; |
||||
import org.springframework.core.io.buffer.PooledDataBuffer; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.ReactiveHttpOutputMessage; |
||||
import org.springframework.http.codec.HttpMessageWriter; |
||||
import org.springframework.lang.Nullable; |
||||
|
||||
/** |
||||
* {@link HttpMessageWriter} for writing with {@link Part}. This can be useful |
||||
* on the server side to write a {@code Flux<Part>} received from a client to |
||||
* some remote service. |
||||
* |
||||
* @author Rossen Stoyanchev |
||||
* @since 5.3 |
||||
*/ |
||||
public class PartHttpMessageWriter extends MultipartWriterSupport implements HttpMessageWriter<Part> { |
||||
|
||||
|
||||
public PartHttpMessageWriter() { |
||||
super(MultipartHttpMessageReader.MIME_TYPES); |
||||
} |
||||
|
||||
|
||||
@Override |
||||
public Mono<Void> write(Publisher<? extends Part> parts, |
||||
ResolvableType elementType, @Nullable MediaType mediaType, ReactiveHttpOutputMessage outputMessage, |
||||
Map<String, Object> hints) { |
||||
|
||||
byte[] boundary = generateMultipartBoundary(); |
||||
|
||||
mediaType = getMultipartMediaType(mediaType, boundary); |
||||
outputMessage.getHeaders().setContentType(mediaType); |
||||
|
||||
if (logger.isDebugEnabled()) { |
||||
logger.debug(Hints.getLogPrefix(hints) + "Encoding Publisher<Part>"); |
||||
} |
||||
|
||||
Flux<DataBuffer> body = Flux.from(parts) |
||||
.concatMap(part -> encodePart(boundary, part, outputMessage.bufferFactory())) |
||||
.concatWith(generateLastLine(boundary, outputMessage.bufferFactory())) |
||||
.doOnDiscard(PooledDataBuffer.class, PooledDataBuffer::release); |
||||
|
||||
return outputMessage.writeWith(body); |
||||
} |
||||
|
||||
private <T> Flux<DataBuffer> encodePart(byte[] boundary, Part part, DataBufferFactory bufferFactory) { |
||||
HttpHeaders headers = new HttpHeaders(part.headers()); |
||||
|
||||
String name = part.name(); |
||||
if (!headers.containsKey(HttpHeaders.CONTENT_DISPOSITION)) { |
||||
headers.setContentDispositionFormData(name, |
||||
(part instanceof FilePart ? ((FilePart) part).filename() : null)); |
||||
} |
||||
|
||||
return Flux.concat( |
||||
generateBoundaryLine(boundary, bufferFactory), |
||||
generatePartHeaders(headers, bufferFactory), |
||||
part.content(), |
||||
generateNewLine(bufferFactory)); |
||||
} |
||||
|
||||
} |
@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
|
||||
/* |
||||
* Copyright 2002-2020 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 |
||||
* |
||||
* https://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.multipart; |
||||
|
||||
import java.nio.charset.StandardCharsets; |
||||
import java.time.Duration; |
||||
import java.util.Collections; |
||||
import java.util.Map; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
import reactor.core.publisher.Flux; |
||||
|
||||
import org.springframework.core.ResolvableType; |
||||
import org.springframework.core.codec.StringDecoder; |
||||
import org.springframework.core.testfixture.io.buffer.AbstractLeakCheckingTests; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.util.MultiValueMap; |
||||
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpResponse; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.mockito.BDDMockito.given; |
||||
import static org.mockito.Mockito.mock; |
||||
import static org.springframework.http.codec.multipart.MultipartHttpMessageWriterTests.parse; |
||||
|
||||
/** |
||||
* Unit tests for {@link PartHttpMessageWriter}. |
||||
* |
||||
* @author Rossen Stoyanchev |
||||
* @since 5.3 |
||||
*/ |
||||
public class PartHttpMessageWriterTests extends AbstractLeakCheckingTests { |
||||
|
||||
private final PartHttpMessageWriter writer = new PartHttpMessageWriter(); |
||||
|
||||
private final MockServerHttpResponse response = new MockServerHttpResponse(this.bufferFactory); |
||||
|
||||
|
||||
@Test |
||||
public void canWrite() { |
||||
assertThat(this.writer.canWrite( |
||||
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), |
||||
MediaType.MULTIPART_FORM_DATA)).isTrue(); |
||||
assertThat(this.writer.canWrite( |
||||
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), |
||||
MediaType.MULTIPART_FORM_DATA)).isTrue(); |
||||
assertThat(this.writer.canWrite( |
||||
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), |
||||
MediaType.MULTIPART_MIXED)).isTrue(); |
||||
assertThat(this.writer.canWrite( |
||||
ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), |
||||
MediaType.MULTIPART_RELATED)).isTrue(); |
||||
|
||||
assertThat(this.writer.canWrite( |
||||
ResolvableType.forClassWithGenerics(Map.class, String.class, Object.class), |
||||
MediaType.MULTIPART_FORM_DATA)).isFalse(); |
||||
} |
||||
|
||||
@Test |
||||
void write() { |
||||
HttpHeaders headers = new HttpHeaders(); |
||||
headers.setContentType(MediaType.TEXT_PLAIN); |
||||
Part textPart = mock(Part.class); |
||||
given(textPart.name()).willReturn("text part"); |
||||
given(textPart.headers()).willReturn(headers); |
||||
given(textPart.content()).willReturn(Flux.just( |
||||
this.bufferFactory.wrap("text1".getBytes(StandardCharsets.UTF_8)), |
||||
this.bufferFactory.wrap("text2".getBytes(StandardCharsets.UTF_8)))); |
||||
|
||||
FilePart filePart = mock(FilePart.class); |
||||
given(filePart.name()).willReturn("file part"); |
||||
given(filePart.headers()).willReturn(new HttpHeaders()); |
||||
given(filePart.filename()).willReturn("file.txt"); |
||||
given(filePart.content()).willReturn(Flux.just( |
||||
this.bufferFactory.wrap("Aa".getBytes(StandardCharsets.UTF_8)), |
||||
this.bufferFactory.wrap("Bb".getBytes(StandardCharsets.UTF_8)), |
||||
this.bufferFactory.wrap("Cc".getBytes(StandardCharsets.UTF_8)) |
||||
)); |
||||
|
||||
Map<String, Object> hints = Collections.emptyMap(); |
||||
this.writer.write(Flux.just(textPart, filePart), null, MediaType.MULTIPART_FORM_DATA, this.response, hints) |
||||
.block(Duration.ofSeconds(5)); |
||||
|
||||
MultiValueMap<String, Part> requestParts = parse(this.response, hints); |
||||
assertThat(requestParts.size()).isEqualTo(2); |
||||
|
||||
Part part = requestParts.getFirst("text part"); |
||||
assertThat(part.name()).isEqualTo("text part"); |
||||
assertThat(part.headers().getContentType()).isEqualTo(MediaType.TEXT_PLAIN); |
||||
String value = decodeToString(part); |
||||
assertThat(value).isEqualTo("text1text2"); |
||||
|
||||
part = requestParts.getFirst("file part"); |
||||
assertThat(part.name()).isEqualTo("file part"); |
||||
assertThat(((FilePart) part).filename()).isEqualTo("file.txt"); |
||||
assertThat(decodeToString(part)).isEqualTo("AaBbCc"); |
||||
} |
||||
|
||||
@SuppressWarnings("ConstantConditions") |
||||
private String decodeToString(Part part) { |
||||
return StringDecoder.textPlainOnly().decodeToMono(part.content(), |
||||
ResolvableType.forClass(String.class), MediaType.TEXT_PLAIN, |
||||
Collections.emptyMap()).block(Duration.ZERO); |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue