Browse Source

Fix form model pre-filling defaults, breaking model_fields_set

When a Pydantic BaseModel is used as a Form() parameter, fields that
were absent from the submission or submitted as blank strings ("") were
being pre-filled with their default values before model construction.
Pydantic then treated those pre-filled defaults as explicitly-set values,
making model_fields_set incorrect and preventing model validators from
distinguishing unset fields from fields set to their default.

Two cases needed fixing:

1. Absent fields - the field key is not in the form data at all
   (e.g. an unchecked HTML checkbox, which browsers omit entirely).

2. Blank string fields - the field key is present but with value ""
   (e.g. a cleared text input, which browsers send as key=).

Fix: add form_input=True parameter to _get_multidict_value. When set,
both absent and blank-string values return None rather than the field
default. _extract_form_body now calls _get_multidict_value(form_input=True)
so neither case gets pre-populated into the values dict. Pydantic then
applies defaults through its own mechanism, which correctly leaves those
fields out of model_fields_set.

Required fields are unaffected: Pydantic still raises a validation error
when a required field is absent from the submitted data.

Closes #13399

Co-authored-by: sneakers-the-rat <[email protected]>
pull/15258/head
Sai Tarrun Pitta 4 months ago
parent
commit
f818eac7d7
  1. 13
      fastapi/dependencies/utils.py
  2. 222
      tests/test_forms_defaults.py
  3. 4
      tests/test_forms_single_model.py

13
fastapi/dependencies/utils.py

@ -751,7 +751,10 @@ def _is_json_field(field: ModelField) -> bool:
def _get_multidict_value(
field: ModelField, values: Mapping[str, Any], alias: str | None = None
field: ModelField,
values: Mapping[str, Any],
alias: str | None = None,
form_input: bool = False,
) -> Any:
alias = alias or get_validation_alias(field)
if (
@ -765,7 +768,7 @@ def _get_multidict_value(
if (
value is None
or (
isinstance(field.field_info, params.Form)
(isinstance(field.field_info, params.Form) or form_input)
and isinstance(value, str) # For type checks
and value == ""
)
@ -774,8 +777,8 @@ def _get_multidict_value(
and len(value) == 0
)
):
if field.field_info.is_required():
return
if form_input or field.field_info.is_required():
return None
else:
return deepcopy(field.default)
return value
@ -916,7 +919,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)

222
tests/test_forms_defaults.py

@ -0,0 +1,222 @@
"""
Tests for correct model_fields_set and default handling in Form endpoints.
Covers:
- Absent fields (never sent by the client)
- Blank strings (HTML forms send "" for empty text inputs)
- Required fields still return 422 when absent
- Behaviour matches JSON body endpoints
- Aliases work correctly
Related: https://github.com/fastapi/fastapi/issues/13399
"""
from typing import Annotated
import pytest
from fastapi import FastAPI, Form
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI()
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class BoolModel(BaseModel):
"""Checkbox-style: bool with non-falsy default."""
checkbox: bool = True
class StringModel(BaseModel):
"""Text input with a non-empty default."""
text: str = "default"
alias_field: str = Field(alias="with", default="nothing")
class MixedModel(BaseModel):
field_bool: bool = True
field_str: str = "hello"
field_nullable: str | None = None
class RequiredModel(BaseModel):
required_field: str
optional_field: str = "default"
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@app.post("/bool")
async def bool_endpoint(model: Annotated[BoolModel, Form()]):
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()}
@app.post("/string")
async def string_endpoint(model: Annotated[StringModel, Form()]):
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump(by_alias=True)}
@app.post("/mixed")
async def mixed_endpoint(model: Annotated[MixedModel, Form()]):
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()}
@app.post("/required")
async def required_endpoint(model: Annotated[RequiredModel, Form()]):
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()}
# JSON body counterpart for parity checks
@app.post("/mixed-json")
async def mixed_json_endpoint(model: MixedModel):
return {"fields_set": sorted(model.model_fields_set), "values": model.model_dump()}
client = TestClient(app)
# ---------------------------------------------------------------------------
# Absent fields — never sent by the client
# ---------------------------------------------------------------------------
def test_absent_bool_field_not_in_fields_set():
"""Unchecked checkbox (absent from form) must NOT appear in model_fields_set."""
resp = client.post("/bool", data={})
assert resp.status_code == 200, resp.text
assert resp.json()["fields_set"] == []
def test_absent_bool_field_default_still_applied():
"""Default value is still present in the response even when field not submitted."""
resp = client.post("/bool", data={})
assert resp.status_code == 200, resp.text
assert resp.json()["values"]["checkbox"] is True
def test_submitted_bool_field_in_fields_set():
"""Submitted checkbox appears in model_fields_set."""
resp = client.post("/bool", data={"checkbox": "false"})
assert resp.status_code == 200, resp.text
assert resp.json()["fields_set"] == ["checkbox"]
assert resp.json()["values"]["checkbox"] is False
# ---------------------------------------------------------------------------
# Blank string fields — HTML forms send "" for empty text inputs
# ---------------------------------------------------------------------------
def test_blank_string_treated_as_unset():
"""Empty text input ('') must NOT appear in model_fields_set; default applied."""
resp = client.post("/string", data={"text": "", "with": ""})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["fields_set"] == []
assert body["values"]["text"] == "default"
assert body["values"]["with"] == "nothing"
def test_non_empty_string_in_fields_set():
"""Non-empty submission must appear in model_fields_set."""
resp = client.post("/string", data={"text": "hello"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert "text" in body["fields_set"]
assert body["values"]["text"] == "hello"
# ---------------------------------------------------------------------------
# Mixed model — no data submitted at all
# ---------------------------------------------------------------------------
def test_no_data_fields_set_empty():
"""Submitting no form data produces an empty model_fields_set."""
resp = client.post("/mixed", data={})
assert resp.status_code == 200, resp.text
assert resp.json()["fields_set"] == []
def test_partial_data_only_submitted_fields_in_set():
"""Only submitted fields appear in model_fields_set."""
resp = client.post("/mixed", data={"field_bool": "false"})
assert resp.status_code == 200, resp.text
assert resp.json()["fields_set"] == ["field_bool"]
def test_all_data_all_fields_in_set():
"""All submitted fields appear in model_fields_set."""
resp = client.post(
"/mixed",
data={"field_bool": "true", "field_str": "world", "field_nullable": "x"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["fields_set"] == ["field_bool", "field_nullable", "field_str"]
# ---------------------------------------------------------------------------
# Required fields — must still return 422 when absent
# ---------------------------------------------------------------------------
def test_required_field_absent_returns_422():
"""Required field absent from form must still return a 422 validation error."""
resp = client.post("/required", data={})
assert resp.status_code == 422, resp.text
detail = resp.json()["detail"]
locs = [e["loc"] for e in detail]
assert ["body", "required_field"] in locs
def test_required_field_present_optional_absent():
"""Required field present, optional absent → only required field in fields_set."""
resp = client.post("/required", data={"required_field": "value"})
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["fields_set"] == ["required_field"]
assert body["values"]["optional_field"] == "default"
# ---------------------------------------------------------------------------
# Parity with JSON body
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"submitted, expected_set",
[
({}, []),
({"field_bool": "true"}, ["field_bool"]),
({"field_str": "world"}, ["field_str"]),
(
{"field_bool": "false", "field_str": "hi", "field_nullable": "x"},
["field_bool", "field_nullable", "field_str"],
),
],
)
def test_form_fields_set_matches_json(submitted, expected_set):
"""Form and JSON endpoints produce the same model_fields_set for equivalent input."""
form_resp = client.post("/mixed", data=submitted)
json_payload = {}
if "field_bool" in submitted:
json_payload["field_bool"] = submitted["field_bool"].lower() == "true"
if "field_str" in submitted:
json_payload["field_str"] = submitted["field_str"]
if "field_nullable" in submitted:
json_payload["field_nullable"] = submitted["field_nullable"]
json_resp = client.post("/mixed-json", json=json_payload)
assert form_resp.status_code == 200, form_resp.text
assert json_resp.status_code == 200, json_resp.text
assert form_resp.json()["fields_set"] == json_resp.json()["fields_set"] == expected_set

4
tests/test_forms_single_model.py

@ -99,13 +99,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": {},
},
]
}

Loading…
Cancel
Save