mirror of
https://github.com/fastapi/fastapi.git
synced 2026-11-05 23:53:14 -05:00
🐛 Fix default handling for request parameters and models (#16527)
This commit is contained in:
1 parent
f63265848e
commit
32240026ad
8 files changed
+151
-101
No files matched your search
@@ -7,6 +7,12 @@ hide:
|
||||
|
||||
## Latest Changes
|
||||
|
||||
Defaults for omitted `Query`, `Header`, `Cookie`, and `Form` parameters and model fields are no longer treated as user input. Defaults are not validated unless `validate_default=True` is enabled. If you rely on validators running for omitted values, enable default validation with `Field(validate_default=True)` for model fields or, for example, `Query(validate_default=True)` for individual parameters. `Body(validate_default=True)` is now honored too.
|
||||
|
||||
For parameter models, applying a default no longer marks a field as explicitly set, so `model_dump(exclude_unset=True)` excludes it. Default factories run once, and validation errors no longer include injected defaults in `input`.
|
||||
|
||||
Omitted boolean form fields still become `False` and count as explicitly set.
|
||||
|
||||
## 0.147.0 (2026-10-19)
|
||||
|
||||
Omitted `bool` and `bool | None` form fields are now interpreted as `False`, matching unchecked HTML checkboxes. This applies to individual `Form` parameters and form models, including required fields and fields with defaults of `True` or `None`. The inferred value is considered explicitly set, including in `model_dump(exclude_unset=True)`.
|
||||
|
||||
@@ -737,8 +737,9 @@ def _validate_value_with_model_field(
|
||||
if value is None:
|
||||
if field.field_info.is_required():
|
||||
return None, [get_missing_field_error(loc=loc)]
|
||||
else:
|
||||
return deepcopy(field.default), []
|
||||
value = deepcopy(field.default)
|
||||
if not field.field_info.validate_default:
|
||||
return value, []
|
||||
return field.validate(value, values, loc=loc)
|
||||
|
||||
|
||||
@@ -770,10 +771,8 @@ def _get_multidict_value(
|
||||
and len(value) == 0
|
||||
)
|
||||
):
|
||||
if field.field_info.is_required():
|
||||
return
|
||||
else:
|
||||
return deepcopy(field.default)
|
||||
# Apply defaults during validation, not while extracting request data.
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
|
||||
@@ -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": {},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Annotated
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import Body, Cookie, FastAPI, Form, Header, Query
|
||||
@@ -13,20 +14,7 @@ def double(value):
|
||||
ValidatedInt = Annotated[int | None, BeforeValidator(double)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parameter",
|
||||
[
|
||||
pytest.param(
|
||||
parameter,
|
||||
marks=pytest.mark.xfail(
|
||||
reason="Missing values are validated as explicitly supplied defaults",
|
||||
raises=AssertionError,
|
||||
),
|
||||
)
|
||||
for parameter in (Query, Header, Cookie, Form)
|
||||
]
|
||||
+ [Body],
|
||||
)
|
||||
@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body])
|
||||
@pytest.mark.parametrize("path", ["/parameter", "/model"])
|
||||
def test_missing_value_uses_default_without_validation(parameter, path):
|
||||
app = FastAPI()
|
||||
@@ -183,3 +171,78 @@ def test_model_can_validate_default(parameter, default, expected):
|
||||
response = client.post("/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body])
|
||||
@pytest.mark.parametrize("default,expected", [(None, 0), (10, 20)])
|
||||
def test_parameter_can_validate_default(parameter, default, expected):
|
||||
app = FastAPI()
|
||||
field = (
|
||||
Body(embed=True, validate_default=True)
|
||||
if parameter is Body
|
||||
else parameter(validate_default=True)
|
||||
)
|
||||
|
||||
@app.post("/")
|
||||
def read_parameter(value: Annotated[ValidatedInt, field] = default):
|
||||
return value
|
||||
|
||||
client = TestClient(app)
|
||||
if parameter is Body:
|
||||
response = client.post("/", json={})
|
||||
else:
|
||||
response = client.post("/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body])
|
||||
@pytest.mark.parametrize("default,expected", [(None, 0), (10, 20)])
|
||||
def test_parameter_validates_default_factory_once(parameter, default, expected):
|
||||
app = FastAPI()
|
||||
default_factory = Mock(return_value=default)
|
||||
field = (
|
||||
Body(embed=True, default_factory=default_factory, validate_default=True)
|
||||
if parameter is Body
|
||||
else parameter(default_factory=default_factory, validate_default=True)
|
||||
)
|
||||
|
||||
@app.post("/")
|
||||
def read_parameter(value: Annotated[ValidatedInt, field]):
|
||||
return value
|
||||
|
||||
default_factory.assert_not_called()
|
||||
client = TestClient(app)
|
||||
if parameter is Body:
|
||||
response = client.post("/", json={})
|
||||
else:
|
||||
response = client.post("/")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == expected
|
||||
default_factory.assert_called_once_with()
|
||||
|
||||
|
||||
def test_parameter_rejects_invalid_default():
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/")
|
||||
def read_parameter(value: Annotated[int, Query(gt=0, validate_default=True)] = -1):
|
||||
return value
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.get("/")
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"detail": [
|
||||
{
|
||||
"type": "greater_than",
|
||||
"loc": ["query", "value"],
|
||||
"msg": "Input should be greater than 0",
|
||||
"input": -1,
|
||||
"ctx": {"gt": 0},
|
||||
}
|
||||
]
|
||||
}
|
||||
response = client.get("/", params={"value": "1"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == 1
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fastapi import Body, Cookie, FastAPI, Form, Header, Query
|
||||
from fastapi._compat import PYDANTIC_VERSION_MINOR_TUPLE
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -331,27 +332,15 @@ def test_missing_none_default_is_unset(parameter):
|
||||
@pytest.mark.parametrize(
|
||||
"parameter,annotation,default",
|
||||
[
|
||||
pytest.param(
|
||||
source,
|
||||
annotation,
|
||||
default,
|
||||
marks=pytest.mark.xfail(
|
||||
source is not Body,
|
||||
reason="Missing model fields are populated with defaults before validation",
|
||||
raises=AssertionError,
|
||||
),
|
||||
)
|
||||
for source, annotation, default in [
|
||||
(Query, int | None, 10),
|
||||
(Header, int | None, 10),
|
||||
(Cookie, int | None, 10),
|
||||
(Form, int | None, 10),
|
||||
(Body, int | None, 10),
|
||||
(Query, list[int] | None, [10]),
|
||||
(Header, list[int] | None, [10]),
|
||||
(Form, list[int] | None, [10]),
|
||||
(Body, list[int] | None, [10]),
|
||||
]
|
||||
(Query, int | None, 10),
|
||||
(Header, int | None, 10),
|
||||
(Cookie, int | None, 10),
|
||||
(Form, int | None, 10),
|
||||
(Body, int | None, 10),
|
||||
(Query, list[int] | None, [10]),
|
||||
(Header, list[int] | None, [10]),
|
||||
(Form, list[int] | None, [10]),
|
||||
(Body, list[int] | None, [10]),
|
||||
],
|
||||
)
|
||||
def test_missing_non_null_default_is_unset(parameter, annotation, default):
|
||||
@@ -376,27 +365,15 @@ def test_missing_non_null_default_is_unset(parameter, annotation, default):
|
||||
@pytest.mark.parametrize(
|
||||
"parameter,annotation,default",
|
||||
[
|
||||
pytest.param(
|
||||
source,
|
||||
annotation,
|
||||
default,
|
||||
marks=pytest.mark.xfail(
|
||||
source is not Body,
|
||||
reason="Missing model fields are populated with defaults before validation",
|
||||
raises=AssertionError,
|
||||
),
|
||||
)
|
||||
for source, annotation, default in [
|
||||
(Query, int | None, 10),
|
||||
(Header, int | None, 10),
|
||||
(Cookie, int | None, 10),
|
||||
(Form, int | None, 10),
|
||||
(Body, int | None, 10),
|
||||
(Query, list[int] | None, [10]),
|
||||
(Header, list[int] | None, [10]),
|
||||
(Form, list[int] | None, [10]),
|
||||
(Body, list[int] | None, [10]),
|
||||
]
|
||||
(Query, int | None, 10),
|
||||
(Header, int | None, 10),
|
||||
(Cookie, int | None, 10),
|
||||
(Form, int | None, 10),
|
||||
(Body, int | None, 10),
|
||||
(Query, list[int] | None, [10]),
|
||||
(Header, list[int] | None, [10]),
|
||||
(Form, list[int] | None, [10]),
|
||||
(Body, list[int] | None, [10]),
|
||||
],
|
||||
)
|
||||
def test_missing_factory_default_is_unset(parameter, annotation, default):
|
||||
@@ -418,25 +395,9 @@ def test_missing_factory_default_is_unset(parameter, annotation, default):
|
||||
assert response.json() == {"value": default, "fields_set": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parameter,path,default",
|
||||
[
|
||||
pytest.param(
|
||||
source,
|
||||
path,
|
||||
default,
|
||||
marks=pytest.mark.xfail(
|
||||
source is not Body
|
||||
and (default is None or (path == "/parameter" and source is not Form)),
|
||||
reason="The default factory is called more than once for an omitted value",
|
||||
raises=AssertionError,
|
||||
),
|
||||
)
|
||||
for source in (Query, Header, Cookie, Form, Body)
|
||||
for path in ("/parameter", "/model")
|
||||
for default in (None, 10)
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body])
|
||||
@pytest.mark.parametrize("path", ["/parameter", "/model"])
|
||||
@pytest.mark.parametrize("default", [None, 10])
|
||||
def test_missing_value_calls_default_factory_once(parameter, path, default):
|
||||
app = FastAPI()
|
||||
default_factory = Mock(return_value=default)
|
||||
@@ -468,6 +429,43 @@ def test_missing_value_calls_default_factory_once(parameter, path, default):
|
||||
default_factory.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body])
|
||||
@pytest.mark.skipif(
|
||||
PYDANTIC_VERSION_MINOR_TUPLE < (2, 10),
|
||||
reason="Default factories receiving validated data require Pydantic 2.10",
|
||||
)
|
||||
def test_model_default_factory_receives_validated_data(parameter):
|
||||
app = FastAPI()
|
||||
|
||||
class Model(BaseModel):
|
||||
value: int
|
||||
doubled: Annotated[int, Field(default_factory=lambda data: data["value"] * 2)]
|
||||
|
||||
@app.post("/")
|
||||
def read_model(params: Annotated[Model, parameter()]):
|
||||
return {
|
||||
"values": params.model_dump(),
|
||||
"supplied": params.model_dump(exclude_unset=True),
|
||||
}
|
||||
|
||||
client = TestClient(app)
|
||||
if parameter is Query:
|
||||
response = client.post("/", params={"value": "5"})
|
||||
elif parameter is Header:
|
||||
response = client.post("/", headers={"value": "5"})
|
||||
elif parameter is Cookie:
|
||||
response = client.post("/", headers={"cookie": "value=5"})
|
||||
elif parameter is Form:
|
||||
response = client.post("/", data={"value": "5"})
|
||||
else:
|
||||
response = client.post("/", json={"value": 5})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"values": {"value": 5, "doubled": 10},
|
||||
"supplied": {"value": 5},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body])
|
||||
@pytest.mark.parametrize("path", ["/parameter", "/model"])
|
||||
def test_supplied_value_does_not_call_default_factory(parameter, path):
|
||||
@@ -532,20 +530,7 @@ def test_value_equal_to_default_is_explicitly_set(parameter):
|
||||
assert response.json() == {"value": 0, "fields_set": ["value"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parameter",
|
||||
[
|
||||
pytest.param(
|
||||
parameter,
|
||||
marks=pytest.mark.xfail(
|
||||
reason="Omitted model fields are marked as explicitly set",
|
||||
raises=AssertionError,
|
||||
),
|
||||
)
|
||||
for parameter in (Query, Header, Cookie, Form)
|
||||
]
|
||||
+ [Body],
|
||||
)
|
||||
@pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body])
|
||||
def test_partial_model_submission(parameter):
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@@ -66,7 +66,6 @@ def test_header_param_model_invalid(client: TestClient):
|
||||
"loc": ["header", "save_data"],
|
||||
"msg": "Field required",
|
||||
"input": {
|
||||
"x_tag": [],
|
||||
"host": "testserver",
|
||||
"accept": "*/*",
|
||||
"accept-encoding": IsOneOf(
|
||||
|
||||
@@ -65,7 +65,7 @@ def test_header_param_model_invalid(client: TestClient):
|
||||
"type": "missing",
|
||||
"loc": ["header", "save_data"],
|
||||
"msg": "Field required",
|
||||
"input": {"x_tag": [], "host": "testserver"},
|
||||
"input": {"host": "testserver"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ def test_header_param_model_no_underscore(client: TestClient):
|
||||
"input": {
|
||||
"host": "testserver",
|
||||
"traceparent": "123",
|
||||
"x_tag": [],
|
||||
"accept": "*/*",
|
||||
"accept-encoding": IsOneOf(
|
||||
"gzip, deflate", "gzip, deflate, zstd"
|
||||
@@ -105,7 +104,6 @@ def test_header_param_model_invalid(client: TestClient):
|
||||
"loc": ["header", "save_data"],
|
||||
"msg": "Field required",
|
||||
"input": {
|
||||
"x_tag": [],
|
||||
"host": "testserver",
|
||||
"accept": "*/*",
|
||||
"accept-encoding": IsOneOf(
|
||||
|
||||
Reference in new issue
Block a user