Browse Source

🐛 Fix form validation regression with empty string default values

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
Abhishek Biswas 9 months ago
parent
commit
e07f5dea76
  1. 13
      fastapi/dependencies/utils.py
  2. 105
      tests/test_forms_empty_string_default_13533.py

13
fastapi/dependencies/utils.py

@ -899,6 +899,8 @@ async def _extract_form_body(
received_body: FormData, received_body: FormData,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
values = {} 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: for field in body_fields:
value = _get_multidict_value(field, received_body) value = _get_multidict_value(field, received_body)
@ -928,10 +930,17 @@ async def _extract_form_body(
for sub_value in value: for sub_value in value:
tg.start_soon(process_fn, sub_value.read) tg.start_soon(process_fn, sub_value.read)
value = serialize_sequence_value(field=field, value=results) 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 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(): 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 values[key] = value
return values return values

105
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"}
Loading…
Cancel
Save