diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageReader.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageReader.java deleted file mode 100644 index 8725bcb6fc..0000000000 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageReader.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * 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.Collections; -import java.util.List; -import java.util.Map; - -import reactor.core.publisher.Flux; - -import org.springframework.core.ResolvableType; -import org.springframework.http.MediaType; -import org.springframework.http.ReactiveHttpInputMessage; -import org.springframework.http.codec.HttpMessageReader; -import org.springframework.util.MultiValueMap; - -/** - * Interface for reading multipart HTML forms with {@code "multipart/form-data"} media - * type in accordance with RFC 7578. - * - * @author Sebastien Deleuze - * @since 5.0 - */ -public interface MultipartHttpMessageReader extends HttpMessageReader> { - - ResolvableType MULTIPART_VALUE_TYPE = - ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class); - - @Override - default List getReadableMediaTypes() { - return Collections.singletonList(MediaType.MULTIPART_FORM_DATA); - } - - @Override - default boolean canRead(ResolvableType elementType, MediaType mediaType) { - return MULTIPART_VALUE_TYPE.isAssignableFrom(elementType) && - (mediaType == null || MediaType.MULTIPART_FORM_DATA.isCompatibleWith(mediaType)); - } - - @Override - default Flux> read(ResolvableType elementType, ReactiveHttpInputMessage message, Map hints) { - return Flux.from(readMono(elementType, message, hints)); - } -} diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriter.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriter.java index 579d5fb71e..8932f28b32 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriter.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriter.java @@ -32,7 +32,6 @@ 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; @@ -53,16 +52,14 @@ 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. + * {@code HttpMessageWriter} for {@code "multipart/form-data"} requests. * - *

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. + *

This writer delegates to other message writers to write the respective + * parts. By default basic writers are registered for {@code String}, and + * {@code Resources}. These can be overridden through the provided constructors. * * @author Sebastien Deleuze + * @author Rossen Stoyanchev * @since 5.0 */ public class MultipartHttpMessageWriter implements HttpMessageWriter> { @@ -70,7 +67,7 @@ public class MultipartHttpMessageWriter implements HttpMessageWriter> partWriters; + private final List> partWriters; private Charset filenameCharset = DEFAULT_CHARSET; @@ -93,9 +90,9 @@ public class MultipartHttpMessageWriter implements HttpMessageWriter> partWriters, DataBufferFactory bufferFactory) { + public MultipartHttpMessageWriter(List> partWriters, DataBufferFactory factory) { this.partWriters = partWriters; - this.bufferFactory = bufferFactory; + this.bufferFactory = factory; } /** @@ -115,10 +112,15 @@ public class MultipartHttpMessageWriter implements HttpMessageWriter getWritableMediaTypes() { + return Collections.singletonList(MediaType.MULTIPART_FORM_DATA); + } + @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))); + return MultiValueMap.class.isAssignableFrom(elementType.getRawClass()) && + (mediaType == null || MediaType.MULTIPART_FORM_DATA.isCompatibleWith(mediaType)); } @Override @@ -126,45 +128,61 @@ public class MultipartHttpMessageWriter implements HttpMessageWriter hints) { - final byte[] boundary = generateMultipartBoundary(); - Map parameters = Collections.singletonMap("boundary", new String(boundary, StandardCharsets.US_ASCII)); + byte[] boundary = generateMultipartBoundary(); - MediaType contentType = new MediaType(MediaType.MULTIPART_FORM_DATA, parameters); HttpHeaders headers = outputMessage.getHeaders(); - headers.setContentType(contentType); + headers.setContentType(new MediaType(MediaType.MULTIPART_FORM_DATA, + Collections.singletonMap("boundary", new String(boundary, StandardCharsets.US_ASCII)))); - return Flux - .from(inputStream) - .single() - .flatMap(form -> { - Flux 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); - }); + return Mono.from(inputStream).flatMap(multiValueMap -> + outputMessage.writeWith(generateParts(multiValueMap, boundary))); + } + + /** + * Generate a multipart boundary. + *

By default delegates to {@link MimeTypeUtils#generateMultipartBoundary()}. + */ + protected byte[] generateMultipartBoundary() { + return MimeTypeUtils.generateMultipartBoundary(); + } + + private Flux generateParts(MultiValueMap map, byte[] boundary) { + return Flux.fromIterable(map.entrySet()) + .concatMap(entry -> Flux + .fromIterable(entry.getValue()) + .concatMap(value -> generatePart(entry.getKey(), value, boundary))) + .concatWith(Mono.just(generateLastLine(boundary))); } @SuppressWarnings("unchecked") - private Flux 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)); + private Flux generatePart(String name, T value, byte[] boundary) { - Optional> writer = this.partWriters - .stream() - .filter(e -> e.canWrite(partType, partContentType)) + MultipartHttpOutputMessage outputMessage = new MultipartHttpOutputMessage(this.bufferFactory); + + T body; + if (value instanceof HttpEntity) { + outputMessage.getHeaders().putAll(((HttpEntity) value).getHeaders()); + body = ((HttpEntity) value).getBody(); + } + else { + body = value; + } + + ResolvableType bodyType = ResolvableType.forClass(body.getClass()); + outputMessage.getHeaders().setContentDispositionFormData(name, getFilename(body)); + + MediaType contentType = outputMessage.getHeaders().getContentType(); + + Optional> writer = this.partWriters.stream() + .filter(partWriter -> partWriter.canWrite(bodyType, contentType)) .findFirst(); if(!writer.isPresent()) { - return Flux.error(new CodecException("No suitable writer found!")); + return Flux.error(new CodecException("No suitable writer found for part: " + name)); } - Mono partWritten = ((HttpMessageWriter)writer.get()) - .write(Mono.just(partBody), partType, partContentType, outputMessage, Collections.emptyMap()); + + Mono partWritten = ((HttpMessageWriter) writer.get()) + .write(Mono.just(body), bodyType, contentType, outputMessage, Collections.emptyMap()); // partWritten.subscribe() is required in order to make sure MultipartHttpOutputMessage#getBody() // returns a non-null value (occurs with ResourceHttpMessageWriter that invokes @@ -180,36 +198,12 @@ public class MultipartHttpMessageWriter implements HttpMessageWriterThis 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. - *

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 + * Return the filename of the given multipart part. This value will be used + * for the {@code Content-Disposition} header. + *

The default implementation returns {@link Resource#getFilename()} if + * the part is a {@code Resource}, and {@code null} in other cases. + * @param part the part for which return a file name + * @return the filename or {@code null} */ protected String getFilename(Object part) { if (part instanceof Resource) { @@ -223,7 +217,6 @@ public class MultipartHttpMessageWriter implements HttpMessageWriter getWritableMediaTypes() { - return Collections.singletonList(MediaType.MULTIPART_FORM_DATA); - } - private static class MultipartHttpOutputMessage implements ReactiveHttpOutputMessage { @@ -304,11 +292,6 @@ public class MultipartHttpMessageWriter implements HttpMessageWriter writeAndFlushWith(Publisher> body) { - return Mono.error(new UnsupportedOperationException()); - } - private DataBuffer generateHeaders() { DataBuffer buffer = this.bufferFactory.allocateBuffer(); for (Map.Entry> entry : headers.entrySet()) { @@ -329,13 +312,21 @@ public class MultipartHttpMessageWriter implements HttpMessageWriter setComplete() { - return (this.body != null ? this.body.then() : Mono.error(new IllegalStateException("Body has not been written yet"))); + public Mono writeAndFlushWith(Publisher> body) { + return Mono.error(new UnsupportedOperationException()); } public Flux getBody() { - return (this.body != null ? this.body : Flux.error(new IllegalStateException("Body has not been written yet"))); + return (this.body != null ? this.body : + Flux.error(new IllegalStateException("Body has not been written yet"))); } + + @Override + public Mono setComplete() { + return (this.body != null ? this.body.then() : + Mono.error(new IllegalStateException("Body has not been written yet"))); + } + } /** diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/Part.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/Part.java index 2c65f58557..e8bcc15d98 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/Part.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/Part.java @@ -26,45 +26,56 @@ import org.springframework.core.io.buffer.DataBuffer; import org.springframework.http.HttpHeaders; /** - * A representation of a part received in a multipart request. Could contain a file, the - * string or json value of a parameter. + * Representation for a part in a "multipart/form-data" request. + * + *

The origin of a multipart request may a browser form in which case each + * part represents a text-based form field or a file upload. Multipart requests + * may also be used outside of browsers to transfer data with any content type + * such as JSON, PDF, etc. * * @author Sebastien Deleuze + * @author Rossen Stoyanchev * @since 5.0 + * @see RFC 7578 (multipart/form-data) + * @see RFC 2183 (Content-Disposition) + * @see HTML5 (multipart forms) */ public interface Part { /** - * @return the headers of this part - */ - HttpHeaders getHeaders(); - - /** - * @return the name of the parameter in the multipart form + * Return the name of the part in the multipart form. + * @return the name of the part, never {@code null} or empty */ String getName(); /** - * @return optionally the filename if the part contains a file + * Return the headers associated with the part. + */ + HttpHeaders getHeaders(); + + /** + * + * Return the name of the file selected by the user in a browser form. + * @return the filename if defined and available */ Optional getFilename(); /** - * @return the content of the part as a String using the charset specified in the - * {@code Content-Type} header if any, or else using {@code UTF-8} by default. + * Return the part content converted to a String with the charset from the + * {@code Content-Type} header or {@code UTF-8} by default. */ Mono getContentAsString(); /** - * @return the content of the part as a stream of bytes + * Return the part raw content as a stream of DataBuffer's. */ Flux getContent(); /** - * Transfer the file contained in this part to the specified destination. - * @param dest the destination file - * @return a {@link Mono} that indicates completion of the file transfer or an error, - * for example an {@link IllegalStateException} if the part does not contain a file + * Transfer the file in this part to the given file destination. + * @param dest the target file + * @return completion {@code Mono} with the result of the file transfer, + * possibly {@link IllegalStateException} if the part isn't a file */ Mono transferTo(File dest); diff --git a/spring-web/src/main/java/org/springframework/http/codec/multipart/SynchronossMultipartHttpMessageReader.java b/spring-web/src/main/java/org/springframework/http/codec/multipart/SynchronossMultipartHttpMessageReader.java index 47aa5a76eb..153574f86a 100644 --- a/spring-web/src/main/java/org/springframework/http/codec/multipart/SynchronossMultipartHttpMessageReader.java +++ b/spring-web/src/main/java/org/springframework/http/codec/multipart/SynchronossMultipartHttpMessageReader.java @@ -26,6 +26,8 @@ import java.nio.channels.ReadableByteChannel; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -52,74 +54,102 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ReactiveHttpInputMessage; import org.springframework.http.codec.HttpMessageReader; +import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MimeType; import org.springframework.util.MultiValueMap; import org.springframework.util.StreamUtils; /** - * Implementation of {@link HttpMessageReader} to read multipart HTML - * forms with {@code "multipart/form-data"} media type in accordance - * with RFC 7578 based + * {@code HttpMessageReader} for {@code "multipart/form-data"} requests based * on the Synchronoss NIO Multipart library. * * @author Sebastien Deleuze + * @author Rossen Stoyanchev * @since 5.0 * @see Synchronoss NIO Multipart */ -public class SynchronossMultipartHttpMessageReader implements MultipartHttpMessageReader { +public class SynchronossMultipartHttpMessageReader implements HttpMessageReader> { + + private static final ResolvableType MULTIPART_VALUE_TYPE = ResolvableType.forClassWithGenerics( + MultiValueMap.class, String.class, Part.class); + @Override - public Mono> readMono(ResolvableType elementType, ReactiveHttpInputMessage inputMessage, Map hints) { + public List getReadableMediaTypes() { + return Collections.singletonList(MediaType.MULTIPART_FORM_DATA); + } - return Flux.create(new NioMultipartConsumer(inputMessage)) - .collectMultimap(part -> part.getName()) - .map(partsMap -> new LinkedMultiValueMap<>(partsMap - .entrySet() - .stream() - .collect(Collectors.toMap( - entry -> entry.getKey(), - entry -> new ArrayList<>(entry.getValue())) - ))); + @Override + public boolean canRead(ResolvableType elementType, MediaType mediaType) { + return MULTIPART_VALUE_TYPE.isAssignableFrom(elementType) && + (mediaType == null || MediaType.MULTIPART_FORM_DATA.isCompatibleWith(mediaType)); } - private static class NioMultipartConsumer implements Consumer> { + @Override + public Flux> read(ResolvableType elementType, + ReactiveHttpInputMessage message, Map hints) { + + return Flux.from(readMono(elementType, message, hints)); + } + + + @Override + public Mono> readMono(ResolvableType elementType, + ReactiveHttpInputMessage inputMessage, Map hints) { + + return Flux.create(new SynchronossPartGenerator(inputMessage)) + .collectMultimap(Part::getName).map(this::toMultiValueMap); + } + + private LinkedMultiValueMap toMultiValueMap(Map> map) { + return new LinkedMultiValueMap<>(map.entrySet().stream() + .collect(Collectors.toMap(Map.Entry::getKey, e -> toList(e.getValue())))); + } + + private List toList(Collection collection) { + return collection instanceof List ? (List) collection : new ArrayList<>(collection); + } + + + /** + * Consume and feed input to the Synchronoss parser, then adapt parser + * output events to {@code Flux>}. + */ + private static class SynchronossPartGenerator implements Consumer> { private final ReactiveHttpInputMessage inputMessage; - public NioMultipartConsumer(ReactiveHttpInputMessage inputMessage) { + SynchronossPartGenerator(ReactiveHttpInputMessage inputMessage) { this.inputMessage = inputMessage; } @Override public void accept(FluxSink emitter) { - HttpHeaders headers = inputMessage.getHeaders(); - MultipartContext context = new MultipartContext( - headers.getContentType().toString(), - Math.toIntExact(headers.getContentLength()), - headers.getFirst(HttpHeaders.ACCEPT_CHARSET)); - NioMultipartParserListener listener = new ReactiveNioMultipartParserListener(emitter); + + MultipartContext context = createMultipartContext(); + NioMultipartParserListener listener = new FluxSinkAdapterListener(emitter); NioMultipartParser parser = Multipart.multipart(context).forNIO(listener); - inputMessage.getBody().subscribe(buffer -> { + this.inputMessage.getBody().subscribe(buffer -> { byte[] resultBytes = new byte[buffer.readableByteCount()]; buffer.read(resultBytes); try { parser.write(resultBytes); } catch (IOException ex) { - listener.onError("Exception thrown while closing the parser", ex); + listener.onError("Exception thrown providing input to the parser", ex); } - - }, (e) -> { + }, (ex) -> { try { - listener.onError("Exception thrown while reading the request body", e); + listener.onError("Request body input error", ex); parser.close(); } - catch (IOException ex) { - listener.onError("Exception thrown while closing the parser", ex); + catch (IOException ex2) { + listener.onError("Exception thrown while closing the parser", ex2); } }, () -> { try { @@ -132,85 +162,100 @@ public class SynchronossMultipartHttpMessageReader implements MultipartHttpMessa } - private static class ReactiveNioMultipartParserListener implements NioMultipartParserListener { - - private FluxSink emitter; - - private final AtomicInteger errorCount = new AtomicInteger(0); + private MultipartContext createMultipartContext() { + HttpHeaders headers = this.inputMessage.getHeaders(); + String contentType = headers.getContentType().toString(); + int contentLength = Math.toIntExact(headers.getContentLength()); + String charset = headers.getFirst(HttpHeaders.ACCEPT_CHARSET); + return new MultipartContext(contentType, contentLength, charset); + } - public ReactiveNioMultipartParserListener(FluxSink emitter) { - this.emitter = emitter; + } + /** + * Listen for parser output and adapt to {@code Flux>}. + */ + private static class FluxSinkAdapterListener implements NioMultipartParserListener { + + private final FluxSink sink; + + private final AtomicInteger terminated = new AtomicInteger(0); + + + FluxSinkAdapterListener(FluxSink sink) { + this.sink = sink; + } + + + @Override + public void onPartFinished(StreamStorage storage, Map> headers) { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.putAll(headers); + this.sink.next(new SynchronossPart(httpHeaders, storage)); + } + + @Override + public void onFormFieldPartFinished(String name, String value, Map> headers) { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.putAll(headers); + this.sink.next(new SynchronossPart(httpHeaders, value)); + } + + @Override + public void onError(String message, Throwable cause) { + if (this.terminated.getAndIncrement() == 0) { + this.sink.error(new RuntimeException(message, cause)); } + } - - @Override - public void onPartFinished(StreamStorage streamStorage, Map> headersFromPart) { - HttpHeaders headers = new HttpHeaders(); - headers.putAll(headersFromPart); - emitter.next(new NioPart(headers, streamStorage)); + @Override + public void onAllPartsFinished() { + if (this.terminated.getAndIncrement() == 0) { + this.sink.complete(); } + } - @Override - public void onFormFieldPartFinished(String fieldName, String fieldValue, Map> headersFromPart) { - HttpHeaders headers = new HttpHeaders(); - headers.putAll(headersFromPart); - emitter.next(new NioPart(headers, fieldValue)); - } - - @Override - public void onAllPartsFinished() { - emitter.complete(); - } - - @Override - public void onNestedPartStarted(Map> headersFromParentPart) { - } - - @Override - public void onNestedPartFinished() { - } - - @Override - public void onError(String message, Throwable cause) { - if (errorCount.getAndIncrement() == 1) { - emitter.error(new RuntimeException(message, cause)); - } - } + @Override + public void onNestedPartStarted(Map> headersFromParentPart) { + } + @Override + public void onNestedPartFinished() { } } - /** - * {@link Part} implementation based on the NIO Multipart library. - */ - private static class NioPart implements Part { + + private static class SynchronossPart implements Part { private final HttpHeaders headers; - private final StreamStorage streamStorage; + private final StreamStorage storage; private final String content; private final DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); - public NioPart(HttpHeaders headers, StreamStorage streamStorage) { + SynchronossPart(HttpHeaders headers, StreamStorage storage) { + Assert.notNull(headers, "HttpHeaders is required"); + Assert.notNull(storage, "'storage' is required"); this.headers = headers; - this.streamStorage = streamStorage; + this.storage = storage; this.content = null; } - public NioPart(HttpHeaders headers, String content) { + SynchronossPart(HttpHeaders headers, String content) { + Assert.notNull(headers, "HttpHeaders is required"); + Assert.notNull(content, "'content' is required"); this.headers = headers; - this.streamStorage = null; + this.storage = null; this.content = content; } @Override public String getName() { - return MultipartUtils.getFieldName(headers); + return MultipartUtils.getFieldName(this.headers); } @Override @@ -223,45 +268,27 @@ public class SynchronossMultipartHttpMessageReader implements MultipartHttpMessa return Optional.ofNullable(MultipartUtils.getFileName(this.headers)); } - @Override - public Mono transferTo(File dest) { - if (!getFilename().isPresent()) { - return Mono.error(new IllegalStateException("The part does not contain a file.")); - } - try { - InputStream inputStream = this.streamStorage.getInputStream(); - // Get a FileChannel when possible in order to use zero copy mechanism - ReadableByteChannel inChannel = Channels.newChannel(inputStream); - FileChannel outChannel = new FileOutputStream(dest).getChannel(); - // NIO Multipart has previously limited the size of the content - long count = (inChannel instanceof FileChannel ? ((FileChannel)inChannel).size() : Long.MAX_VALUE); - long result = outChannel.transferFrom(inChannel, 0, count); - if (result < count) { - return Mono.error(new IOException( - "Could only write " + result + " out of " + count + " bytes")); - } - } - catch (IOException ex) { - return Mono.error(ex); - } - return Mono.empty(); - } - @Override public Mono getContentAsString() { if (this.content != null) { return Mono.just(this.content); } - MediaType contentType = this.headers.getContentType(); - Charset charset = (contentType.getCharset() == null ? StandardCharsets.UTF_8 : contentType.getCharset()); try { - return Mono.just(StreamUtils.copyToString(this.streamStorage.getInputStream(), charset)); + InputStream inputStream = this.storage.getInputStream(); + Charset charset = getCharset(); + return Mono.just(StreamUtils.copyToString(inputStream, charset)); } catch (IOException e) { - return Mono.error(new IllegalStateException("Error while reading part content as a string", e)); + return Mono.error(new IllegalStateException( + "Error while reading part content as a string", e)); } } + private Charset getCharset() { + return Optional.ofNullable(this.headers.getContentType()) + .map(MimeType::getCharset).orElse(StandardCharsets.UTF_8); + } + @Override public Flux getContent() { if (this.content != null) { @@ -269,9 +296,29 @@ public class SynchronossMultipartHttpMessageReader implements MultipartHttpMessa buffer.write(this.content.getBytes()); return Flux.just(buffer); } - InputStream inputStream = this.streamStorage.getInputStream(); + InputStream inputStream = this.storage.getInputStream(); return DataBufferUtils.read(inputStream, this.bufferFactory, 4096); } + @Override + public Mono transferTo(File dest) { + if (this.storage == null || !getFilename().isPresent()) { + return Mono.error(new IllegalStateException("The part does not represent a file.")); + } + try { + ReadableByteChannel ch = Channels.newChannel(this.storage.getInputStream()); + long expected = (ch instanceof FileChannel ? ((FileChannel) ch).size() : Long.MAX_VALUE); + long actual = new FileOutputStream(dest).getChannel().transferFrom(ch, 0, expected); + if (actual < expected) { + return Mono.error(new IOException( + "Could only write " + actual + " out of " + expected + " bytes")); + } + } + catch (IOException ex) { + return Mono.error(ex); + } + return Mono.empty(); + } } + } diff --git a/spring-web/src/main/java/org/springframework/web/server/ServerWebExchange.java b/spring-web/src/main/java/org/springframework/web/server/ServerWebExchange.java index 9b026b30a6..9e675f5310 100644 --- a/spring-web/src/main/java/org/springframework/web/server/ServerWebExchange.java +++ b/spring-web/src/main/java/org/springframework/web/server/ServerWebExchange.java @@ -79,13 +79,15 @@ public interface ServerWebExchange { /** * Return the form data from the body of the request if the Content-Type is - * {@code "application/x-www-form-urlencoded"} or an empty map. + * {@code "application/x-www-form-urlencoded"} or an empty map otherwise. + * This method may be called multiple times. */ Mono> getFormData(); /** - * Return the form parts from the body of the request or an empty {@code Mono} - * if the Content-Type is not "multipart/form-data". + * Return the parts of a multipart request if the Content-Type is + * {@code "multipart/form-data"} or an empty map otherwise. + * This method may be called multiple times. */ Mono> getMultipartData(); diff --git a/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java b/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java index 46cd0da88f..c6e92f4d8a 100644 --- a/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java +++ b/spring-web/src/main/java/org/springframework/web/server/adapter/DefaultServerWebExchange.java @@ -48,8 +48,8 @@ import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebSession; import org.springframework.web.server.session.WebSessionManager; -import static org.springframework.http.MediaType.*; -import static org.springframework.http.codec.multipart.MultipartHttpMessageReader.*; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA; /** * Default implementation of {@link ServerWebExchange}. @@ -64,6 +64,9 @@ public class DefaultServerWebExchange implements ServerWebExchange { private static final ResolvableType FORM_DATA_VALUE_TYPE = ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class); + private static final ResolvableType MULTIPART_VALUE_TYPE = ResolvableType.forClassWithGenerics( + MultiValueMap.class, String.class, Part.class); + private static final Mono> EMPTY_FORM_DATA = Mono.just(CollectionUtils.unmodifiableMultiValueMap(new LinkedMultiValueMap(0))) .cache(); @@ -121,9 +124,10 @@ public class DefaultServerWebExchange implements ServerWebExchange { return ((HttpMessageReader>)codecConfigurer .getReaders() .stream() - .filter(messageReader -> messageReader.canRead(FORM_DATA_VALUE_TYPE, APPLICATION_FORM_URLENCODED)) + .filter(reader -> reader.canRead(FORM_DATA_VALUE_TYPE, APPLICATION_FORM_URLENCODED)) .findFirst() - .orElseThrow(() -> new IllegalStateException("Could not find HttpMessageReader that supports " + APPLICATION_FORM_URLENCODED))) + .orElseThrow(() -> new IllegalStateException( + "Could not find HttpMessageReader that supports " + APPLICATION_FORM_URLENCODED))) .readMono(FORM_DATA_VALUE_TYPE, request, Collections.emptyMap()) .switchIfEmpty(EMPTY_FORM_DATA) .cache(); @@ -143,12 +147,13 @@ public class DefaultServerWebExchange implements ServerWebExchange { try { contentType = request.getHeaders().getContentType(); if (MULTIPART_FORM_DATA.isCompatibleWith(contentType)) { - return ((HttpMessageReader>)codecConfigurer + return ((HttpMessageReader>) codecConfigurer .getReaders() .stream() - .filter(messageReader -> messageReader.canRead(MULTIPART_VALUE_TYPE, MULTIPART_FORM_DATA)) + .filter(reader -> reader.canRead(MULTIPART_VALUE_TYPE, MULTIPART_FORM_DATA)) .findFirst() - .orElseThrow(() -> new IllegalStateException("Could not find HttpMessageReader that supports " + MULTIPART_FORM_DATA))) + .orElseThrow(() -> new IllegalStateException( + "Could not find HttpMessageReader that supports " + MULTIPART_FORM_DATA))) .readMono(FORM_DATA_VALUE_TYPE, request, Collections.emptyMap()) .switchIfEmpty(EMPTY_MULTIPART_DATA) .cache(); diff --git a/spring-web/src/test/java/org/springframework/http/codec/multipart/MultipartHttpMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/multipart/MultipartHttpMessageReaderTests.java deleted file mode 100644 index 6d312d995c..0000000000 --- a/spring-web/src/test/java/org/springframework/http/codec/multipart/MultipartHttpMessageReaderTests.java +++ /dev/null @@ -1,69 +0,0 @@ -/* - * 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.Map; - -import org.junit.Before; -import org.junit.Test; - -import org.springframework.core.ResolvableType; -import org.springframework.http.MediaType; -import org.springframework.util.MultiValueMap; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - - -/** - * @author Sebastien Deleuze - */ -public class MultipartHttpMessageReaderTests { - - private MultipartHttpMessageReader reader; - - @Before - public void setUp() throws Exception { - this.reader = (elementType, message, hints) -> { - throw new UnsupportedOperationException(); - }; - } - - @Test - public void canRead() { - assertTrue(this.reader.canRead( - ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), - MediaType.MULTIPART_FORM_DATA)); - - assertFalse(this.reader.canRead( - ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), - MediaType.MULTIPART_FORM_DATA)); - - assertFalse(this.reader.canRead( - ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), - MediaType.MULTIPART_FORM_DATA)); - - assertFalse(this.reader.canRead( - ResolvableType.forClassWithGenerics(Map.class, String.class, String.class), - MediaType.MULTIPART_FORM_DATA)); - - assertFalse(this.reader.canRead( - ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), - MediaType.APPLICATION_FORM_URLENCODED)); - } - -} diff --git a/spring-web/src/test/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriterTests.java b/spring-web/src/test/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriterTests.java index 13e791b274..afc7a366cd 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriterTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/multipart/MultipartHttpMessageWriterTests.java @@ -25,6 +25,8 @@ 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.core.ResolvableType.forClassWithGenerics; import static org.springframework.http.HttpHeaders.CONTENT_TYPE; import reactor.core.publisher.Mono; @@ -57,27 +59,27 @@ public class MultipartHttpMessageWriterTests { @Test public void canWrite() { - assertTrue(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), + + assertTrue(this.writer.canWrite(forClassWithGenerics(MultiValueMap.class, String.class, Object.class), MediaType.MULTIPART_FORM_DATA)); - assertTrue(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), + assertTrue(this.writer.canWrite(forClassWithGenerics(MultiValueMap.class, String.class, String.class), MediaType.MULTIPART_FORM_DATA)); - assertFalse(this.writer.canWrite(ResolvableType.forClassWithGenerics(MultiValueMap.class, Object.class, Object.class), + + assertFalse(this.writer.canWrite(forClassWithGenerics(Map.class, String.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), + assertFalse(this.writer.canWrite(forClassWithGenerics(MultiValueMap.class, String.class, Object.class), MediaType.APPLICATION_FORM_URLENCODED)); } @Test public void writeMultipart() throws Exception { - MultiValueMap parts = new LinkedMultiValueMap<>(); - parts.add("name 1", "value 1"); - parts.add("name 2", "value 2+1"); - parts.add("name 2", "value 2+2"); + MultiValueMap map = new LinkedMultiValueMap<>(); + map.add("name 1", "value 1"); + map.add("name 2", "value 2+1"); + map.add("name 2", "value 2+2"); Resource logo = new ClassPathResource("/org/springframework/http/converter/logo.jpg"); - parts.add("logo", logo); + map.add("logo", logo); // SPR-12108 Resource utf8 = new ClassPathResource("/org/springframework/http/converter/logo.jpg") { @@ -86,28 +88,28 @@ public class MultipartHttpMessageWriterTests { return "Hall\u00F6le.jpg"; } }; - parts.add("utf8", utf8); + map.add("utf8", utf8); - Foo foo = new Foo("bar"); HttpHeaders entityHeaders = new HttpHeaders(); entityHeaders.setContentType(MediaType.APPLICATION_JSON_UTF8); - HttpEntity entity = new HttpEntity<>(foo, entityHeaders); - parts.add("json", entity); + HttpEntity entity = new HttpEntity<>(new Foo("bar"), entityHeaders); + map.add("json", entity); MockServerHttpResponse response = new MockServerHttpResponse(); - this.writer.write(Mono.just(parts), null, MediaType.MULTIPART_FORM_DATA, response, Collections.emptyMap()).block(); + Map hints = Collections.emptyMap(); + this.writer.write(Mono.just(map), null, MediaType.MULTIPART_FORM_DATA, response, hints).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(); + // see if Synchronoss NIO Multipart can read what we wrote + SynchronossMultipartHttpMessageReader reader = new SynchronossMultipartHttpMessageReader(); MockServerHttpRequest request = MockServerHttpRequest.post("/foo") .header(CONTENT_TYPE, contentType.toString()) .body(response.getBody()); - MultiValueMap requestParts = multipartReader. - readMono(MultipartHttpMessageReader.MULTIPART_VALUE_TYPE, request, Collections.emptyMap()).block(); + ResolvableType elementType = forClassWithGenerics(MultiValueMap.class, String.class, Part.class); + MultiValueMap requestParts = reader.readMono(elementType, request, hints).block(); assertEquals(5, requestParts.size()); diff --git a/spring-web/src/test/java/org/springframework/http/codec/multipart/SynchronossMultipartHttpMessageReaderTests.java b/spring-web/src/test/java/org/springframework/http/codec/multipart/SynchronossMultipartHttpMessageReaderTests.java index 180ebbf807..ab08bef41a 100644 --- a/spring-web/src/test/java/org/springframework/http/codec/multipart/SynchronossMultipartHttpMessageReaderTests.java +++ b/spring-web/src/test/java/org/springframework/http/codec/multipart/SynchronossMultipartHttpMessageReaderTests.java @@ -17,46 +17,75 @@ package org.springframework.http.codec.multipart; import java.io.IOException; +import java.util.Map; import java.util.Optional; -import static java.util.Collections.emptyMap; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; import org.junit.Test; -import static org.springframework.http.HttpHeaders.CONTENT_LENGTH; -import static org.springframework.http.HttpHeaders.CONTENT_TYPE; -import static org.springframework.http.MediaType.MULTIPART_FORM_DATA; -import static org.springframework.http.codec.multipart.MultipartHttpMessageReader.*; - import reactor.core.publisher.Flux; import reactor.test.StepVerifier; +import org.springframework.core.ResolvableType; import org.springframework.core.io.ClassPathResource; 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.MockHttpOutputMessage; +import org.springframework.http.codec.HttpMessageReader; import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.mock.http.server.reactive.test.MockServerHttpRequest; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import static java.util.Collections.emptyMap; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.springframework.core.ResolvableType.forClassWithGenerics; +import static org.springframework.http.HttpHeaders.CONTENT_LENGTH; +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA; + + /** * @author Sebastien Deleuze */ public class SynchronossMultipartHttpMessageReaderTests { + private final HttpMessageReader> reader = + new SynchronossMultipartHttpMessageReader(); + + + @Test + public void canRead() { + assertTrue(this.reader.canRead( + ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), + MediaType.MULTIPART_FORM_DATA)); + + assertFalse(this.reader.canRead( + ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Object.class), + MediaType.MULTIPART_FORM_DATA)); + + assertFalse(this.reader.canRead( + ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class), + MediaType.MULTIPART_FORM_DATA)); + + assertFalse(this.reader.canRead( + ResolvableType.forClassWithGenerics(Map.class, String.class, String.class), + MediaType.MULTIPART_FORM_DATA)); + + assertFalse(this.reader.canRead( + ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), + MediaType.APPLICATION_FORM_URLENCODED)); + } + @Test public void resolveParts() throws IOException { ServerHttpRequest request = generateMultipartRequest(); - MultipartHttpMessageReader multipartReader = new SynchronossMultipartHttpMessageReader(); - MultiValueMap parts = multipartReader.readMono(MULTIPART_VALUE_TYPE, request, emptyMap()).block(); + ResolvableType elementType = forClassWithGenerics(MultiValueMap.class, String.class, Part.class); + MultiValueMap parts = this.reader.readMono(elementType, request, emptyMap()).block(); assertEquals(2, parts.size()); assertTrue(parts.containsKey("fooPart")); @@ -65,10 +94,7 @@ public class SynchronossMultipartHttpMessageReaderTests { Optional filename = part.getFilename(); assertTrue(filename.isPresent()); assertEquals("foo.txt", filename.get()); - DataBuffer buffer = part - .getContent() - .reduce((s1, s2) -> s1.write(s2)) - .block(); + DataBuffer buffer = part.getContent().reduce(DataBuffer::write).block(); assertEquals(12, buffer.readableByteCount()); byte[] byteContent = new byte[12]; buffer.read(byteContent); @@ -85,9 +111,8 @@ public class SynchronossMultipartHttpMessageReaderTests { @Test public void bodyError() { ServerHttpRequest request = generateErrorMultipartRequest(); - MultipartHttpMessageReader multipartReader = new SynchronossMultipartHttpMessageReader(); - StepVerifier.create(multipartReader.readMono(MULTIPART_VALUE_TYPE, request, emptyMap())) - .verifyError(); + ResolvableType elementType = forClassWithGenerics(MultiValueMap.class, String.class, Part.class); + StepVerifier.create(this.reader.readMono(elementType, request, emptyMap())).verifyError(); } private ServerHttpRequest generateMultipartRequest() throws IOException { @@ -103,21 +128,18 @@ public class SynchronossMultipartHttpMessageReaderTests { parts.add("barPart", barPart); converter.write(parts, MULTIPART_FORM_DATA, outputMessage); byte[] content = outputMessage.getBodyAsBytes(); - MockServerHttpRequest request = MockServerHttpRequest + return MockServerHttpRequest .post("/foo") .header(CONTENT_TYPE, outputMessage.getHeaders().getContentType().toString()) .header(CONTENT_LENGTH, String.valueOf(content.length)) .body(new String(content)); - return request; } private ServerHttpRequest generateErrorMultipartRequest() { - DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); - MockServerHttpRequest request = MockServerHttpRequest + return MockServerHttpRequest .post("/foo") .header(CONTENT_TYPE, MULTIPART_FORM_DATA.toString()) - .body(Flux.just(bufferFactory.wrap("invalid content".getBytes()))); - return request; + .body(Flux.just(new DefaultDataBufferFactory().wrap("invalid content".getBytes()))); } } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java index e4bbc30781..3e98ba59ec 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyExtractors.java @@ -39,8 +39,6 @@ import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; -import static org.springframework.http.codec.multipart.MultipartHttpMessageReader.*; - /** * Implementations of {@link BodyExtractor} that read various bodies, such a reactive streams. * @@ -53,6 +51,9 @@ public abstract class BodyExtractors { private static final ResolvableType FORM_TYPE = ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class); + private static final ResolvableType MULTIPART_TYPE = ResolvableType.forClassWithGenerics( + MultiValueMap.class, String.class, Part.class); + /** * Return a {@code BodyExtractor} that reads into a Reactor {@link Mono}. @@ -151,8 +152,8 @@ public abstract class BodyExtractors { HttpMessageReader> messageReader = multipartMessageReader(context); return context.serverResponse() - .map(serverResponse -> messageReader.readMono(MULTIPART_VALUE_TYPE, MULTIPART_VALUE_TYPE, serverRequest, serverResponse, context.hints())) - .orElseGet(() -> messageReader.readMono(MULTIPART_VALUE_TYPE, serverRequest, context.hints())); + .map(serverResponse -> messageReader.readMono(MULTIPART_TYPE, MULTIPART_TYPE, serverRequest, serverResponse, context.hints())) + .orElseGet(() -> messageReader.readMono(MULTIPART_TYPE, serverRequest, context.hints())); }; } @@ -204,7 +205,7 @@ public abstract class BodyExtractors { private static HttpMessageReader> multipartMessageReader(BodyExtractor.Context context) { return context.messageReaders().get() .filter(messageReader -> messageReader - .canRead(MULTIPART_VALUE_TYPE, MediaType.MULTIPART_FORM_DATA)) + .canRead(MULTIPART_TYPE, MediaType.MULTIPART_FORM_DATA)) .findFirst() .map(BodyExtractors::>cast) .orElseThrow(() -> new IllegalStateException( diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyInserters.java b/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyInserters.java index f637b4c681..8e12b60f2a 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyInserters.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/function/BodyInserters.java @@ -33,15 +33,12 @@ import org.springframework.http.ReactiveHttpOutputMessage; import org.springframework.http.client.reactive.ClientHttpRequest; import org.springframework.http.codec.HttpMessageWriter; import org.springframework.http.codec.ServerSentEvent; -import org.springframework.http.codec.multipart.MultipartHttpMessageReader; import org.springframework.http.codec.multipart.Part; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; -import static org.springframework.http.codec.multipart.MultipartHttpMessageReader.*; - /** * Implementations of {@link BodyInserter} that write various bodies, such a reactive streams, * server-sent events, resources, etc. @@ -60,6 +57,9 @@ public abstract class BodyInserters { private static final ResolvableType FORM_TYPE = ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, String.class); + private static final ResolvableType MULTIPART_VALUE_TYPE = ResolvableType.forClassWithGenerics( + MultiValueMap.class, String.class, Part.class); + private static final BodyInserter EMPTY = (response, context) -> response.setComplete();