This commit is contained in:
Motov Yurii 2025-12-16 21:09:33 +00:00 committed by GitHub
commit 9a2eab07a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 212 additions and 23 deletions

View File

@ -750,24 +750,31 @@ def _validate_value_with_model_field(
def _get_multidict_value(
field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None
field: ModelField,
values: Mapping[str, Any],
alias: Union[str, None] = None,
form_input: bool = False,
) -> Any:
alias = alias or get_validation_alias(field)
if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):
value = values.getlist(alias)
else:
value = values.get(alias, None)
if (
value is None
or (
isinstance(field.field_info, (params.Form, temp_pydantic_v1_params.Form))
and isinstance(value, str) # For type checks
and value == ""
)
or (is_sequence_field(field) and len(value) == 0)
if form_input:
# Special handling for form inputs:
# Treat empty strings or empty lists as missing values
if (isinstance(value, str) and value == "") or (
is_sequence_field(field) and len(value) == 0
):
return None
else:
# For non-form inputs:
# If value is None or an empty sequence, use the default (if not required) or
# treat as missing (if required)
if value is None or (is_sequence_field(field) and len(value) == 0):
if field.required:
return
return None
else:
return deepcopy(field.default)
return value
@ -906,7 +913,7 @@ async def _extract_form_body(
values = {}
for field in body_fields:
value = _get_multidict_value(field, received_body)
value = _get_multidict_value(field, received_body, form_input=True)
field_info = field.field_info
if (
isinstance(field_info, (params.File, temp_pydantic_v1_params.File))

View File

@ -1,12 +1,73 @@
from typing import Optional
from typing import Any, List, Optional
from fastapi import FastAPI, File, Form
from starlette.testclient import TestClient
import pytest
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
from typing_extensions import Annotated
from .utils import needs_pydanticv2
class FormParams(BaseModel):
param_1: str = "default"
param_2: Annotated[str, Field(alias="param_2_alias")] = "default"
param_3: Annotated[str, Form(alias="param_3_alias")] = "default"
upload_file: Optional[UploadFile] = None
file: Annotated[Optional[bytes], File()] = None
app = FastAPI()
@app.post("/form-param")
def form_param(
param_1: Annotated[str, Form()] = "default",
param_2: Annotated[str, Form(alias="param_2_alias")] = "default",
upload_file: Optional[UploadFile] = None,
file: Annotated[Optional[bytes], File()] = None,
):
return {
"param_1": param_1,
"param_2": param_2,
"upload_file": upload_file,
"file": file,
}
@app.post("/form-model")
def form_model(params: Annotated[FormParams, Form()]):
return {
"param_1": params.param_1,
"param_2": params.param_2,
"param_3": params.param_3,
"upload_file": params.upload_file,
"file": params.file,
}
@app.post("/form-param-list-with-default")
def form_param_list_with_default(
params: Annotated[
List[str],
Form(),
] = [], # noqa: B006 (default_factory doesn't work with Pydantic V1)
):
return {"params": params}
@app.post("/form-param-optional-list")
def form_param_optional_list(
params: Annotated[Optional[List[str]], Form()] = None,
):
return {"params": params}
@app.post("/form-model-fields-set")
def form_model_fields_set(params: Annotated[FormParams, Form()]):
return {"fields_set": params.model_fields_set}
@app.post("/urlencoded")
async def post_url_encoded(age: Annotated[Optional[int], Form()] = None):
return age
@ -20,16 +81,137 @@ async def post_multi_part(
return {"file": file, "age": age}
client = TestClient(app)
@pytest.fixture(scope="module")
def client():
with TestClient(app) as test_client:
yield test_client
def test_form_default_url_encoded():
@pytest.mark.parametrize(
"data",
[
pytest.param({}, id="no_data_sent"),
pytest.param(
{"param_1": "", "param_2_alias": "", "upload_file": "", "file": ""},
id="empty_strings_sent",
),
],
)
def test_defaults_form_param(client: TestClient, data: dict):
"""
Empty string or no input data - default value is used.
For parameters declared as single Form fields.
"""
response = client.post("/form-param", data=data)
assert response.json() == {
"param_1": "default",
"param_2": "default",
"upload_file": None,
"file": None,
}
@pytest.mark.parametrize(
"data",
[
pytest.param({}, id="no_data_sent"),
pytest.param(
{
"param_1": "",
"param_2_alias": "",
"param_3_alias": "",
"upload_file": "",
"file": "",
},
id="empty_strings_sent",
),
],
)
def test_defaults_form_model(client: TestClient, data: dict):
"""
Empty string or no data - default value is used.
For parameters declared as Form model.
"""
response = client.post("/form-model", data=data)
assert response.json() == {
"param_1": "default",
"param_2": "default",
"param_3": "default",
"upload_file": None,
"file": None,
}
@pytest.mark.parametrize(
("url", "expected_res"),
[
("/form-param-list-with-default", []),
("/form-param-optional-list", None),
],
)
def test_form_list_param_no_input(client: TestClient, url: str, expected_res: Any):
"""
No input data passed to list parameter - default value is used.
"""
response = client.post(url)
assert response.json() == {"params": expected_res}
@pytest.mark.parametrize(
("data", "expected_res"),
[
({"params": ""}, [""]),
({"params": [""]}, [""]),
({"params": ["", "a"]}, ["", "a"]),
({"params": ["a", ""]}, ["a", ""]),
],
)
@pytest.mark.parametrize(
"url", ["/form-param-list-with-default", "/form-param-optional-list"]
)
def test_form_list_param_empty_str(
client: TestClient, url: str, data: dict, expected_res: Any
):
"""
Empty strings passed to list parameter treated as empty strings.
"""
response = client.post(url, data=data)
assert response.json() == {"params": expected_res}
@pytest.mark.parametrize(
"data",
[
pytest.param({}, id="no_data_sent"),
pytest.param(
{
"param_1": "",
"param_2_alias": "",
"param_3_alias": "",
"upload_file": "",
"file": "",
},
id="empty_strings_sent",
),
],
)
@needs_pydanticv2
def test_form_model_fields_set(client: TestClient, data: dict):
"""
Check that fields are not pre-populated with default values when no data sent or
empty string sent.
"""
response = client.post("/form-model-fields-set", data=data)
assert response.json() == {"fields_set": []}
def test_form_default_url_encoded(client: TestClient):
response = client.post("/urlencoded", data={"age": ""})
assert response.status_code == 200
assert response.text == "null"
def test_form_default_multi_part():
def test_form_default_multi_part(client: TestClient):
response = client.post("/multipart", data={"age": ""})
assert response.status_code == 200
assert response.json() == {"file": None, "age": None}

View File

@ -121,13 +121,13 @@ def test_no_data():
"type": "missing",
"loc": ["body", "username"],
"msg": "Field required",
"input": {"tags": ["foo", "bar"], "with": "nothing"},
"input": {},
},
{
"type": "missing",
"loc": ["body", "lastname"],
"msg": "Field required",
"input": {"tags": ["foo", "bar"], "with": "nothing"},
"input": {},
},
]
}