Browse Source
This commit introduces the PartEvent API. PartEvents are either - FormPartEvents, representing a form field, or - FilePartEvents, representing a file upload. The PartEventHttpMessageReader is a HttpMessageReader that splits multipart data into a stream of PartEvents. Form fields generate one FormPartEvent; file uploads produce at least one FilePartEvent. The last element that makes up a particular part will have isLast set to true. The PartEventHttpMessageWriter is a HttpMessageWriter that writes a Publisher<PartEvent> to a outgoing HTTP message. This writer is particularly useful for relaying a multipart request on the server. Closes gh-28006pull/28369/head
22 changed files with 1436 additions and 56 deletions
@ -0,0 +1,176 @@
@@ -0,0 +1,176 @@
|
||||
/* |
||||
* Copyright 2002-2022 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 org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.util.Assert; |
||||
|
||||
/** |
||||
* Default implementations of {@link PartEvent} and subtypes. |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @since 6.0 |
||||
*/ |
||||
abstract class DefaultPartEvents { |
||||
|
||||
public static FormPartEvent form(HttpHeaders headers) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
return new DefaultFormFieldPartEvent(headers); |
||||
} |
||||
|
||||
public static FormPartEvent form(HttpHeaders headers, String value) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
Assert.notNull(value, "Value must not be null"); |
||||
return new DefaultFormFieldPartEvent(headers, value); |
||||
} |
||||
|
||||
public static FilePartEvent file(HttpHeaders headers, DataBuffer dataBuffer, boolean isLast) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
Assert.notNull(dataBuffer, "DataBuffer must not be null"); |
||||
return new DefaultFilePartEvent(headers, dataBuffer, isLast); |
||||
} |
||||
|
||||
public static FilePartEvent file(HttpHeaders headers) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
return new DefaultFilePartEvent(headers); |
||||
} |
||||
|
||||
public static PartEvent create(HttpHeaders headers, DataBuffer dataBuffer, boolean isLast) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
Assert.notNull(dataBuffer, "DataBuffer must not be null"); |
||||
if (headers.getContentDisposition().getFilename() != null) { |
||||
return file(headers, dataBuffer, isLast); |
||||
} |
||||
else { |
||||
return new DefaultPartEvent(headers, dataBuffer, isLast); |
||||
} |
||||
} |
||||
|
||||
public static PartEvent create(HttpHeaders headers) { |
||||
Assert.notNull(headers, "Headers must not be null"); |
||||
if (headers.getContentDisposition().getFilename() != null) { |
||||
return file(headers); |
||||
} |
||||
else { |
||||
return new DefaultPartEvent(headers); |
||||
} |
||||
} |
||||
|
||||
|
||||
|
||||
private static abstract class AbstractPartEvent implements PartEvent { |
||||
|
||||
private final HttpHeaders headers; |
||||
|
||||
|
||||
protected AbstractPartEvent(HttpHeaders headers) { |
||||
this.headers = HttpHeaders.readOnlyHttpHeaders(headers); |
||||
} |
||||
|
||||
@Override |
||||
public HttpHeaders headers() { |
||||
return this.headers; |
||||
} |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Default implementation of {@link PartEvent}. |
||||
*/ |
||||
private static class DefaultPartEvent extends AbstractPartEvent { |
||||
|
||||
private static final DataBuffer EMPTY = DefaultDataBufferFactory.sharedInstance.allocateBuffer(0); |
||||
|
||||
private final DataBuffer content; |
||||
|
||||
private final boolean last; |
||||
|
||||
|
||||
public DefaultPartEvent(HttpHeaders headers) { |
||||
this(headers, EMPTY, true); |
||||
} |
||||
|
||||
public DefaultPartEvent(HttpHeaders headers, DataBuffer content, boolean last) { |
||||
super(headers); |
||||
this.content = content; |
||||
this.last = last; |
||||
} |
||||
|
||||
@Override |
||||
public DataBuffer content() { |
||||
return this.content; |
||||
} |
||||
|
||||
@Override |
||||
public boolean isLast() { |
||||
return this.last; |
||||
} |
||||
|
||||
} |
||||
|
||||
/** |
||||
* Default implementation of {@link FormPartEvent}. |
||||
*/ |
||||
private static final class DefaultFormFieldPartEvent extends AbstractPartEvent implements FormPartEvent { |
||||
|
||||
private static final String EMPTY = ""; |
||||
|
||||
private final String value; |
||||
|
||||
|
||||
public DefaultFormFieldPartEvent(HttpHeaders headers) { |
||||
this(headers, EMPTY); |
||||
} |
||||
|
||||
public DefaultFormFieldPartEvent(HttpHeaders headers, String value) { |
||||
super(headers); |
||||
this.value = value; |
||||
} |
||||
|
||||
@Override |
||||
public String value() { |
||||
return this.value; |
||||
} |
||||
|
||||
@Override |
||||
public DataBuffer content() { |
||||
byte[] bytes = this.value.getBytes(MultipartUtils.charset(headers())); |
||||
return DefaultDataBufferFactory.sharedInstance.wrap(bytes); |
||||
} |
||||
|
||||
@Override |
||||
public boolean isLast() { |
||||
return true; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Default implementation of {@link FilePartEvent}. |
||||
*/ |
||||
private static class DefaultFilePartEvent extends DefaultPartEvent implements FilePartEvent { |
||||
|
||||
public DefaultFilePartEvent(HttpHeaders headers) { |
||||
super(headers); |
||||
} |
||||
|
||||
public DefaultFilePartEvent(HttpHeaders headers, DataBuffer content, boolean last) { |
||||
super(headers, content, last); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,196 @@
@@ -0,0 +1,196 @@
|
||||
/* |
||||
* Copyright 2002-2022 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.io.IOException; |
||||
import java.nio.charset.StandardCharsets; |
||||
import java.nio.file.Path; |
||||
import java.util.function.Consumer; |
||||
|
||||
import reactor.core.publisher.Flux; |
||||
import reactor.core.publisher.Mono; |
||||
|
||||
import org.springframework.core.io.Resource; |
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.core.io.buffer.DefaultDataBufferFactory; |
||||
import org.springframework.http.ContentDisposition; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.MediaTypeFactory; |
||||
import org.springframework.lang.Nullable; |
||||
import org.springframework.util.Assert; |
||||
import org.springframework.util.StringUtils; |
||||
|
||||
/** |
||||
* Represents an event triggered for a file upload. Contains the |
||||
* {@linkplain #filename() filename}, besides the {@linkplain #headers() headers} |
||||
* and {@linkplain #content() content} exposed through {@link PartEvent}. |
||||
* |
||||
* <p>On the client side, instances of this interface can be created via one |
||||
* of the overloaded {@linkplain #create(String, Path) create} methods. |
||||
* |
||||
* <p>On the server side, multipart file uploads trigger one or more |
||||
* {@code FilePartEvent}, as {@linkplain PartEvent described here}. |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @since 6.0 |
||||
* @see PartEvent |
||||
*/ |
||||
public interface FilePartEvent extends PartEvent { |
||||
|
||||
/** |
||||
* Return the original filename in the client's filesystem. |
||||
* <p><strong>Note:</strong> Please keep in mind this filename is supplied |
||||
* by the client and should not be used blindly. In addition to not using |
||||
* the directory portion, the file name could also contain characters such |
||||
* as ".." and others that can be used maliciously. It is recommended to not |
||||
* use this filename directly. Preferably generate a unique one and save |
||||
* this one somewhere for reference, if necessary. |
||||
* @return the original filename, or the empty String if no file has been chosen |
||||
* in the multipart form, or {@code null} if not defined or not available |
||||
* @see <a href="https://tools.ietf.org/html/rfc7578#section-4.2">RFC 7578, Section 4.2</a> |
||||
* @see <a href="https://owasp.org/www-community/vulnerabilities/Unrestricted_File_Upload">Unrestricted File Upload</a> |
||||
*/ |
||||
default String filename() { |
||||
String filename = this.headers().getContentDisposition().getFilename(); |
||||
Assert.state(filename != null, "No filename found"); |
||||
return filename; |
||||
} |
||||
|
||||
|
||||
/** |
||||
* Creates a stream of {@code FilePartEvent} objects based on the given |
||||
* {@linkplain PartEvent#name() name} and resource. |
||||
* @param name the name of the part |
||||
* @param resource the resource |
||||
* @return a stream of events |
||||
*/ |
||||
static Flux<FilePartEvent> create(String name, Resource resource) { |
||||
return create(name, resource, null); |
||||
} |
||||
|
||||
/** |
||||
* Creates a stream of {@code FilePartEvent} objects based on the given |
||||
* {@linkplain PartEvent#name() name} and resource. |
||||
* @param name the name of the part |
||||
* @param resource the resource |
||||
* @param headersConsumer used to change default headers. Can be {@code null}. |
||||
* @return a stream of events |
||||
*/ |
||||
static Flux<FilePartEvent> create(String name, Resource resource, @Nullable Consumer<HttpHeaders> headersConsumer) { |
||||
try { |
||||
return create(name, resource.getFile().toPath(), headersConsumer); |
||||
} |
||||
catch (IOException ex) { |
||||
return Flux.error(ex); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Creates a stream of {@code FilePartEvent} objects based on the given |
||||
* {@linkplain PartEvent#name() name} and file path. |
||||
* @param name the name of the part |
||||
* @param path the file path |
||||
* @return a stream of events |
||||
*/ |
||||
static Flux<FilePartEvent> create(String name, Path path) { |
||||
return create(name, path, null); |
||||
} |
||||
|
||||
/** |
||||
* Creates a stream of {@code FilePartEvent} objects based on the given |
||||
* {@linkplain PartEvent#name() name} and file path. |
||||
* @param name the name of the part |
||||
* @param path the file path |
||||
* @param headersConsumer used to change default headers. Can be {@code null}. |
||||
* @return a stream of events |
||||
*/ |
||||
static Flux<FilePartEvent> create(String name, Path path, @Nullable Consumer<HttpHeaders> headersConsumer) { |
||||
Assert.hasLength(name, "Name must not be empty"); |
||||
Assert.notNull(path, "Path must not be null"); |
||||
|
||||
return Flux.defer(() -> { |
||||
String pathName = path.toString(); |
||||
MediaType contentType = MediaTypeFactory.getMediaType(pathName) |
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM); |
||||
String filename = StringUtils.getFilename(pathName); |
||||
if (filename == null) { |
||||
return Flux.error(new IllegalArgumentException("Invalid file: " + pathName)); |
||||
} |
||||
Flux<DataBuffer> contents = DataBufferUtils.read(path, DefaultDataBufferFactory.sharedInstance, 8192); |
||||
|
||||
return create(name, filename, contentType, contents, headersConsumer); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* Creates a stream of {@code FilePartEvent} objects based on the given |
||||
* {@linkplain PartEvent#name() name}, {@linkplain #filename()}, |
||||
* content-type, and contents. |
||||
* @param partName the name of the part |
||||
* @param filename the filename |
||||
* @param contentType the content-type for the contents |
||||
* @param contents the contents |
||||
* @return a stream of events |
||||
*/ |
||||
static Flux<FilePartEvent> create(String partName, String filename, MediaType contentType, |
||||
Flux<DataBuffer> contents) { |
||||
|
||||
return create(partName, filename, contentType, contents, null); |
||||
} |
||||
|
||||
/** |
||||
* Creates a stream of {@code FilePartEvent} objects based on the given |
||||
* {@linkplain PartEvent#name() name}, {@linkplain #filename()}, |
||||
* content-type, and contents. |
||||
* @param partName the name of the part |
||||
* @param filename the filename |
||||
* @param contentType the content-type for the contents |
||||
* @param contents the contents |
||||
* @param headersConsumer used to change default headers. Can be {@code null}. |
||||
* @return a stream of events |
||||
*/ |
||||
static Flux<FilePartEvent> create(String partName, String filename, MediaType contentType, |
||||
Flux<DataBuffer> contents, @Nullable Consumer<HttpHeaders> headersConsumer) { |
||||
|
||||
Assert.hasLength(partName, "PartName must not be empty"); |
||||
Assert.hasLength(filename, "Filename must not be empty"); |
||||
Assert.notNull(contentType, "ContentType must not be null"); |
||||
Assert.notNull(contents, "Contents must not be null"); |
||||
|
||||
return Flux.defer(() -> { |
||||
HttpHeaders headers = new HttpHeaders(); |
||||
headers.setContentType(contentType); |
||||
|
||||
headers.setContentDisposition(ContentDisposition.formData() |
||||
.name(partName) |
||||
.filename(filename, StandardCharsets.UTF_8) |
||||
.build()); |
||||
|
||||
if (headersConsumer != null) { |
||||
headersConsumer.accept(headers); |
||||
} |
||||
|
||||
return contents.map(content -> DefaultPartEvents.file(headers, content, false)) |
||||
.concatWith(Mono.just(DefaultPartEvents.file(headers))); |
||||
}); |
||||
} |
||||
|
||||
|
||||
|
||||
} |
||||
@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
|
||||
/* |
||||
* Copyright 2002-2022 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.util.function.Consumer; |
||||
|
||||
import reactor.core.publisher.Mono; |
||||
|
||||
import org.springframework.http.ContentDisposition; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.lang.Nullable; |
||||
import org.springframework.util.Assert; |
||||
|
||||
/** |
||||
* Represents an event triggered for a form field. Contains the |
||||
* {@linkplain #value() value}, besides the {@linkplain #headers() headers} |
||||
* exposed through {@link PartEvent}. |
||||
* |
||||
* <p>Multipart form fields trigger one {@code FormPartEvent}, as |
||||
* {@linkplain PartEvent described here}. |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @since 6.0 |
||||
*/ |
||||
public interface FormPartEvent extends PartEvent { |
||||
|
||||
/** |
||||
* Return the form field value. |
||||
*/ |
||||
String value(); |
||||
|
||||
/** |
||||
* Creates a stream with a single {@code FormPartEven} based on the given |
||||
* {@linkplain PartEvent#name() name} and {@linkplain #value() value}. |
||||
* @param name the name of the part |
||||
* @param value the form field value |
||||
* @return a single event stream |
||||
*/ |
||||
static Mono<FormPartEvent> create(String name, String value) { |
||||
return create(name, value, null); |
||||
} |
||||
|
||||
/** |
||||
* Creates a stream with a single {@code FormPartEven} based on the given |
||||
* {@linkplain PartEvent#name() name} and {@linkplain #value() value}. |
||||
* @param name the name of the part |
||||
* @param value the form field value |
||||
* @param headersConsumer used to change default headers. Can be {@code null}. |
||||
* @return a single event stream |
||||
*/ |
||||
static Mono<FormPartEvent> create(String name, String value, @Nullable Consumer<HttpHeaders> headersConsumer) { |
||||
Assert.hasLength(name, "Name must not be empty"); |
||||
Assert.notNull(value, "Value must not be null"); |
||||
|
||||
return Mono.fromCallable(() -> { |
||||
HttpHeaders headers = new HttpHeaders(); |
||||
headers.setContentType(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8)); |
||||
headers.setContentDisposition(ContentDisposition.formData(). |
||||
name(name) |
||||
.build()); |
||||
if (headersConsumer != null) { |
||||
headersConsumer.accept(headers); |
||||
} |
||||
return DefaultPartEvents.form(headers, value); |
||||
}); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
|
||||
/* |
||||
* Copyright 2002-2022 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.file.Path; |
||||
import java.util.function.BiFunction; |
||||
import java.util.function.Predicate; |
||||
|
||||
import org.reactivestreams.Publisher; |
||||
import reactor.core.publisher.Flux; |
||||
|
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.util.Assert; |
||||
|
||||
/** |
||||
* Represents an event for a "multipart/form-data" request. |
||||
* Can be a {@link FormPartEvent} or a {@link FilePartEvent}. |
||||
* |
||||
* <h2>Server Side</h2> |
||||
* |
||||
* Each part in a multipart HTTP message produces at least one |
||||
* {@code PartEvent} containing both {@link #headers() headers} and a |
||||
* {@linkplain PartEvent#content() buffer} with content of the part. |
||||
* <ul> |
||||
* <li>Form field will produce a <em>single</em> {@link FormPartEvent}, |
||||
* containing the {@linkplain FormPartEvent#value() value} of the field.</li> |
||||
* <li>File uploads will produce <em>one or more</em> {@link FilePartEvent}s, |
||||
* containing the {@linkplain FilePartEvent#filename() filename} used when |
||||
* uploading. If the file is large enough to be split across multiple buffers, |
||||
* the first {@code FilePartEvent} will be followed by subsequent events.</li> |
||||
* </ul> |
||||
* The final {@code PartEvent} for a particular part will have |
||||
* {@link #isLast()} set to {@code true}, and can be followed by |
||||
* additional events belonging to subsequent parts. |
||||
* The {@code isLast()} property is suitable as a predicate for the |
||||
* {@link Flux#windowUntil(Predicate)} operator, in order to split events from |
||||
* all parts into windows that each belong to a single part. |
||||
* From that, the {@link Flux#switchOnFirst(BiFunction)} operator allows you to |
||||
* see whether you are handling a form field or file upload. |
||||
* For example: |
||||
* |
||||
* <pre class=code> |
||||
* Flux<PartEvent> allPartsEvents = ... // obtained via @RequestPayload or request.bodyToFlux(PartEvent.class)
|
||||
* allPartsEvents.windowUntil(PartEvent::isLast) |
||||
* .concatMap(p -> p.switchOnFirst((signal, partEvents) -> { |
||||
* if (signal.hasValue()) { |
||||
* PartEvent event = signal.get(); |
||||
* if (event instanceof FormPartEvent formEvent) { |
||||
* String value = formEvent.value(); |
||||
* // handle form field
|
||||
* } |
||||
* else if (event instanceof FilePartEvent fileEvent) { |
||||
* String filename filename = fileEvent.filename(); |
||||
* Flux<DataBuffer> contents = partEvents.map(PartEvent::content); |
||||
* // handle file upload
|
||||
* } |
||||
* else { |
||||
* return Mono.error("Unexpected event: " + event); |
||||
* } |
||||
* } |
||||
* else { |
||||
* return partEvents; // either complete or error signal
|
||||
* } |
||||
* })) |
||||
* </pre> |
||||
* Received part events can also be relayed to another service by using the |
||||
* {@link org.springframework.web.reactive.function.client.WebClient WebClient}. |
||||
* See below. |
||||
* |
||||
* <p><strong>NOTE</strong> that the {@linkplain PartEvent#content() body contents} |
||||
* must be completely consumed, relayed, or released to avoid memory leaks. |
||||
* |
||||
* <h2>Client Side</h2> |
||||
* On the client side, {@code PartEvent}s can be created to represent a file upload. |
||||
* <ul> |
||||
* <li>Form fields can be created via {@link FormPartEvent#create(String, String)}.</li> |
||||
* <li>File uploads can be created via {@link FilePartEvent#create(String, Path)}.</li> |
||||
* </ul> |
||||
* The streams returned by these static methods can be concatenated via |
||||
* {@link Flux#concat(Publisher[])} to create a request for the |
||||
* {@link org.springframework.web.reactive.function.client.WebClient WebClient}: |
||||
* For instance, this sample will POST a multipart form containing a form field |
||||
* and a file. |
||||
* |
||||
* <pre class=code> |
||||
* Resource resource = ... |
||||
* Mono<String> result = webClient |
||||
* .post() |
||||
* .uri("https://example.com") |
||||
* .body(Flux.concat( |
||||
* FormEventPart.create("field", "field value"), |
||||
* FilePartEvent.create("file", resource) |
||||
* ), PartEvent.class) |
||||
* .retrieve() |
||||
* .bodyToMono(String.class); |
||||
* </pre> |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @since 6.0 |
||||
* @see FormPartEvent |
||||
* @see FilePartEvent |
||||
* @see PartEventHttpMessageReader |
||||
* @see PartEventHttpMessageWriter |
||||
*/ |
||||
public interface PartEvent { |
||||
|
||||
/** |
||||
* Return the name of the event, as provided through the |
||||
* {@code Content-Disposition name} parameter. |
||||
* @return the name of the part, never {@code null} or empty |
||||
*/ |
||||
default String name() { |
||||
String name = headers().getContentDisposition().getName(); |
||||
Assert.state(name != null, "No name available"); |
||||
return name; |
||||
} |
||||
|
||||
/** |
||||
* Return the headers of the part that this event belongs to. |
||||
*/ |
||||
HttpHeaders headers(); |
||||
|
||||
/** |
||||
* Return the content of this event. The returned buffer must be consumed or |
||||
* {@linkplain org.springframework.core.io.buffer.DataBufferUtils#release(DataBuffer) released}. |
||||
*/ |
||||
DataBuffer content(); |
||||
|
||||
/** |
||||
* Indicates whether this is the last event of a particular |
||||
* part. |
||||
*/ |
||||
boolean isLast(); |
||||
|
||||
} |
||||
@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
|
||||
/* |
||||
* Copyright 2002-2022 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.Collections; |
||||
import java.util.List; |
||||
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.DecodingException; |
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferLimitException; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.ReactiveHttpInputMessage; |
||||
import org.springframework.http.codec.HttpMessageReader; |
||||
import org.springframework.http.codec.LoggingCodecSupport; |
||||
import org.springframework.lang.Nullable; |
||||
import org.springframework.util.Assert; |
||||
|
||||
/** |
||||
* {@code HttpMessageReader} for parsing {@code "multipart/form-data"} requests |
||||
* to a stream of {@link PartEvent} elements. |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @since 6.0 |
||||
* @see PartEvent |
||||
*/ |
||||
public class PartEventHttpMessageReader extends LoggingCodecSupport implements HttpMessageReader<PartEvent> { |
||||
|
||||
private int maxInMemorySize = 256 * 1024; |
||||
|
||||
private int maxHeadersSize = 10 * 1024; |
||||
|
||||
private Charset headersCharset = StandardCharsets.UTF_8; |
||||
|
||||
|
||||
/** |
||||
* Get the {@link #setMaxInMemorySize configured} maximum in-memory size. |
||||
*/ |
||||
public int getMaxInMemorySize() { |
||||
return this.maxInMemorySize; |
||||
} |
||||
|
||||
/** |
||||
* Configure the maximum amount of memory allowed for form fields. |
||||
* When the limit is exceeded, form fields parts are rejected with |
||||
* {@link DataBufferLimitException}. |
||||
|
||||
* <p>By default this is set to 256K. |
||||
* @param maxInMemorySize the in-memory limit in bytes; if set to -1 the entire |
||||
* contents will be stored in memory |
||||
*/ |
||||
public void setMaxInMemorySize(int maxInMemorySize) { |
||||
this.maxInMemorySize = maxInMemorySize; |
||||
} |
||||
|
||||
/** |
||||
* Configure the maximum amount of memory that is allowed per headers section of each part. |
||||
* Defaults to 10K. |
||||
* @param byteCount the maximum amount of memory for headers |
||||
*/ |
||||
public void setMaxHeadersSize(int byteCount) { |
||||
this.maxHeadersSize = byteCount; |
||||
} |
||||
|
||||
/** |
||||
* Set the character set used to decode headers. |
||||
* Defaults to UTF-8 as per RFC 7578. |
||||
* @param headersCharset the charset to use for decoding headers |
||||
* @see <a href="https://tools.ietf.org/html/rfc7578#section-5.1">RFC-7578 Section 5.1</a> |
||||
*/ |
||||
public void setHeadersCharset(Charset headersCharset) { |
||||
Assert.notNull(headersCharset, "HeadersCharset must not be null"); |
||||
this.headersCharset = headersCharset; |
||||
} |
||||
|
||||
|
||||
@Override |
||||
public List<MediaType> getReadableMediaTypes() { |
||||
return Collections.singletonList(MediaType.MULTIPART_FORM_DATA); |
||||
} |
||||
|
||||
@Override |
||||
public boolean canRead(ResolvableType elementType, @Nullable MediaType mediaType) { |
||||
return PartEvent.class.equals(elementType.toClass()) && |
||||
(mediaType == null || MediaType.MULTIPART_FORM_DATA.isCompatibleWith(mediaType)); |
||||
} |
||||
|
||||
@Override |
||||
public Mono<PartEvent> readMono(ResolvableType elementType, ReactiveHttpInputMessage message, |
||||
Map<String, Object> hints) { |
||||
return Mono.error( |
||||
new UnsupportedOperationException("Cannot read multipart request body into single PartEvent")); |
||||
} |
||||
|
||||
@Override |
||||
public Flux<PartEvent> read(ResolvableType elementType, ReactiveHttpInputMessage message, |
||||
Map<String, Object> hints) { |
||||
|
||||
return Flux.defer(() -> { |
||||
byte[] boundary = MultipartUtils.boundary(message, this.headersCharset); |
||||
if (boundary == null) { |
||||
return Flux.error(new DecodingException("No multipart boundary found in Content-Type: \"" + |
||||
message.getHeaders().getContentType() + "\"")); |
||||
} |
||||
return MultipartParser.parse(message.getBody(), boundary, this.maxHeadersSize, this.headersCharset) |
||||
.windowUntil(t -> t instanceof MultipartParser.HeadersToken, true) |
||||
.concatMap(tokens -> tokens.switchOnFirst((signal, flux) -> { |
||||
if (signal.hasValue()) { |
||||
MultipartParser.HeadersToken headersToken = (MultipartParser.HeadersToken) signal.get(); |
||||
Assert.state(headersToken != null, "Signal should be headers token"); |
||||
|
||||
HttpHeaders headers = headersToken.headers(); |
||||
Flux<MultipartParser.BodyToken> bodyTokens = |
||||
flux.filter(t -> t instanceof MultipartParser.BodyToken) |
||||
.cast(MultipartParser.BodyToken.class); |
||||
return createEvents(headers, bodyTokens); |
||||
} |
||||
else { |
||||
// complete or error signal
|
||||
return flux.cast(PartEvent.class); |
||||
} |
||||
})); |
||||
}); |
||||
} |
||||
|
||||
private Publisher<? extends PartEvent> createEvents(HttpHeaders headers, Flux<MultipartParser.BodyToken> bodyTokens) { |
||||
if (MultipartUtils.isFormField(headers)) { |
||||
Flux<DataBuffer> contents = bodyTokens.map(MultipartParser.BodyToken::buffer); |
||||
return DataBufferUtils.join(contents, this.maxInMemorySize) |
||||
.map(content -> { |
||||
String value = content.toString(MultipartUtils.charset(headers)); |
||||
DataBufferUtils.release(content); |
||||
return DefaultPartEvents.form(headers, value); |
||||
}) |
||||
.switchIfEmpty(Mono.fromCallable(() -> DefaultPartEvents.form(headers))); |
||||
} |
||||
else if (headers.getContentDisposition().getFilename() != null) { |
||||
return bodyTokens |
||||
.map(body -> DefaultPartEvents.file(headers, body.buffer(), body.isLast())) |
||||
.switchIfEmpty(Mono.fromCallable(() -> DefaultPartEvents.file(headers))); |
||||
} |
||||
else { |
||||
return bodyTokens |
||||
.map(body -> DefaultPartEvents.create(headers, body.buffer(), body.isLast())) |
||||
.switchIfEmpty(Mono.fromCallable(() -> DefaultPartEvents.create(headers))); // empty body
|
||||
} |
||||
|
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
|
||||
/* |
||||
* Copyright 2002-2022 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.Collections; |
||||
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.DataBufferUtils; |
||||
import org.springframework.core.io.buffer.PooledDataBuffer; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.http.ReactiveHttpOutputMessage; |
||||
import org.springframework.http.codec.HttpMessageWriter; |
||||
import org.springframework.lang.Nullable; |
||||
import org.springframework.util.Assert; |
||||
|
||||
/** |
||||
* {@link HttpMessageWriter} for writing {@link PartEvent} objects. Useful for |
||||
* server-side proxies, that relay multipart requests to others services. |
||||
* |
||||
* @author Arjen Poutsma |
||||
* @since 6.0 |
||||
* @see PartEvent |
||||
*/ |
||||
public class PartEventHttpMessageWriter extends MultipartWriterSupport implements HttpMessageWriter<PartEvent> { |
||||
|
||||
public PartEventHttpMessageWriter() { |
||||
super(Collections.singletonList(MediaType.MULTIPART_FORM_DATA)); |
||||
} |
||||
|
||||
@Override |
||||
public boolean canWrite(ResolvableType elementType, @Nullable MediaType mediaType) { |
||||
if (PartEvent.class.isAssignableFrom(elementType.toClass())) { |
||||
if (mediaType == null) { |
||||
return true; |
||||
} |
||||
for (MediaType supportedMediaType : getWritableMediaTypes()) { |
||||
if (supportedMediaType.isCompatibleWith(mediaType)) { |
||||
return true; |
||||
} |
||||
} |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
@Override |
||||
public Mono<Void> write(Publisher<? extends PartEvent> partDataStream, 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<PartEvent>"); |
||||
} |
||||
|
||||
Flux<DataBuffer> body = Flux.from(partDataStream) |
||||
.windowUntil(PartEvent::isLast) |
||||
.concatMap(partData -> partData.switchOnFirst((signal, flux) -> { |
||||
if (signal.hasValue()) { |
||||
PartEvent value = signal.get(); |
||||
Assert.state(value != null, "Null value"); |
||||
return encodePartData(boundary, outputMessage.bufferFactory(), value, flux); |
||||
} |
||||
else { |
||||
return flux.cast(DataBuffer.class); |
||||
} |
||||
})) |
||||
.concatWith(generateLastLine(boundary, outputMessage.bufferFactory())) |
||||
.doOnDiscard(PooledDataBuffer.class, DataBufferUtils::release); |
||||
|
||||
if (logger.isDebugEnabled()) { |
||||
body = body.doOnNext(buffer -> Hints.touchDataBuffer(buffer, hints, logger)); |
||||
} |
||||
|
||||
return outputMessage.writeWith(body); |
||||
} |
||||
|
||||
private Flux<DataBuffer> encodePartData(byte[] boundary, DataBufferFactory bufferFactory, PartEvent first, Flux<? extends PartEvent> flux) { |
||||
return Flux.concat( |
||||
generateBoundaryLine(boundary, bufferFactory), |
||||
generatePartHeaders(first.headers(), bufferFactory), |
||||
flux.map(PartEvent::content), |
||||
generateNewLine(bufferFactory)); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,317 @@
@@ -0,0 +1,317 @@
|
||||
/* |
||||
* Copyright 2002-2022 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.util.List; |
||||
import java.util.function.Consumer; |
||||
|
||||
import io.netty.buffer.PooledByteBufAllocator; |
||||
import org.junit.jupiter.api.Test; |
||||
import reactor.core.publisher.Flux; |
||||
import reactor.test.StepVerifier; |
||||
|
||||
import org.springframework.core.codec.DecodingException; |
||||
import org.springframework.core.io.ClassPathResource; |
||||
import org.springframework.core.io.Resource; |
||||
import org.springframework.core.io.buffer.DataBuffer; |
||||
import org.springframework.core.io.buffer.DataBufferFactory; |
||||
import org.springframework.core.io.buffer.DataBufferUtils; |
||||
import org.springframework.core.io.buffer.NettyDataBufferFactory; |
||||
import org.springframework.http.ContentDisposition; |
||||
import org.springframework.http.HttpHeaders; |
||||
import org.springframework.http.MediaType; |
||||
import org.springframework.web.testfixture.http.server.reactive.MockServerHttpRequest; |
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8; |
||||
import static java.util.Collections.emptyMap; |
||||
import static java.util.Collections.singletonMap; |
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.assertj.core.api.AssertionsForClassTypes.entry; |
||||
import static org.springframework.core.ResolvableType.forClass; |
||||
|
||||
/** |
||||
* @author Arjen Poutsma |
||||
*/ |
||||
class PartEventHttpMessageReaderTests { |
||||
|
||||
private static final int BUFFER_SIZE = 64; |
||||
|
||||
private static final DataBufferFactory bufferFactory = new NettyDataBufferFactory(new PooledByteBufAllocator()); |
||||
|
||||
private static final MediaType TEXT_PLAIN_ASCII = new MediaType("text", "plain", StandardCharsets.US_ASCII); |
||||
|
||||
private final PartEventHttpMessageReader reader = new PartEventHttpMessageReader(); |
||||
|
||||
@Test |
||||
public void canRead() { |
||||
assertThat(this.reader.canRead(forClass(PartEvent.class), MediaType.MULTIPART_FORM_DATA)).isTrue(); |
||||
assertThat(this.reader.canRead(forClass(PartEvent.class), null)).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
public void simple() { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("simple.multipart", getClass()), "simple-boundary"); |
||||
|
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.assertNext(form(headers -> assertThat(headers).isEmpty(), "This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak.")) |
||||
.assertNext(form(headers -> assertThat(headers.getContentType()).isEqualTo(TEXT_PLAIN_ASCII), |
||||
"This is explicitly typed plain ASCII text.\r\nIt DOES end with a linebreak.\r\n")) |
||||
.verifyComplete(); |
||||
} |
||||
|
||||
@Test |
||||
public void noHeaders() { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("no-header.multipart", getClass()), "boundary"); |
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.assertNext(data(headers -> assertThat(headers).isEmpty(), bodyText("a"), true)) |
||||
.verifyComplete(); |
||||
} |
||||
|
||||
@Test |
||||
public void noEndBoundary() { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("no-end-boundary.multipart", getClass()), "boundary"); |
||||
|
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.expectError(DecodingException.class) |
||||
.verify(); |
||||
} |
||||
|
||||
@Test |
||||
public void garbage() { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("garbage-1.multipart", getClass()), "boundary"); |
||||
|
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.expectError(DecodingException.class) |
||||
.verify(); |
||||
} |
||||
|
||||
|
||||
@Test |
||||
public void noEndHeader() { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("no-end-header.multipart", getClass()), "boundary"); |
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.expectError(DecodingException.class) |
||||
.verify(); |
||||
} |
||||
|
||||
@Test |
||||
public void noEndBody() { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("no-end-body.multipart", getClass()), "boundary"); |
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.expectError(DecodingException.class) |
||||
.verify(); |
||||
} |
||||
|
||||
@Test |
||||
public void noBody() { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("no-body.multipart", getClass()), "boundary"); |
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.assertNext(form(headers -> assertThat(headers).contains(entry("Part", List.of("1"))), "")) |
||||
.assertNext(data(headers -> assertThat(headers).contains(entry("Part", List.of("2"))), bodyText("a"), true)) |
||||
.verifyComplete(); |
||||
} |
||||
|
||||
|
||||
@Test |
||||
public void cancel() { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("simple.multipart", getClass()), "simple-boundary"); |
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result, 3) |
||||
.assertNext(form(headers -> assertThat(headers).isEmpty(), |
||||
"This is implicitly typed plain ASCII text.\r\nIt does NOT end with a linebreak.")) |
||||
.thenCancel() |
||||
.verify(); |
||||
} |
||||
|
||||
|
||||
@Test |
||||
public void firefox() { |
||||
|
||||
MockServerHttpRequest request = createRequest(new ClassPathResource("firefox.multipart", getClass()), |
||||
"---------------------------18399284482060392383840973206"); |
||||
|
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
StepVerifier.create(result) |
||||
.assertNext(data(headersFormField("text1"), bodyText("a"), true)) |
||||
.assertNext(data(headersFormField("text2"), bodyText("b"), true)) |
||||
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, true)) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, true)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, true)) |
||||
.verifyComplete(); |
||||
} |
||||
|
||||
@Test |
||||
public void chrome() { |
||||
|
||||
MockServerHttpRequest request = createRequest(new ClassPathResource("chrome.multipart", getClass()), |
||||
"----WebKitFormBoundaryEveBLvRT65n21fwU"); |
||||
|
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
StepVerifier.create(result) |
||||
.assertNext(data(headersFormField("text1"), bodyText("a"), true)) |
||||
.assertNext(data(headersFormField("text2"), bodyText("b"), true)) |
||||
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, true)) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, true)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, true)) |
||||
.verifyComplete(); |
||||
} |
||||
|
||||
@Test |
||||
public void safari() { |
||||
|
||||
MockServerHttpRequest request = createRequest(new ClassPathResource("safari.multipart", getClass()), |
||||
"----WebKitFormBoundaryG8fJ50opQOML0oGD"); |
||||
|
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
StepVerifier.create(result) |
||||
.assertNext(data(headersFormField("text1"), bodyText("a"), true)) |
||||
.assertNext(data(headersFormField("text2"), bodyText("b"), true)) |
||||
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file1", "a.txt"), DataBufferUtils::release, true)) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, true)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, false)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, true)) |
||||
.verifyComplete(); |
||||
} |
||||
|
||||
|
||||
@Test |
||||
public void utf8Headers() { |
||||
MockServerHttpRequest request = createRequest( |
||||
new ClassPathResource("utf8.multipart", getClass()), "\"simple-boundary\""); |
||||
|
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.assertNext(data(headers -> assertThat(headers).containsEntry("Føø", List.of("Bår")), |
||||
bodyText("This is plain ASCII text."), true)) |
||||
.verifyComplete(); |
||||
} |
||||
|
||||
@Test |
||||
public void exceedHeaderLimit() { |
||||
Flux<DataBuffer> body = DataBufferUtils |
||||
.readByteChannel((new ClassPathResource("files.multipart", getClass()))::readableChannel, bufferFactory, |
||||
282); |
||||
|
||||
MediaType contentType = new MediaType("multipart", "form-data", |
||||
singletonMap("boundary", "----WebKitFormBoundaryG8fJ50opQOML0oGD")); |
||||
MockServerHttpRequest request = MockServerHttpRequest.post("/") |
||||
.contentType(contentType) |
||||
.body(body); |
||||
|
||||
this.reader.setMaxHeadersSize(230); |
||||
|
||||
Flux<PartEvent> result = this.reader.read(forClass(PartEvent.class), request, emptyMap()); |
||||
|
||||
StepVerifier.create(result) |
||||
.assertNext(data(headersFile("file2", "a.txt"), DataBufferUtils::release, true)) |
||||
.assertNext(data(headersFile("file2", "b.txt"), DataBufferUtils::release, true)) |
||||
.verifyComplete(); |
||||
} |
||||
|
||||
private MockServerHttpRequest createRequest(Resource resource, String boundary) { |
||||
Flux<DataBuffer> body = DataBufferUtils |
||||
.readByteChannel(resource::readableChannel, bufferFactory, BUFFER_SIZE); |
||||
|
||||
MediaType contentType = new MediaType("multipart", "form-data", singletonMap("boundary", boundary)); |
||||
return MockServerHttpRequest.post("/") |
||||
.contentType(contentType) |
||||
.body(body); |
||||
} |
||||
|
||||
private static Consumer<PartEvent> form(Consumer<HttpHeaders> headersConsumer, String value) { |
||||
return data -> { |
||||
headersConsumer.accept(data.headers()); |
||||
String actual = data.content().toString(UTF_8); |
||||
assertThat(actual).isEqualTo(value); |
||||
assertThat(data.isLast()).isTrue(); |
||||
}; |
||||
} |
||||
|
||||
private static Consumer<PartEvent> data(Consumer<HttpHeaders> headersConsumer, Consumer<DataBuffer> bufferConsumer, boolean isLast) { |
||||
return data -> { |
||||
headersConsumer.accept(data.headers()); |
||||
bufferConsumer.accept(data.content()); |
||||
assertThat(data.isLast()).isEqualTo(isLast); |
||||
}; |
||||
} |
||||
|
||||
private static Consumer<HttpHeaders> headersFormField(String expectedName) { |
||||
return headers -> { |
||||
ContentDisposition cd = headers.getContentDisposition(); |
||||
assertThat(cd.isFormData()).isTrue(); |
||||
assertThat(cd.getName()).isEqualTo(expectedName); |
||||
}; |
||||
} |
||||
|
||||
private static Consumer<HttpHeaders> headersFile(String expectedName, String expectedFilename) { |
||||
return headers -> { |
||||
ContentDisposition cd = headers.getContentDisposition(); |
||||
assertThat(cd.isFormData()).isTrue(); |
||||
assertThat(cd.getName()).isEqualTo(expectedName); |
||||
assertThat(cd.getFilename()).isEqualTo(expectedFilename); |
||||
}; |
||||
} |
||||
|
||||
private static Consumer<DataBuffer> bodyText(String expected) { |
||||
return buffer -> { |
||||
String s = buffer.toString(UTF_8); |
||||
DataBufferUtils.release(buffer); |
||||
assertThat(s).isEqualTo(expected); |
||||
}; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
--boundary |
||||
Part: 1 |
||||
|
||||
|
||||
--boundary |
||||
Part: 2 |
||||
|
||||
a |
||||
--boundary-- |
||||
Loading…
Reference in new issue