Browse Source
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)pull/14212/head
2 changed files with 116 additions and 2 deletions
@ -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"} |
|||
Loading…
Reference in new issue