Browse Source
This commit adds a reactive HttpMessageWriter that allows to write multipart HTML forms with multipart/form-data media type. Issue: SPR-14546pull/1201/head
Sebastien Deleuze
8 years ago
2 changed files with 524 additions and 0 deletions
@ -0,0 +1,356 @@
@@ -0,0 +1,356 @@
|
||||
/* |
||||
* Copyright 2002-2017 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.multipart; |
||||
|
||||
import java.io.UnsupportedEncodingException; |
||||
import java.nio.charset.Charset; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.util.Arrays; |
||||
import java.util.Collections; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
import java.util.Optional; |
||||
import java.util.concurrent.atomic.AtomicBoolean; |
||||
import java.util.function.Supplier; |
||||
|
||||
import javax.mail.internet.MimeUtility; |
||||
|
||||
import org.reactivestreams.Publisher; |
||||
import reactor.core.publisher.Flux; |
||||
import reactor.core.publisher.Mono; |
||||
import reactor.util.function.Tuples; |
||||
|
||||
import org.springframework.core.ResolvableType; |
||||
import org.springframework.core.codec.CharSequenceEncoder; |
||||
import org.springframework.core.codec.CodecException; |
||||
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.DefaultDataBufferFactory; |
||||
import org.springframework.http.HttpEntity; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.ReactiveHttpOutputMessage; |
||||
import org.springframework.http.codec.EncoderHttpMessageWriter; |
||||
import org.springframework.http.codec.HttpMessageWriter; |
||||
import org.springframework.http.codec.ResourceHttpMessageWriter; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.util.MimeTypeUtils; |
||||
import org.springframework.util.MultiValueMap; |
||||
|
||||
/** |
||||
* Implementation of {@link HttpMessageWriter} to write multipart HTML |
||||
* forms with {@code "multipart/form-data"} media type. |
||||
* |
||||
* <p>When writing multipart data, this writer uses other |
||||
* {@link HttpMessageWriter HttpMessageWriters} to write the respective |
||||
* MIME parts. By default, basic writers are registered (for {@code Strings} |
||||
* and {@code Resources}). These can be overridden through the provided |
||||
* constructors. |
||||
* |
||||
* @author Sebastien Deleuze |
||||
* @since 5.0 |
||||
*/ |
||||
public class MultipartHttpMessageWriter implements HttpMessageWriter<MultiValueMap<String, ?>> { |
||||
|
||||
public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; |
||||
|
||||
|
||||
private List<HttpMessageWriter<?>> partWriters; |
||||
|
||||
private Charset filenameCharset = DEFAULT_CHARSET; |
||||
|
||||
private final DataBufferFactory bufferFactory; |
||||
|
||||
|
||||
public MultipartHttpMessageWriter() { |
||||
this(new DefaultDataBufferFactory()); |
||||
} |
||||
|
||||
public MultipartHttpMessageWriter(DataBufferFactory bufferFactory) { |
||||
this.partWriters = Arrays.asList( |
||||
new EncoderHttpMessageWriter<>(CharSequenceEncoder.textPlainOnly()), |
||||
new ResourceHttpMessageWriter() |
||||
); |
||||
this.bufferFactory = bufferFactory; |
||||
} |
||||
|
||||
public MultipartHttpMessageWriter(List<HttpMessageWriter<?>> partWriters) { |
||||
this(partWriters, new DefaultDataBufferFactory()); |
||||
} |
||||
|
||||
public MultipartHttpMessageWriter(List<HttpMessageWriter<?>> partWriters, DataBufferFactory bufferFactory) { |
||||
this.partWriters = partWriters; |
||||
this.bufferFactory = bufferFactory; |
||||
} |
||||
|
||||
/** |
||||
* Set the character set to use for writing file names in the multipart request. |
||||
* <p>By default this is set to "UTF-8". |
||||
*/ |
||||
public void setFilenameCharset(Charset charset) { |
||||
Assert.notNull(charset, "'charset' must not be null"); |
||||
this.filenameCharset = charset; |
||||
} |
||||
|
||||
/** |
||||
* Return the configured filename charset. |
||||
*/ |
||||
public Charset getFilenameCharset() { |
||||
return this.filenameCharset; |
||||
} |
||||
|
||||
|
||||
@Override |
||||
public boolean canWrite(ResolvableType elementType, MediaType mediaType) { |
||||
return (mediaType == null || MediaType.MULTIPART_FORM_DATA.isCompatibleWith(mediaType)) && |
||||
(MultiValueMap.class.isAssignableFrom(elementType.getRawClass()) && String.class.isAssignableFrom(elementType.resolveGeneric(0))); |
||||
} |
||||
|
||||
@Override |
||||
public Mono<Void> write(Publisher<? extends MultiValueMap<String, ?>> inputStream, |
||||
ResolvableType elementType, MediaType mediaType, ReactiveHttpOutputMessage outputMessage, |
||||
Map<String, Object> hints) { |
||||
|
||||
final byte[] boundary = generateMultipartBoundary(); |
||||
Map<String, String> parameters = Collections.singletonMap("boundary", new String(boundary, StandardCharsets.US_ASCII)); |
||||
|
||||
MediaType contentType = new MediaType(MediaType.MULTIPART_FORM_DATA, parameters); |
||||
HttpHeaders headers = outputMessage.getHeaders(); |
||||
headers.setContentType(contentType); |
||||
|
||||
return Flux |
||||
.from(inputStream) |
||||
.single() |
||||
.flatMap(form -> { |
||||
Flux<DataBuffer> body = Flux.fromIterable(form.entrySet()) |
||||
.concatMap(entry -> Flux.fromIterable(entry.getValue()).map(value -> Tuples.of(entry.getKey(), value))) |
||||
.concatMap(part -> generatePart(part.getT1(), getHttpEntity(part.getT2()), boundary)) |
||||
.concatWith(Mono.just(generateLastLine(boundary))); |
||||
return outputMessage.writeWith(body); |
||||
}); |
||||
} |
||||
|
||||
@SuppressWarnings("unchecked") |
||||
private Flux<DataBuffer> generatePart(String name, HttpEntity<?> partEntity, byte[] boundary) { |
||||
Object partBody = partEntity.getBody(); |
||||
ResolvableType partType = ResolvableType.forClass(partBody.getClass()); |
||||
MultipartHttpOutputMessage outputMessage = new MultipartHttpOutputMessage(this.bufferFactory); |
||||
HttpHeaders partHeaders = outputMessage.getHeaders(); |
||||
outputMessage.getHeaders().putAll(partHeaders); |
||||
MediaType partContentType = partHeaders.getContentType(); |
||||
partHeaders.setContentDispositionFormData(name, getFilename(partBody)); |
||||
|
||||
Optional<HttpMessageWriter<?>> writer = this.partWriters |
||||
.stream() |
||||
.filter(e -> e.canWrite(partType, partContentType)) |
||||
.findFirst(); |
||||
|
||||
if(!writer.isPresent()) { |
||||
return Flux.error(new CodecException("No suitable writer found!")); |
||||
} |
||||
Mono<Void> partWritten = ((HttpMessageWriter<Object>)writer.get()) |
||||
.write(Mono.just(partBody), partType, partContentType, outputMessage, Collections.emptyMap()); |
||||
|
||||
// partWritten.subscribe() is required in order to make sure MultipartHttpOutputMessage#getBody()
|
||||
// returns a non-null value (occurs with ResourceHttpMessageWriter that invokes
|
||||
// ReactiveHttpOutputMessage.writeWith() only when at least one element has been
|
||||
// requested).
|
||||
partWritten.subscribe(); |
||||
|
||||
return Flux.concat( |
||||
Mono.just(generateBoundaryLine(boundary)), |
||||
outputMessage.getBody(), |
||||
Mono.just(generateNewLine()) |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Generate a multipart boundary. |
||||
* <p>This implementation delegates to |
||||
* {@link MimeTypeUtils#generateMultipartBoundary()}. |
||||
*/ |
||||
protected byte[] generateMultipartBoundary() { |
||||
return MimeTypeUtils.generateMultipartBoundary(); |
||||
} |
||||
|
||||
/** |
||||
* Return an {@link HttpEntity} for the given part Object. |
||||
* @param part the part to return an {@link HttpEntity} for |
||||
* @return the part Object itself it is an {@link HttpEntity}, |
||||
* or a newly built {@link HttpEntity} wrapper for that part |
||||
*/ |
||||
protected HttpEntity<?> getHttpEntity(Object part) { |
||||
if (part instanceof HttpEntity) { |
||||
return (HttpEntity<?>) part; |
||||
} |
||||
else { |
||||
return new HttpEntity<>(part); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Return the filename of the given multipart part. This value will be used for the |
||||
* {@code Content-Disposition} header. |
||||
* <p>The default implementation returns {@link Resource#getFilename()} if the part is a |
||||
* {@code Resource}, and {@code null} in other cases. Can be overridden in subclasses. |
||||
* @param part the part to determine the file name for |
||||
* @return the filename, or {@code null} if not known |
||||
*/ |
||||
protected String getFilename(Object part) { |
||||
if (part instanceof Resource) { |
||||
Resource resource = (Resource) part; |
||||
String filename = resource.getFilename(); |
||||
filename = MimeDelegate.encode(filename, this.filenameCharset.name()); |
||||
return filename; |
||||
} |
||||
else { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
|
||||
private DataBuffer generateBoundaryLine(byte[] boundary) { |
||||
DataBuffer buffer = this.bufferFactory.allocateBuffer(boundary.length + 4); |
||||
buffer.write((byte)'-'); |
||||
buffer.write((byte)'-'); |
||||
buffer.write(boundary); |
||||
buffer.write((byte)'\r'); |
||||
buffer.write((byte)'\n'); |
||||
return buffer; |
||||
} |
||||
|
||||
private DataBuffer generateNewLine() { |
||||
DataBuffer buffer = this.bufferFactory.allocateBuffer(2); |
||||
buffer.write((byte)'\r'); |
||||
buffer.write((byte)'\n'); |
||||
return buffer; |
||||
} |
||||
|
||||
private DataBuffer generateLastLine(byte[] boundary) { |
||||
DataBuffer buffer = this.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; |
||||
} |
||||
|
||||
@Override |
||||
public List<MediaType> getWritableMediaTypes() { |
||||
return Collections.singletonList(MediaType.MULTIPART_FORM_DATA); |
||||
} |
||||
|
||||
|
||||
private static class MultipartHttpOutputMessage implements ReactiveHttpOutputMessage { |
||||
|
||||
private final DataBufferFactory bufferFactory; |
||||
|
||||
private final HttpHeaders headers = new HttpHeaders(); |
||||
|
||||
private final AtomicBoolean commited = new AtomicBoolean(); |
||||
|
||||
private Flux<DataBuffer> body; |
||||
|
||||
|
||||
public MultipartHttpOutputMessage(DataBufferFactory bufferFactory) { |
||||
this.bufferFactory = bufferFactory; |
||||
} |
||||
|
||||
|
||||
@Override |
||||
public HttpHeaders getHeaders() { |
||||
return (this.body != null ? HttpHeaders.readOnlyHttpHeaders(this.headers) : this.headers); |
||||
} |
||||
|
||||
@Override |
||||
public DataBufferFactory bufferFactory() { |
||||
return this.bufferFactory; |
||||
} |
||||
|
||||
@Override |
||||
public void beforeCommit(Supplier<? extends Mono<Void>> action) { |
||||
this.commited.set(true); |
||||
} |
||||
|
||||
@Override |
||||
public boolean isCommitted() { |
||||
return this.commited.get(); |
||||
} |
||||
|
||||
@Override |
||||
public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) { |
||||
if (this.body != null) { |
||||
return Mono.error(new IllegalStateException("Multiple calls to writeWith() not supported")); |
||||
} |
||||
this.body = Flux.just(generateHeaders()).concatWith(body); |
||||
return this.body.then(); |
||||
} |
||||
|
||||
@Override |
||||
public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) { |
||||
return Mono.error(new UnsupportedOperationException()); |
||||
} |
||||
|
||||
private DataBuffer generateHeaders() { |
||||
DataBuffer buffer = this.bufferFactory.allocateBuffer(); |
||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) { |
||||
byte[] headerName = entry.getKey().getBytes(StandardCharsets.US_ASCII); |
||||
for (String headerValueString : entry.getValue()) { |
||||
byte[] headerValue = headerValueString.getBytes(StandardCharsets.US_ASCII); |
||||
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; |
||||
} |
||||
|
||||
@Override |
||||
public Mono<Void> setComplete() { |
||||
return (this.body != null ? this.body.then() : Mono.error(new IllegalStateException("Body has not been written yet"))); |
||||
} |
||||
|
||||
public Flux<DataBuffer> getBody() { |
||||
return (this.body != null ? this.body : Flux.error(new IllegalStateException("Body has not been written yet"))); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Inner class to avoid a hard dependency on the JavaMail API. |
||||
*/ |
||||
private static class MimeDelegate { |
||||
|
||||
public static String encode(String value, String charset) { |
||||
try { |
||||
return MimeUtility.encodeText(value, charset, null); |
||||
} |
||||
catch (UnsupportedEncodingException ex) { |
||||
throw new IllegalStateException(ex); |
||||
} |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
|
||||
/* |
||||
* Copyright 2002-2017 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.multipart; |
||||
|
||||
import java.util.Arrays; |
||||
import java.util.Collections; |
||||
import java.util.List; |
||||
import java.util.Map; |
||||
|
||||
import static org.junit.Assert.*; |
||||
import static org.junit.Assert.assertEquals; |
||||
import static org.junit.Assert.assertTrue; |
||||
import org.junit.Test; |
||||
import static org.springframework.http.HttpHeaders.CONTENT_TYPE; |
||||
import reactor.core.publisher.Mono; |
||||
|
||||
import org.springframework.core.ResolvableType; |
||||
import org.springframework.core.codec.CharSequenceEncoder; |
||||
import org.springframework.core.io.ClassPathResource; |
||||
import org.springframework.core.io.Resource; |
||||
import org.springframework.http.HttpEntity; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.codec.EncoderHttpMessageWriter; |
||||
import org.springframework.http.codec.ResourceHttpMessageWriter; |
||||
import org.springframework.http.codec.json.Jackson2JsonEncoder; |
||||
import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; |
||||
import org.springframework.mock.http.server.reactive.test.MockServerHttpResponse; |
||||
import org.springframework.util.LinkedMultiValueMap; |
||||
import org.springframework.util.MultiValueMap; |
||||
|
||||
/** |
||||
* @author Sebastien Deleuze |
||||
*/ |
||||
public class MultipartHttpMessageWriterTests { |
||||
|
||||
private final MultipartHttpMessageWriter writer = new MultipartHttpMessageWriter( |
||||
Arrays.asList( |
||||
new EncoderHttpMessageWriter<>(CharSequenceEncoder.textPlainOnly()), |
||||
new ResourceHttpMessageWriter(), |
||||
new EncoderHttpMessageWriter<>(new Jackson2JsonEncoder()) |
||||
)); |
||||
|
||||
@Test |
||||
public void canWrite() { |
||||
assertTrue(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), |
||||
MediaType.MULTIPART_FORM_DATA)); |
||||
assertTrue(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), |
||||
MediaType.MULTIPART_FORM_DATA)); |
||||
assertFalse(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, Object.class, Object.class), |
||||
MediaType.MULTIPART_FORM_DATA)); |
||||
assertFalse(this.writer.canWrite(ResolvableType.forClassWithGenerics(Map.class, String.class, Object.class), |
||||
MediaType.MULTIPART_FORM_DATA)); |
||||
assertFalse(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), |
||||
MediaType.APPLICATION_FORM_URLENCODED)); |
||||
} |
||||
|
||||
@Test |
||||
public void writeMultipart() throws Exception { |
||||
MultiValueMap<String, Object> parts = new LinkedMultiValueMap<>(); |
||||
parts.add("name 1", "value 1"); |
||||
parts.add("name 2", "value 2+1"); |
||||
parts.add("name 2", "value 2+2"); |
||||
|
||||
Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg"); |
||||
parts.add("logo", logo); |
||||
|
||||
// SPR-12108
|
||||
Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") { |
||||
@Override |
||||
public String getFilename() { |
||||
return "Hall\u00F6le.jpg"; |
||||
} |
||||
}; |
||||
parts.add("utf8", utf8); |
||||
|
||||
Foo foo = new Foo("bar"); |
||||
HttpHeaders entityHeaders = new HttpHeaders(); |
||||
entityHeaders.setContentType(MediaType.APPLICATION_JSON_UTF8); |
||||
HttpEntity<Foo> entity = new HttpEntity<>(foo, entityHeaders); |
||||
parts.add("json", entity); |
||||
|
||||
MockServerHttpResponse response = new MockServerHttpResponse(); |
||||
this.writer.write(Mono.just(parts), null, MediaType.MULTIPART_FORM_DATA, response, Collections.emptyMap()).block(); |
||||
|
||||
final MediaType contentType = response.getHeaders().getContentType(); |
||||
assertNotNull("No boundary found", contentType.getParameter("boundary")); |
||||
|
||||
// see if NIO Multipart can read what we wrote
|
||||
MultipartHttpMessageReader multipartReader = new SynchronossMultipartHttpMessageReader(); |
||||
MockServerHttpRequest request = MockServerHttpRequest.post("/foo") |
||||
.header(CONTENT_TYPE, contentType.toString()) |
||||
.body(response.getBody()); |
||||
|
||||
MultiValueMap<String, Part> requestParts = multipartReader. |
||||
readMono(MultipartHttpMessageReader.MULTIPART_VALUE_TYPE, request, Collections.emptyMap()).block(); |
||||
assertEquals(5, requestParts.size()); |
||||
|
||||
|
||||
Part part = requestParts.getFirst("name 1"); |
||||
assertEquals("name 1", part.getName()); |
||||
assertEquals("value 1", part.getContentAsString().block()); |
||||
assertFalse(part.getFilename().isPresent()); |
||||
|
||||
List<Part> part2 = requestParts.get("name 2"); |
||||
assertEquals(2, part2.size()); |
||||
part = part2.get(0); |
||||
assertEquals("name 2", part.getName()); |
||||
assertEquals("value 2+1", part.getContentAsString().block()); |
||||
part = part2.get(1); |
||||
assertEquals("name 2", part.getName()); |
||||
assertEquals("value 2+2", part.getContentAsString().block()); |
||||
|
||||
part = requestParts.getFirst("logo"); |
||||
assertEquals("logo", part.getName()); |
||||
assertTrue(part.getFilename().isPresent()); |
||||
assertEquals("logo.jpg", part.getFilename().get()); |
||||
assertEquals(MediaType.IMAGE_JPEG, part.getHeaders().getContentType()); |
||||
assertEquals(logo.getFile().length(), part.getHeaders().getContentLength()); |
||||
|
||||
part = requestParts.getFirst("utf8"); |
||||
assertEquals("utf8", part.getName()); |
||||
assertTrue(part.getFilename().isPresent()); |
||||
assertEquals("Hall\u00F6le.jpg", part.getFilename().get()); |
||||
assertEquals(MediaType.IMAGE_JPEG, part.getHeaders().getContentType()); |
||||
assertEquals(utf8.getFile().length(), part.getHeaders().getContentLength()); |
||||
|
||||
part = requestParts.getFirst("json"); |
||||
assertEquals("json", part.getName()); |
||||
assertEquals(MediaType.APPLICATION_JSON_UTF8, part.getHeaders().getContentType()); |
||||
assertEquals("{\"bar\":\"bar\"}", part.getContentAsString().block()); |
||||
} |
||||
|
||||
private class Foo { |
||||
|
||||
private String bar; |
||||
|
||||
public Foo() { |
||||
} |
||||
|
||||
public Foo(String bar) { |
||||
this.bar = bar; |
||||
} |
||||
|
||||
public String getBar() { |
||||
return bar; |
||||
} |
||||
|
||||
public void setBar(String bar) { |
||||
this.bar = bar; |
||||
} |
||||
} |
||||
|
||||
} |
Loading…
Reference in new issue