fix: Preserve empty strings in multipart/form-data requests (#4204)#4271
Conversation
4fd77f1 to
31286df
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4271 +/- ##
==========================================
- Coverage 98.25% 98.25% -0.01%
==========================================
Files 343 343
Lines 15824 15822 -2
Branches 1746 1745 -1
==========================================
- Hits 15548 15546 -2
Misses 138 138
Partials 138 138 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
31286df to
d380e39
Compare
eb2e4ea to
5d3d9e3
Compare
5d3d9e3 to
41a11e9
Compare
- Create a copy of form_values to handle type conversions - Prevents type error when assigning None to dict[str, Any] - Maintains all functionality while satisfying type checker - All tests continue to pass Fix: Preserve empty strings in multipart/form-data requests for GitHub issue litestar-org#4204 - Fix issue litestar-org#4204 where empty strings in multipart forms were converted to None - Change boolean check to length check in _multipart.py line 114 - Empty bytearrays now decode to empty strings instead of becoming None - Add comprehensive tests for empty string handling in multipart forms - Ensure consistency between URL-encoded and multipart form behavior The fix changes 'elif data:' to 'elif len(data) > 0:' and handles the empty case by decoding the empty bytearray to an empty string. Tests verify: - Empty strings are preserved in multipart forms - Consistency between URL-encoded and multipart behavior - Raw multipart content parsing works correctly - Existing functionality remains unaffected
41a11e9 to
9c9c45a
Compare
provinzkraut
left a comment
There was a problem hiding this comment.
This fix is way to complex and imposes significant additional runtime overhead. Instead of trying to remove the unwanted Nones, the behaviour should be fixed during multipart parsing. Extracting the multipart data shouldn't include that much more special casing and trickery than doing the same for URL encoded form data, which already works as expected
0261fcc to
fbf61b8
Compare
|
Documentation preview will be available shortly at https://litestar-org.github.io/litestar-docs-preview/4271 |
provinzkraut
left a comment
There was a problem hiding this comment.
This already looks better. Still, a couple of suggestions for the tests
Co-authored-by: Janek Nouvertné <provinzkraut@posteo.de>
9c44181 to
cd30507
Compare
0fcd6e3 to
5be1952
Compare
|
After upgrading to 2.18 some of my tests broke due to this change. Ultimately it's an issue on my end but it took some time to figure it out, plus there are some difference between DTO (PydanticDTO specifically) and non-DTO (Pydantic BaseModel). In short:
I can post a MCVE if interested. |
i think you should |
from datetime import date
from typing import Annotated, Any
from litestar import Litestar, post
from litestar.datastructures import UploadFile
from litestar.dto import DTOData
from litestar.enums import RequestEncodingType
from litestar.params import Body
from litestar.plugins.pydantic import PydanticDTO
from pydantic import BaseModel
class MyForm(BaseModel, arbitrary_types_allowed=True):
script: UploadFile
updated_on: date | None = None
@post("/")
async def process(
data: Annotated[MyForm, Body(media_type=RequestEncodingType.MULTI_PART)],
) -> dict[str, Any]:
return {"filename": data.script.filename, "updated_on": data.updated_on}
@post("/dto", dto=PydanticDTO[MyForm])
async def process_dto(
data: Annotated[DTOData[MyForm], Body(media_type=RequestEncodingType.MULTI_PART)],
) -> dict[str, Any]:
builtins = data.as_builtins()
return {
"filename": builtins["script"].filename,
"updated_on": builtins["updated_on"],
}
app = Litestar(route_handlers=[process, process_dto])
import httpx
with open("upload.py", "rb") as f:
files = {"script": f}
for data in {}, {"updated_on": None}, {"updated_on": "null"}:
print(f"\n--- data={data} ---\n")
resp = httpx.post("http://localhost:8000", files=files, data=data)
print(resp.text)
print("-" * 120)
resp = httpx.post("http://localhost:8000/dto", files=files, data=data)
print(resp.text)
|
…n no file submitted
When a browser submits a multipart form without selecting a file, the field
is sent as an empty string (""). Since 2.18.0 (PR litestar-org#4271 which fixed litestar-org#4204),
this empty string is no longer converted to None, causing validation to fail
for Optional[UploadFile] fields with a 400 error.
Fix: in _extract_multipart, before the type-hints loop, detect when a field
value is "" and the expected type is Optional[UploadFile] (or a subclass),
and replace the empty string with None before it reaches the conversion layer.
Fixes litestar-org#4647
…ltipart form (#4659) * fix: Optional[UploadFile] with multipart/form-data should be None when no file submitted When a browser submits a multipart form without selecting a file, the field is sent as an empty string (""). Since 2.18.0 (PR #4271 which fixed #4204), this empty string is no longer converted to None, causing validation to fail for Optional[UploadFile] fields with a 400 error. Fix: in _extract_multipart, before the type-hints loop, detect when a field value is "" and the expected type is Optional[UploadFile] (or a subclass), and replace the empty string with None before it reaches the conversion layer. Fixes #4647 * fix: add type annotation for inner to satisfy mypy * refactor: simplify Optional[UploadFile] check using isinstance guard
Description
Closes #4204