diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 80f9c76e90..ed5959f0cf 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -745,10 +745,9 @@ def _get_multidict_value( and len(value) == 0 ) ): - if field.field_info.is_required(): - return - else: - return deepcopy(field.default) + # Don't pre-fill defaults here - let Pydantic handle them during validation + # This preserves model_fields_set correctly for Form fields + return None return value diff --git a/tests/test_form_model_fields_set.py b/tests/test_form_model_fields_set.py new file mode 100644 index 0000000000..9bae51e022 --- /dev/null +++ b/tests/test_form_model_fields_set.py @@ -0,0 +1,120 @@ +""" +Test that Form-based dependency models preserve model_fields_set correctly. + +This test addresses issue https://github.com/fastapi/fastapi/issues/13399 +where Form models were pre-filling default values, making it impossible +to distinguish between explicitly set fields and fields using defaults. +""" + +from typing import Annotated + +import pytest +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel + + +class ModelWithDefaults(BaseModel): + field_bool: bool = True + field_str: str = "default_value" + field_int: int = 42 + + +app = FastAPI() + + +@app.post("/json") +async def json_endpoint(model: ModelWithDefaults): + return { + "fields_set": list(model.model_fields_set), + "field_bool": model.field_bool, + "field_str": model.field_str, + "field_int": model.field_int, + } + + +@app.post("/form") +async def form_endpoint(model: Annotated[ModelWithDefaults, Form()]): + return { + "fields_set": list(model.model_fields_set), + "field_bool": model.field_bool, + "field_str": model.field_str, + "field_int": model.field_int, + } + + +client = TestClient(app) + + +def test_json_empty_preserves_fields_set(): + """JSON with empty dict should have empty fields_set""" + response = client.post("/json", json={}) + assert response.status_code == 200 + data = response.json() + assert data["fields_set"] == [] + assert data["field_bool"] is True + assert data["field_str"] == "default_value" + assert data["field_int"] == 42 + + +def test_json_partial_preserves_fields_set(): + """JSON with partial data should only set provided fields""" + response = client.post("/json", json={"field_str": "custom"}) + assert response.status_code == 200 + data = response.json() + assert data["fields_set"] == ["field_str"] + assert data["field_bool"] is True + assert data["field_str"] == "custom" + assert data["field_int"] == 42 + + +def test_json_all_fields_preserves_fields_set(): + """JSON with all fields should mark all as set""" + response = client.post( + "/json", + json={"field_bool": False, "field_str": "custom", "field_int": 100}, + ) + assert response.status_code == 200 + data = response.json() + assert set(data["fields_set"]) == {"field_bool", "field_str", "field_int"} + assert data["field_bool"] is False + assert data["field_str"] == "custom" + assert data["field_int"] == 100 + + +def test_form_empty_preserves_fields_set(): + """Form with empty data should have empty fields_set (matching JSON behavior)""" + response = client.post("/form", data={}) + assert response.status_code == 200 + data = response.json() + # This is the key fix: Form should behave like JSON + assert data["fields_set"] == [] + assert data["field_bool"] is True + assert data["field_str"] == "default_value" + assert data["field_int"] == 42 + + +def test_form_partial_preserves_fields_set(): + """Form with partial data should only set provided fields (matching JSON behavior)""" + response = client.post("/form", data={"field_str": "custom"}) + assert response.status_code == 200 + data = response.json() + # This is the key fix: only field_str should be in fields_set + assert data["fields_set"] == ["field_str"] + assert data["field_bool"] is True + assert data["field_str"] == "custom" + assert data["field_int"] == 42 + + +def test_form_all_fields_preserves_fields_set(): + """Form with all fields should mark all as set""" + response = client.post( + "/form", + data={"field_bool": "false", "field_str": "custom", "field_int": "100"}, + ) + assert response.status_code == 200 + data = response.json() + assert set(data["fields_set"]) == {"field_bool", "field_str", "field_int"} + assert data["field_bool"] is False + assert data["field_str"] == "custom" + assert data["field_int"] == 100