Skip to content

Commit 00cc48d

Browse files
committed
Support repeatable multipart write
Closes gh-34859
1 parent d8ac3ff commit 00cc48d

File tree

2 files changed

+88
-28
lines changed

2 files changed

+88
-28
lines changed

spring-web/src/main/java/org/springframework/http/converter/FormHttpMessageConverter.java

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import java.util.LinkedHashMap;
2929
import java.util.List;
3030
import java.util.Map;
31+
import java.util.Objects;
3132

3233
import org.jspecify.annotations.Nullable;
3334

@@ -485,9 +486,18 @@ private void writeMultipart(
485486
outputMessage.getHeaders().setContentType(contentType);
486487

487488
if (outputMessage instanceof StreamingHttpOutputMessage streamingOutputMessage) {
488-
streamingOutputMessage.setBody(outputStream -> {
489-
writeParts(outputStream, parts, boundary);
490-
writeEnd(outputStream, boundary);
489+
boolean repeatable = checkPartsRepeatable(parts);
490+
streamingOutputMessage.setBody(new StreamingHttpOutputMessage.Body() {
491+
@Override
492+
public void writeTo(OutputStream outputStream) throws IOException {
493+
FormHttpMessageConverter.this.writeParts(outputStream, parts, boundary);
494+
writeEnd(outputStream, boundary);
495+
}
496+
497+
@Override
498+
public boolean repeatable() {
499+
return repeatable;
500+
}
491501
});
492502
}
493503
else {
@@ -496,6 +506,35 @@ private void writeMultipart(
496506
}
497507
}
498508

509+
@SuppressWarnings({"unchecked", "ConstantValue"})
510+
private <T> boolean checkPartsRepeatable(MultiValueMap<String, Object> map) {
511+
return map.entrySet().stream().allMatch(e -> e.getValue().stream().filter(Objects::nonNull).allMatch(part -> {
512+
HttpHeaders headers = null;
513+
Object body = part;
514+
if (part instanceof HttpEntity<?> entity) {
515+
headers = entity.getHeaders();
516+
body = entity.getBody();
517+
Assert.state(body != null, "Empty body for part '" + e.getKey() + "': " + part);
518+
}
519+
HttpMessageConverter<?> converter = findConverterFor(e.getKey(), headers, body);
520+
return (converter instanceof AbstractHttpMessageConverter<?> ahmc &&
521+
((AbstractHttpMessageConverter<T>) ahmc).supportsRepeatableWrites((T) body));
522+
}));
523+
}
524+
525+
private @Nullable HttpMessageConverter<?> findConverterFor(
526+
String name, @Nullable HttpHeaders headers, Object body) {
527+
528+
Class<?> partType = body.getClass();
529+
MediaType contentType = (headers != null ? headers.getContentType() : null);
530+
for (HttpMessageConverter<?> converter : this.partConverters) {
531+
if (converter.canWrite(partType, contentType)) {
532+
return converter;
533+
}
534+
}
535+
return null;
536+
}
537+
499538
/**
500539
* When {@link #setMultipartCharset(Charset)} is configured (i.e. RFC 2047,
501540
* {@code encoded-word} syntax) we need to use ASCII for part headers, or
@@ -521,32 +560,27 @@ private void writeParts(OutputStream os, MultiValueMap<String, Object> parts, by
521560
@SuppressWarnings("unchecked")
522561
private void writePart(String name, HttpEntity<?> partEntity, OutputStream os) throws IOException {
523562
Object partBody = partEntity.getBody();
524-
if (partBody == null) {
525-
throw new IllegalStateException("Empty body for part '" + name + "': " + partEntity);
526-
}
527-
Class<?> partType = partBody.getClass();
563+
Assert.state(partBody != null, "Empty body for part '" + name + "': " + partEntity);
528564
HttpHeaders partHeaders = partEntity.getHeaders();
529565
MediaType partContentType = partHeaders.getContentType();
530-
for (HttpMessageConverter<?> messageConverter : this.partConverters) {
531-
if (messageConverter.canWrite(partType, partContentType)) {
532-
Charset charset = isFilenameCharsetSet() ? StandardCharsets.US_ASCII : this.charset;
533-
HttpOutputMessage multipartMessage = new MultipartHttpOutputMessage(os, charset);
534-
String filename = getFilename(partBody);
535-
ContentDisposition.Builder cd = ContentDisposition.formData()
536-
.name(name);
537-
if (filename != null) {
538-
cd.filename(filename, this.multipartCharset);
539-
}
540-
multipartMessage.getHeaders().setContentDisposition(cd.build());
541-
if (!partHeaders.isEmpty()) {
542-
multipartMessage.getHeaders().putAll(partHeaders);
543-
}
544-
((HttpMessageConverter<Object>) messageConverter).write(partBody, partContentType, multipartMessage);
545-
return;
566+
HttpMessageConverter<?> converter = findConverterFor(name, partHeaders, partBody);
567+
if (converter != null) {
568+
Charset charset = isFilenameCharsetSet() ? StandardCharsets.US_ASCII : this.charset;
569+
HttpOutputMessage multipartMessage = new MultipartHttpOutputMessage(os, charset);
570+
String filename = getFilename(partBody);
571+
ContentDisposition.Builder cd = ContentDisposition.formData().name(name);
572+
if (filename != null) {
573+
cd.filename(filename, this.multipartCharset);
574+
}
575+
multipartMessage.getHeaders().setContentDisposition(cd.build());
576+
if (!partHeaders.isEmpty()) {
577+
multipartMessage.getHeaders().putAll(partHeaders);
546578
}
579+
((HttpMessageConverter<Object>) converter).write(partBody, partContentType, multipartMessage);
580+
return;
547581
}
548-
throw new HttpMessageNotWritableException("Could not write request: no suitable HttpMessageConverter " +
549-
"found for request type [" + partType.getName() + "]");
582+
throw new HttpMessageNotWritableException("Could not write request: " +
583+
"no suitable HttpMessageConverter found for request type [" + partBody.getClass().getName() + "]");
550584
}
551585

552586
/**

spring-web/src/test/java/org/springframework/http/converter/FormHttpMessageConverterTests.java

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2002-2024 the original author or authors.
2+
* Copyright 2002-2025 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -40,6 +40,7 @@
4040
import org.springframework.http.HttpEntity;
4141
import org.springframework.http.HttpHeaders;
4242
import org.springframework.http.MediaType;
43+
import org.springframework.http.StreamingHttpOutputMessage;
4344
import org.springframework.http.converter.support.AllEncompassingFormHttpMessageConverter;
4445
import org.springframework.http.converter.xml.SourceHttpMessageConverter;
4546
import org.springframework.util.LinkedMultiValueMap;
@@ -204,7 +205,7 @@ public String getFilename() {
204205
parameters.put("charset", UTF_8.name());
205206
parameters.put("foo", "bar");
206207

207-
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
208+
StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage();
208209
this.converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage);
209210

210211
final MediaType contentType = outputMessage.getHeaders().getContentType();
@@ -248,6 +249,8 @@ public String getFilename() {
248249
item = items.get(5);
249250
assertThat(item.getFieldName()).isEqualTo("json");
250251
assertThat(item.getContentType()).isEqualTo("application/json");
252+
253+
assertThat(outputMessage.wasRepeatable()).isTrue();
251254
}
252255

253256
@Test
@@ -286,7 +289,7 @@ public String getFilename() {
286289
parameters.put("charset", UTF_8.name());
287290
parameters.put("foo", "bar");
288291

289-
MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
292+
StreamingMockHttpOutputMessage outputMessage = new StreamingMockHttpOutputMessage();
290293
this.converter.write(parts, new MediaType("multipart", "form-data", parameters), outputMessage);
291294

292295
final MediaType contentType = outputMessage.getHeaders().getContentType();
@@ -330,6 +333,8 @@ public String getFilename() {
330333
item = items.get(5);
331334
assertThat(item.getFieldName()).isEqualTo("xml");
332335
assertThat(item.getContentType()).isEqualTo("text/xml");
336+
337+
assertThat(outputMessage.wasRepeatable()).isFalse();
333338
}
334339

335340
@Test // SPR-13309
@@ -444,6 +449,27 @@ private void assertInvalidFormIsRejectedWithSpecificException(String body) {
444449
}
445450

446451

452+
private static class StreamingMockHttpOutputMessage extends MockHttpOutputMessage implements StreamingHttpOutputMessage {
453+
454+
private boolean repeatable;
455+
456+
public boolean wasRepeatable() {
457+
return this.repeatable;
458+
}
459+
460+
@Override
461+
public void setBody(Body body) {
462+
try {
463+
this.repeatable = body.repeatable();
464+
body.writeTo(getBody());
465+
}
466+
catch (IOException ex) {
467+
throw new RuntimeException(ex);
468+
}
469+
}
470+
}
471+
472+
447473
private static class MockHttpOutputMessageRequestContext implements UploadContext {
448474

449475
private final MockHttpOutputMessage outputMessage;

0 commit comments

Comments
 (0)