diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index cc7e55b4b..a804af82d 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -750,26 +750,33 @@ 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 field.required: - return - else: - return deepcopy(field.default) + + 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 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)) diff --git a/tests/test_form_default.py b/tests/test_form_default.py index 2a12049d1..dc119f1b8 100644 --- a/tests/test_form_default.py +++ b/tests/test_form_default.py @@ -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} diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py index 1db63f021..0942ba171 100644 --- a/tests/test_forms_single_model.py +++ b/tests/test_forms_single_model.py @@ -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": {}, }, ] }