From bcb268c31bfe0961beab6305c5e086c2e9ea0167 Mon Sep 17 00:00:00 2001 From: Sagar Chanchal Date: Thu, 27 Aug 2026 18:41:16 +0530 Subject: [PATCH] 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 --- .../converter/multipart/PartGenerator.java | 39 +++++++++++++++++++ .../MultipartHttpMessageConverterTests.java | 20 ++++++++++ .../servlet-empty-last-part.multipart | 9 +++++ .../multipart/servlet-empty-part.multipart | 9 +++++ 4 files changed, 77 insertions(+) create mode 100644 spring-web/src/test/resources/org/springframework/http/multipart/servlet-empty-last-part.multipart create mode 100644 spring-web/src/test/resources/org/springframework/http/multipart/servlet-empty-part.multipart diff --git a/spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java b/spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java index 5e1046e13ae3..f2aadf879006 100644 --- a/spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java +++ b/spring-web/src/main/java/org/springframework/http/converter/multipart/PartGenerator.java @@ -86,6 +86,7 @@ public MultiValueMap getParts() { @Override public void onHeaders(HttpHeaders headers) { + this.state.onComplete(); if (isFormField(headers)) { this.state = new FormFieldState(headers); } @@ -127,6 +128,7 @@ void deleteParts() { @Override public void onComplete() { + this.state.onComplete(); if (logger.isTraceEnabled()) { logger.trace("Finished reading " + this.partCount + " part(s)"); } @@ -171,6 +173,14 @@ private interface State { */ 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 @@ private final class FormFieldState implements State { private final HttpHeaders headers; + private boolean emitted; + public FormFieldState(HttpHeaders headers) { this.headers = headers; } @@ -227,6 +239,14 @@ public void onBody(DataBuffer dataBuffer, boolean last) { 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 @@ private final class InMemoryState implements State { private final HttpHeaders headers; + private boolean emitted; public InMemoryState(HttpHeaders headers) { this.headers = headers; @@ -282,6 +303,14 @@ public void onBody(DataBuffer dataBuffer, boolean last) { } this.content.add(dataBuffer); if (last) { + onComplete(); + } + } + + @Override + public void onComplete() { + if (!this.emitted) { + this.emitted = true; emitMemoryPart(); } } @@ -339,6 +368,8 @@ private final class FileState implements State { private long byteCount; + private boolean emitted; + public FileState(HttpHeaders headers, Path folder) { this.headers = headers; this.file = createFile(folder); @@ -377,6 +408,14 @@ public void onBody(DataBuffer dataBuffer, boolean last) { } 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(); diff --git a/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java b/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java index 14200fd12ae2..8fbc60fa72fa 100644 --- a/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java +++ b/spring-web/src/test/java/org/springframework/http/converter/multipart/MultipartHttpMessageConverterTests.java @@ -196,6 +196,26 @@ void readMultipartBrowser() throws Exception { assertThat(result.get("text2")).anyMatch(isFormData("text2", "b")); } + @Test + void readMultipartEmptyPart() throws Exception { + MockHttpInputMessage response = createMultipartResponse("servlet-empty-part.multipart", "boundary"); + MultiValueMap 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 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"); diff --git a/spring-web/src/test/resources/org/springframework/http/multipart/servlet-empty-last-part.multipart b/spring-web/src/test/resources/org/springframework/http/multipart/servlet-empty-last-part.multipart new file mode 100644 index 000000000000..cde4d1ad106f --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/multipart/servlet-empty-last-part.multipart @@ -0,0 +1,9 @@ +--boundary +Content-Disposition: form-data; name="text1" + +a +--boundary +Content-Disposition: form-data; name="text2" + + +--boundary-- diff --git a/spring-web/src/test/resources/org/springframework/http/multipart/servlet-empty-part.multipart b/spring-web/src/test/resources/org/springframework/http/multipart/servlet-empty-part.multipart new file mode 100644 index 000000000000..cef44ec25e20 --- /dev/null +++ b/spring-web/src/test/resources/org/springframework/http/multipart/servlet-empty-part.multipart @@ -0,0 +1,9 @@ +--boundary +Content-Disposition: form-data; name="text1" + + +--boundary +Content-Disposition: form-data; name="text2" + +a +--boundary--