Emit multipart parts with empty bodies in PartGenerator

Parts were emitted only from PartListener.onBody(buffer, last=true), so a
part with an empty body (for example a blank form field, or a trailing
empty part) was silently dropped from the resulting MultiValueMap, and
was indistinguishable from an absent field.

This carries over the fix from the reactive DefaultPartHttpMessageReader
(spring-framework#30953): State gains an onComplete() callback that emits
the part, also when it has an empty body. It is invoked when a new part
begins, and when parsing completes for the final part.

Signed-off-by: Sagar Chanchal <Sagarr2112@gmail.com>
This commit is contained in:
Sagar Chanchal
2026-09-07 11:42:44 +02:00
committed by Brian Clozel
parent 2028c54d01
commit 8f4fcb6cbc
4 changed files with 77 additions and 0 deletions
@@ -86,6 +86,7 @@ final class PartGenerator implements MultipartParser.PartListener {
@Override
public void onHeaders(HttpHeaders headers) {
this.state.onComplete();
if (isFormField(headers)) {
this.state = new FormFieldState(headers);
}
@@ -127,6 +128,7 @@ final class PartGenerator implements MultipartParser.PartListener {
@Override
public void onComplete() {
this.state.onComplete();
if (logger.isTraceEnabled()) {
logger.trace("Finished reading " + this.partCount + " part(s)");
}
@@ -171,6 +173,14 @@ final class PartGenerator implements MultipartParser.PartListener {
*/
void onBody(DataBuffer dataBuffer, boolean last);
/**
* Invoked when no further {@link #onBody(DataBuffer, boolean) body} is
* expected for the current part, that is when a new part begins, or when
* parsing completed. This emits the part, also when it has an empty body.
*/
default void onComplete() {
}
/**
* Clean up resources.
*/
@@ -210,6 +220,8 @@ final class PartGenerator implements MultipartParser.PartListener {
private final HttpHeaders headers;
private boolean emitted;
public FormFieldState(HttpHeaders headers) {
this.headers = headers;
}
@@ -227,6 +239,14 @@ final class PartGenerator implements MultipartParser.PartListener {
PartGenerator.this.maxInMemorySize + " bytes");
}
if (last) {
onComplete();
}
}
@Override
public void onComplete() {
if (!this.emitted) {
this.emitted = true;
byte[] bytes = this.value.toByteArrayUnsafe();
String value = new String(bytes, MultipartUtils.charset(this.headers));
FormFieldPart formFieldPart = DefaultParts.formFieldPart(this.headers, value);
@@ -268,6 +288,7 @@ final class PartGenerator implements MultipartParser.PartListener {
private final HttpHeaders headers;
private boolean emitted;
public InMemoryState(HttpHeaders headers) {
this.headers = headers;
@@ -282,6 +303,14 @@ final class PartGenerator implements MultipartParser.PartListener {
}
this.content.add(dataBuffer);
if (last) {
onComplete();
}
}
@Override
public void onComplete() {
if (!this.emitted) {
this.emitted = true;
emitMemoryPart();
}
}
@@ -339,6 +368,8 @@ final class PartGenerator implements MultipartParser.PartListener {
private long byteCount;
private boolean emitted;
public FileState(HttpHeaders headers, Path folder) {
this.headers = headers;
this.file = createFile(folder);
@@ -377,6 +408,14 @@ final class PartGenerator implements MultipartParser.PartListener {
}
writeBuffer(dataBuffer);
if (last) {
onComplete();
}
}
@Override
public void onComplete() {
if (!this.emitted) {
this.emitted = true;
Part part = DefaultParts.part(this.headers, this.file);
PartGenerator.this.addPart(part);
closeOutputStream();
@@ -196,6 +196,26 @@ class MultipartHttpMessageConverterTests {
assertThat(result.get("text2")).anyMatch(isFormData("text2", "b"));
}
@Test
void readMultipartEmptyPart() throws Exception {
MockHttpInputMessage response = createMultipartResponse("servlet-empty-part.multipart", "boundary");
MultiValueMap<String, Part> result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null);
assertThat(result).containsOnlyKeys("text1", "text2");
assertThat(result.get("text1")).anyMatch(isFormData("text1", ""));
assertThat(result.get("text2")).anyMatch(isFormData("text2", "a"));
}
@Test
void readMultipartEmptyLastPart() throws Exception {
MockHttpInputMessage response = createMultipartResponse("servlet-empty-last-part.multipart", "boundary");
MultiValueMap<String, Part> result = converter.read(ResolvableType.forClassWithGenerics(MultiValueMap.class, String.class, Part.class), response, null);
assertThat(result).containsOnlyKeys("text1", "text2");
assertThat(result.get("text1")).anyMatch(isFormData("text1", "a"));
assertThat(result.get("text2")).anyMatch(isFormData("text2", ""));
}
@Test
void readMultipartInvalid() throws Exception {
MockHttpInputMessage response = createMultipartResponse("garbage-1.multipart", "boundary");
@@ -0,0 +1,9 @@
--boundary
Content-Disposition: form-data; name="text1"
a
--boundary
Content-Disposition: form-data; name="text2"
--boundary--
@@ -0,0 +1,9 @@
--boundary
Content-Disposition: form-data; name="text1"
--boundary
Content-Disposition: form-data; name="text2"
a
--boundary--