From e07f5dea761ed8083a0891b0e4b9adb95b20abcf Mon Sep 17 00:00:00 2001 From: Abhishek Biswas Date: Wed, 22 Oct 2025 00:54:57 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20form=20validation=20regres?= =?UTF-8?q?sion=20with=20empty=20string=20default=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #13533 This fixes regressions in form handling where empty strings in form fields were incorrectly treated as values instead of using default values for optional fields. ## Problem When form fields with Optional types and None defaults received empty strings (""), FastAPI incorrectly treated them as empty string values instead of using the default value (None). This affected both: - x-www-form-urlencoded forms - multipart/form-data forms This regression broke existing applications when upgrading from v0.112.4 to v0.115.x. ## Solution Modified the _extract_form_body function in fastapi/dependencies/utils.py: 1. Track defined body_field aliases to distinguish between declared fields and extra fields 2. Don't add empty strings for Form fields since _get_multidict_value already handles them correctly by returning default values 3. Only add extra fields from received_body that are not already defined as body_fields to prevent overwriting correctly processed default values ## Tests - Added comprehensive regression tests in tests/test_forms_empty_string_default_13533.py - All existing form-related tests pass (verified 20+ tests) - Python 3.8+ compatible (uses typing_extensions.Annotated) --- fastapi/dependencies/utils.py | 13 ++- .../test_forms_empty_string_default_13533.py | 105 ++++++++++++++++++ 2 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 tests/test_forms_empty_string_default_13533.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index aa06dd2a9..8b080fdb5 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -899,6 +899,8 @@ async def _extract_form_body( received_body: FormData, ) -> Dict[str, Any]: values = {} + # Track which keys are defined as body_fields to avoid re-adding empty strings + body_field_aliases = {field.alias for field in body_fields} for field in body_fields: value = _get_multidict_value(field, received_body) @@ -928,10 +930,17 @@ async def _extract_form_body( for sub_value in value: tg.start_soon(process_fn, sub_value.read) value = serialize_sequence_value(field=field, value=results) - if value is not None: + # Don't add empty strings for Form fields - _get_multidict_value already handled them + if value is not None and not ( + isinstance(field_info, (params.Form, temp_pydantic_v1_params.Form)) + and isinstance(value, str) + and value == "" + ): values[field.alias] = value + # Only add extra fields that are not already defined as body_fields + # This prevents overwriting correctly processed default values for key, value in received_body.items(): - if key not in values: + if key not in body_field_aliases and key not in values: values[key] = value return values diff --git a/tests/test_forms_empty_string_default_13533.py b/tests/test_forms_empty_string_default_13533.py new file mode 100644 index 000000000..98edaccb0 --- /dev/null +++ b/tests/test_forms_empty_string_default_13533.py @@ -0,0 +1,105 @@ +""" +Test for issue #13533: Multiple regressions in the handling of forms & form validation + +This test verifies that empty strings in form fields are correctly handled: +- For optional fields with None default, empty string should result in None +- For optional fields with int type, empty string should result in None (not parsing error) +- This applies to both x-www-form-urlencoded and multipart/form-data +""" + +from typing import Optional + +from typing_extensions import Annotated + +from fastapi import FastAPI, File, Form +from fastapi.testclient import TestClient + +# Test app for URL-encoded forms with optional string +app_urlencoded_str = FastAPI() + + +@app_urlencoded_str.post("/test") +def endpoint_urlencoded_str( + name: Annotated[Optional[str], Form(embed=True)] = None, +): + return {"name": name} + + +# Test app for URL-encoded forms with optional int +app_urlencoded_int = FastAPI() + + +@app_urlencoded_int.post("/test") +def endpoint_urlencoded_int( + age: Annotated[Optional[int], Form()] = None, +): + return {"age": age} + + +# Test app for multipart forms +app_multipart = FastAPI() + + +@app_multipart.post("/test") +def endpoint_multipart( + file: Annotated[Optional[bytes], File()] = None, + name: Annotated[Optional[str], Form(embed=True)] = None, +): + return {"file_size": len(file) if file else None, "name": name} + + +def test_urlencoded_empty_string_optional_str(): + """ + Regression test for #13533: Empty string in URL-encoded form + with optional str field should use default value (None) + """ + client = TestClient(app_urlencoded_str) + response = client.post("/test", data={"name": ""}) + assert response.status_code == 200 + assert response.json() == {"name": None} + + +def test_urlencoded_empty_string_optional_int(): + """ + Regression test for #13533: Empty string in URL-encoded form + with optional int field should use default value (None), not cause parsing error + """ + client = TestClient(app_urlencoded_int) + response = client.post("/test", data={"age": ""}) + assert response.status_code == 200 + assert response.json() == {"age": None} + + +def test_multipart_empty_string_optional_str(): + """ + Regression test for #13533: Empty string in multipart form + with optional str field should use default value (None) + """ + client = TestClient(app_multipart) + response = client.post("/test", data={"name": ""}) + assert response.status_code == 200 + assert response.json() == {"file_size": None, "name": None} + + +def test_urlencoded_with_actual_value(): + """Verify that actual values still work correctly""" + client = TestClient(app_urlencoded_str) + response = client.post("/test", data={"name": "John"}) + assert response.status_code == 200 + assert response.json() == {"name": "John"} + + +def test_urlencoded_int_with_actual_value(): + """Verify that actual int values still work correctly""" + client = TestClient(app_urlencoded_int) + response = client.post("/test", data={"age": "25"}) + assert response.status_code == 200 + assert response.json() == {"age": 25} + + +def test_multipart_with_actual_value(): + """Verify that actual values in multipart forms still work correctly""" + client = TestClient(app_multipart) + response = client.post("/test", data={"name": "John"}) + assert response.status_code == 200 + assert response.json() == {"file_size": None, "name": "John"}