Affects: Spring Boot 2.4.2
Hi, I've noticed that multipart/form-data does not include a content length when the request is created with multipart/form-data . This has caused an issue with trying to access a route that requires a content-length to be set or else I get a 411. I've created a small demo to recreate the problem which can found here https://github.com/JoeBalduz/spring-webclient-multipart-no-content-length.
I also saw that #26046 appears to be the same issue as mine but it is closed. Is this possibly something that can be fixed in a future version so that the content-length is set automatically for multipart/form-data? I've tried setting the content-length myself in multiple ways but the closest I got was being off by about 10 bytes.
Comment From: rstoyanchev
It is not easy to compute a content-length. It requires serializing and buffering the entire content in memory, which is hardly ideal for a multipart request in particular. Granted we may know the length of a Resource
(e.g. a file), but we'd still need to serialize headers, multipart boundaries, and all other parts with their content to know the content-length of the entire request.
Can you elaborate on "trying to access a route that requires a content-length"? For a multipart request that doesn't seem like a reasonable requirement.
Keep in mind also that we do set the content-length on individual parts where the length is know. For example for a file Resource
I would expect the part headers to have a content-length. That is a more feasible expectation I think.
Comment From: JoeBalduz
There's an API route I need to access where if the Content-Length header isn't set on the http request, I get a 411 error and I'm not able to upload my file, along with a string, to that route. I don't control that API, so I've had to switch to using RestTemplate in order to send that request however I'd prefer to not use RestTemplate since it is blocking.
Comment From: rstoyanchev
The FormHttpMessageConverter
also doesn't set the content-length for a multipart request. Can you elaborate?
Comment From: JoeBalduz
I'm not exactly sure where the content-length header is being set when I use RestTemplate but I can see in wireshark that it has a content-length header and I'm no longer getting the 411 error when sending a request to the API I'm accessing and the request is working. Here is how I'm using RestTemplate:
val headers = HttpHeaders()
headers.contentType = MediaType.MULTIPART_FORM_DATA
val httpEntity = HttpEntity(multipartBodyBuilder.build(), headers)
val response = restTemplate.postForEntity("url/im/accessing", httpEntity, String::class.java)
Comment From: JoeBalduz
I can also update the demo I have to add RestTemplate as one of the choices if that would help.
Comment From: rstoyanchev
Yes that would be great, thanks.
Comment From: JoeBalduz
The demo has been updated with a route that uses RestTemplate
Comment From: rstoyanchev
Thanks. Indeed the RestTemplate
applies BufferingClientHttpRequestWrapper
which buffers the entire request body which might require quite a bit of memory depending on the overall size and the number of concurrent requests.
The same can be achieved with the WebClient
also by buffering. We don't have anything out-of-the-box but you can write one. I tested with the below for example:
class BufferingFilter : ExchangeFilterFunction {
override fun filter(request: ClientRequest, next: ExchangeFunction): Mono<ClientResponse> {
return next.exchange(ClientRequest.from(request)
.body { message: ClientHttpRequest?, context: BodyInserter.Context? -> request.body().insert(BufferingDecorator(message), context!!) }
.build())
}
private class BufferingDecorator(delegate: ClientHttpRequest?) : ClientHttpRequestDecorator(delegate!!) {
override fun writeWith(body: Publisher<out DataBuffer>): Mono<Void> {
return DataBufferUtils.join(body)
.flatMap { dataBuffer: DataBuffer ->
val length = dataBuffer.readableByteCount()
headers.contentLength = length.toLong()
super.writeWith(Mono.just(dataBuffer))
}
}
}
}
Comment From: JoeBalduz
Awesome, that worked. Thanks for the help!
Comment From: EstebanDugueperoux2
Hello,
I have same issue and I don't know Kotlin. Is it possible to have same filter in Java?
Best Regards.
Comment From: rstoyanchev
I suppose we should just make such a buffering ExchangeFilterFunction
available. @EstebanDugueperoux2 could you create a new issue?
Comment From: nidhi-nair
@rstoyanchev I was unable to find a relevant issue for this, but have implemented an equivalent filter function in my project. May I open an issue to contribute a buffering ExchangeFilterFunction
addition to this repo?
Comment From: rstoyanchev
@nidhi-nair, sure we can consider that.
Comment From: kozel-stas
Java example, I will leave it here, maybe it will help somebody.
public class MultipartExchangeFilterFunction implements ExchangeFilterFunction {
@Override
@Nonnull
public Mono<ClientResponse> filter(@Nonnull ClientRequest request, @Nonnull ExchangeFunction next) {
if (MediaType.MULTIPART_FORM_DATA.includes(request.headers().getContentType())
&& (request.method() == HttpMethod.PUT || request.method() == HttpMethod.POST)) {
return next.exchange(
ClientRequest.from(request)
.body((outputMessage, context) -> request.body().insert(new BufferingDecorator(outputMessage), context))
.build()
);
} else {
return next.exchange(request);
}
}
private static final class BufferingDecorator extends ClientHttpRequestDecorator {
private BufferingDecorator(ClientHttpRequest delegate) {
super(delegate);
}
@Override
@Nonnull
public Mono<Void> writeWith(@Nonnull Publisher<? extends DataBuffer> body) {
return DataBufferUtils.join(body).flatMap(buffer -> {
getHeaders().setContentLength(buffer.readableByteCount());
return super.writeWith(Mono.just(buffer));
});
}
}
}
Comment From: Pro456458
Hi I come across to this issue 26489 "Spring WebClient multipart/form-data does not include a content length" . Provided solution working fine and inside ExchangeFilterFunction, content-length is well calculated in case of uploading file in a single chunk. But in case of uploading file in multiple chunk (for large size) it started failing and giving exception {The length of the file input stream does not match the provided length. Expected: X, Actual (approximate): 'Y' "} Could @rstoyanchev @bclozel @sbrannen @nidhi-nair @spring-projects-issues one help me to provide the solution how to calculate the content for each chunk
Below is code snippet for reference
public Mono<Object> publishFileUsingChunks(Mono<DataBuffer> dataBufferMono, String generatedFileName, String accessToken) {
this.webClientBuild(); // initialize webclient.builder
Mono<InputStream> inputStreamMono = dataBufferMono.map(dataBuffer -> dataBuffer.asInputStream(true));
log.debug("dataBufferMono to inputStreamMono ... and the given filename :: "+ generatedFileName);
return inputStreamMono.zipWhen(
inputStream -> {
try(BufferedInputStream in = new BufferedInputStream(inputStream)) {
int chunkSize= 40485760; //40MB (approx)
byte[] bytes = new byte[chunkSize];
int length = 0;
Mono<String> fileUploadResponseMono = null;
while((length = in.read(bytes)) != -1){
log.debug("upload in chunks has been started and length:: "+ length);
byte[] inpBuf = ArrayUtils.subarray(bytes, 0, length);
Mono<String> resp = importFile(accessToken, inpBuf, generatedFileName);
if(fileUploadResponseMono !=null){
fileUploadResponseMono = fileUploadResponseMono.zipWhen(
fileUploadResponse -> resp, (fileUploadResp, response) -> response);
}else {
fileUploadResponseMono = resp;
}
}
return fileUploadResponseMono;
} catch (IOException e) {
e.printStackTrace();
throw new CMSWriteServiceException(e.getMessage());
}
},
(inputStream, uploadResponse)-> uploadResponse
);
}
private Mono<String> importFile(String accessToken, byte[] inpBuf, String generatedFileName) {
String uri=RELATIVE_URL;
MultipartBodyBuilder builder = new MultipartBodyBuilder();
builder.part("file", inpBuf);
return webClient.post().uri(uriBuilder -> uriBuilder.path(uri)
.build())
.header(HttpHeaders.AUTHORIZATION, "Bearer " +accessToken)
.header(HttpHeaders.CONTENT_TYPE, MediaType.MULTIPART_FORM_DATA_VALUE)
.bodyValue(builder.build())
.retrieve()
.onStatus(HttpStatus::isError, response ->
response.bodyToMono(String.class).flatMap(error ->
Mono.error(new Exception(" Error: --->"+ error)))
)
.bodyToMono(String.class)
.doOnSuccess( response ->
log.debug("uploaded multipart successfully:: {}", response)
)
.doOnError(WebClientResponseException.class, error ->
log.error("Error Message :: {} Error Response :: {}",error.getMessage(), error.getResponseBodyAsString())
);
}
private void webClientBuild() {
this.webClient = WebClient.builder()
.codecs(clientCodecConfigurer -> clientCodecConfigurer.defaultCodecs()
.maxInMemorySize(500000000))
.baseUrl(BASE_URL)
.filter(new MultipartExchangeFilterFunction())
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT_ENCODING, "gzip, deflate, br")
.defaultHeader("KeepAlive", "true")
.build();
}