Browse Source

Merge be89bde3f8 into 460f8d2cc8

pull/13464/merge
Jonny Saunders 12 hours ago
committed by GitHub
parent
commit
cdff9630f7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 54
      docs/en/docs/tutorial/request-form-models.md
  2. 48
      docs_src/request_form_models/tutorial003_an_py310.py
  3. 46
      docs_src/request_form_models/tutorial003_py310.py
  4. 57
      docs_src/request_form_models/tutorial004_an_py310.py
  5. 57
      docs_src/request_form_models/tutorial004_py310.py
  6. 15
      fastapi/dependencies/utils.py
  7. 286
      tests/test_forms_defaults.py
  8. 4
      tests/test_forms_single_model.py
  9. 44
      tests/test_tutorial/test_request_form_models/test_tutorial003.py
  10. 45
      tests/test_tutorial/test_request_form_models/test_tutorial004.py

54
docs/en/docs/tutorial/request-form-models.md

@ -73,6 +73,60 @@ They will receive an error response telling them that the field `extra` is not a
}
```
## Default Fields { #default-fields }
Form-encoded data has some quirks that can make working with pydantic models counterintuitive.
Say, for example, you were generating an HTML form from a model,
and that model had a boolean field in it that you wanted to display as a checkbox
with a default `True` value:
{* ../../docs_src/request_form_models/tutorial003_an_py310.py hl[11,10:23] *}
This works as expected when the checkbox remains checked,
the form encoded data in the request looks like this:
```formencoded
checkbox=on
```
and the JSON response is also correct:
```json
{"checkbox":true}
```
When the checkbox is *unchecked*, though, something strange happens.
The submitted form data is *empty*,
and the returned JSON data still shows `checkbox` still being `true`!
This is because checkboxes in HTML forms don't work exactly like the boolean inputs we expect,
when a checkbox is checked, if there is no `value` attribute, the value will be `"on"`,
and [the field will be omitted altogether if unchecked](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/checkbox).
When dealing with form models with defaults,
we need to take special care to handle cases where the field being *unset* has a specific meaning.
We also don't want to just treat any time the value is unset as ``False`` -
that would defeat the purpose of the default!
We want to specifically correct the behavior when it is used in the context of a *form.*
In some cases, we can resolve the problem by changing or removing the default,
but we don't always have that option -
particularly when the model is used in other places than the form
The recommended approach is to duplicate your model:
/// note
Take care to ensure that your duplicate models don't diverge,
e.g. if you are using sqlmodel,
where you may end up with `MyModel`, `MyModelCreate`, and `MyModelCreateForm`.
///
{* ../../docs_src/request_form_models/tutorial004_an_py310.py hl[7,13:25] *}
## Summary { #summary }
You can use Pydantic models to declare form fields in FastAPI. 😎

48
docs_src/request_form_models/tutorial003_an_py310.py

@ -0,0 +1,48 @@
from typing import Annotated
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel
class MyModel(BaseModel):
checkbox: bool = True
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
loader = DictLoader({"form.html": form_template})
templates = Jinja2Templates(env=Environment(loader=loader))
app = FastAPI()
@app.get("/form", response_class=HTMLResponse)
async def show_form(request: Request):
return templates.TemplateResponse(
request=request, name="form.html", context={"model": MyModel}
)
@app.post("/form")
async def submit_form(data: Annotated[MyModel, Form()]) -> MyModel:
return data

46
docs_src/request_form_models/tutorial003_py310.py

@ -0,0 +1,46 @@
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel
class MyModel(BaseModel):
checkbox: bool = True
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
loader = DictLoader({"form.html": form_template})
templates = Jinja2Templates(env=Environment(loader=loader))
app = FastAPI()
@app.get("/form", response_class=HTMLResponse)
async def show_form(request: Request):
return templates.TemplateResponse(
request=request, name="form.html", context={"model": MyModel}
)
@app.post("/form")
async def submit_form(data: MyModel = Form()) -> MyModel:
return data

57
docs_src/request_form_models/tutorial004_an_py310.py

@ -0,0 +1,57 @@
from typing import Annotated, cast
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel, ValidationInfo, model_validator
class MyModel(BaseModel):
checkbox: bool = True
class MyModelForm(MyModel):
@model_validator(mode="before")
def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict:
if "checkbox" not in value:
value["checkbox"] = False
return value
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
loader = DictLoader({"form.html": form_template})
templates = Jinja2Templates(env=Environment(loader=loader))
app = FastAPI()
@app.get("/form", response_class=HTMLResponse)
async def show_form(request: Request):
return templates.TemplateResponse(
request=request, name="form.html", context={"model": MyModel}
)
@app.post("/form")
async def submit_form(data: Annotated[MyModelForm, Form()]) -> MyModel:
data = cast(MyModel, data)
return data

57
docs_src/request_form_models/tutorial004_py310.py

@ -0,0 +1,57 @@
from typing import cast
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
from pydantic import BaseModel, model_validator
class MyModel(BaseModel):
checkbox: bool = True
class MyModelForm(MyModel):
@model_validator(mode="before")
def handle_defaults(cls, value: dict) -> dict:
if "checkbox" not in value:
value["checkbox"] = False
return value
form_template = """
<form action="/form" method="POST">
{% for field_name, field in model.model_fields.items() %}
<p>
<label for="{{ field_name }}">{{ field_name }}</label>
{% if field.annotation.__name__ == "bool" %}
<input type="checkbox" name="{{field_name}}"
{% if field.default %}
checked="checked"
{% endif %}
>
{% else %}
<input name="{{ field_name }}">
{% endif %}
</p>
{% endfor %}
<button type="submit">Submit</button>
</form>
"""
loader = DictLoader({"form.html": form_template})
templates = Jinja2Templates(env=Environment(loader=loader))
app = FastAPI()
@app.get("/form", response_class=HTMLResponse)
async def show_form(request: Request):
return templates.TemplateResponse(
request=request, name="form.html", context={"model": MyModel}
)
@app.post("/form")
async def submit_form(data: MyModelForm = Form()) -> MyModel:
data = cast(MyModel, data)
return data

15
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
@ -920,7 +923,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)
@ -941,6 +944,8 @@ async def _extract_form_body(
value = serialize_sequence_value(field=field, value=results)
if value is not None:
values[get_validation_alias(field)] = value
# preserve extra keys not in model body fields for validation
field_aliases = {get_validation_alias(field) for field in body_fields}
for key in received_body.keys():
if key not in field_aliases:

286
tests/test_forms_defaults.py

@ -0,0 +1,286 @@
from typing import Annotated
import pytest
from fastapi import FastAPI, Form
from pydantic import BaseModel, Field, model_validator
from starlette.testclient import TestClient
def _validate_input(value: dict) -> dict:
"""
model validators in before mode should receive values passed
to model instantiation before any further validation
"""
# we should not be double-instantiating the models
assert isinstance(value, dict)
value["init_input"] = value.copy()
# differentiate between explicit Nones and unpassed values
if "true_if_unset" not in value:
value["true_if_unset"] = True
return value
class Parent(BaseModel):
init_input: dict
# importantly, no default here
@model_validator(mode="before")
def validate_inputs(cls, value: dict) -> dict:
return _validate_input(value)
class StandardModel(Parent):
default_true: bool = True
default_false: bool = False
default_none: bool | None = None
default_zero: int = 0
default_str: str = "foo"
true_if_unset: bool | None = None
class FieldModel(Parent):
default_true: bool = Field(default=True)
default_false: bool = Field(default=False)
default_none: bool | None = Field(default=None)
default_zero: int = Field(default=0)
default_str: str = Field(default="foo")
true_if_unset: bool | None = Field(default=None)
class AnnotatedFieldModel(Parent):
default_true: Annotated[bool, Field(default=True)]
default_false: Annotated[bool, Field(default=False)]
default_none: Annotated[bool | None, Field(default=None)]
default_zero: Annotated[int, Field(default=0)]
default_str: Annotated[str, Field(default="foo")]
true_if_unset: Annotated[bool | None, Field(default=None)]
class AnnotatedFormModel(Parent):
default_true: Annotated[bool, Form(default=True)]
default_false: Annotated[bool, Form(default=False)]
default_none: Annotated[bool | None, Form(default=None)]
default_zero: Annotated[int, Form(default=0)]
default_str: Annotated[str, Form(default="foo")]
true_if_unset: Annotated[bool | None, Form(default=None)]
class SimpleForm(BaseModel):
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
foo: Annotated[str, Form(default="bar")]
alias_with: Annotated[str, Form(alias="with", default="nothing")]
class ResponseModel(BaseModel):
fields_set: list = Field(default_factory=list)
dumped_fields_no_exclude: dict = Field(default_factory=dict)
dumped_fields_exclude_default: dict = Field(default_factory=dict)
dumped_fields_exclude_unset: dict = Field(default_factory=dict)
dumped_fields_no_meta: dict = Field(default_factory=dict)
init_input: dict
@classmethod
def from_value(cls, value: Parent) -> "ResponseModel":
return ResponseModel(
init_input=value.init_input,
fields_set=list(value.model_fields_set),
dumped_fields_no_exclude=value.model_dump(),
dumped_fields_exclude_default=value.model_dump(exclude_defaults=True),
dumped_fields_exclude_unset=value.model_dump(exclude_unset=True),
dumped_fields_no_meta=value.model_dump(
exclude={"init_input", "fields_set"}
),
)
app = FastAPI()
@app.post("/form/standard")
async def form_standard(value: Annotated[StandardModel, Form()]) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/field")
async def form_field(value: Annotated[FieldModel, Form()]) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/annotated-field")
async def form_annotated_field(
value: Annotated[AnnotatedFieldModel, Form()],
) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/annotated-form")
async def form_annotated_form(
value: Annotated[AnnotatedFormModel, Form()],
) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/form/inlined")
async def form_inlined(
default_true: Annotated[bool, Form()] = True,
default_false: Annotated[bool, Form()] = False,
default_none: Annotated[bool | None, Form()] = None,
default_zero: Annotated[int, Form()] = 0,
default_str: Annotated[str, Form()] = "foo",
true_if_unset: Annotated[bool | None, Form()] = None,
):
"""
Rather than using a model, inline the fields in the endpoint.
This doesn't use the `ResponseModel` pattern, since that is just to
test the instantiation behavior prior to the endpoint function.
Since we are receiving the values of the fields here (and thus,
having the defaults is correct behavior), we just return the values.
"""
if true_if_unset is None:
true_if_unset = True
return {
"default_true": default_true,
"default_false": default_false,
"default_none": default_none,
"default_zero": default_zero,
"default_str": default_str,
"true_if_unset": true_if_unset,
}
@app.post("/json/standard")
async def json_standard(value: StandardModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/json/field")
async def json_field(value: FieldModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/json/annotated-field")
async def json_annotated_field(value: AnnotatedFieldModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/json/annotated-form")
async def json_annotated_form(value: AnnotatedFormModel) -> ResponseModel:
return ResponseModel.from_value(value)
@app.post("/simple-form")
def form_endpoint(model: Annotated[SimpleForm, Form()]) -> dict:
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
return model.model_dump()
MODEL_TYPES = {
"standard": StandardModel,
"field": FieldModel,
"annotated-field": AnnotatedFieldModel,
"annotated-form": AnnotatedFormModel,
}
ENCODINGS = ("form", "json")
@pytest.fixture(scope="module")
def client() -> TestClient:
with TestClient(app) as test_client:
yield test_client
@pytest.mark.parametrize("encoding", ENCODINGS)
@pytest.mark.parametrize("model_type", MODEL_TYPES.keys())
def test_no_prefill_defaults_all_unset(encoding, model_type, client):
"""
When the model is instantiated by the server, it should not have its defaults prefilled
"""
endpoint = f"/{encoding}/{model_type}"
if encoding == "form":
res = client.post(endpoint, data={})
else:
res = client.post(endpoint, json={})
assert res.status_code == 200
response_model = ResponseModel(**res.json())
assert response_model.init_input == {}
assert len(response_model.fields_set) == 2
assert response_model.dumped_fields_no_exclude["true_if_unset"] is True
@pytest.mark.parametrize("encoding", ENCODINGS)
@pytest.mark.parametrize("model_type", MODEL_TYPES.keys())
def test_no_prefill_defaults_partially_set(encoding, model_type, client):
"""
When the model is instantiated by the server, it should not have its defaults prefilled,
and pydantic should be able to differentiate between unset and default values when some are passed
"""
endpoint = f"/{encoding}/{model_type}"
if encoding == "form":
data = {"true_if_unset": "False", "default_false": "True", "default_zero": "0"}
res = client.post(endpoint, data=data)
else:
data = {"true_if_unset": False, "default_false": True, "default_zero": 0}
res = client.post(endpoint, json=data)
dumped_exclude_unset = MODEL_TYPES[model_type](**data).model_dump(
exclude_unset=True
)
dumped_exclude_default = MODEL_TYPES[model_type](**data).model_dump(
exclude_defaults=True
)
assert res.status_code == 200
response_model = ResponseModel(**res.json())
assert response_model.init_input == data
assert len(response_model.fields_set) == 4
assert response_model.dumped_fields_exclude_unset == dumped_exclude_unset
assert response_model.dumped_fields_no_exclude["true_if_unset"] is False
assert "default_zero" not in dumped_exclude_default
assert "default_zero" not in response_model.dumped_fields_exclude_default
def test_casted_empty_defaults(client: TestClient):
"""https://github.com/fastapi/fastapi/pull/13464#issuecomment-2708378172"""
form_content = {"foo": "", "with": ""}
response = client.post("/simple-form", data=form_content)
response_content = response.json()
assert response_content["foo"] == "bar" # Expected :'bar' -> Actual :''
assert response_content["alias_with"] == "nothing" # ok
@pytest.mark.parametrize("model_type", list(MODEL_TYPES.keys()) + ["inlined"])
def test_empty_string_inputs(model_type, client):
"""
Form inputs with no input are empty strings,
these should be treated as being unset.
"""
data = {
"default_true": "",
"default_false": "",
"default_none": "",
"default_str": "",
"default_zero": "",
"true_if_unset": "",
}
response = client.post(f"/form/{model_type}", data=data)
assert response.status_code == 200
if model_type != "inlined":
response_model = ResponseModel(**response.json())
assert set(response_model.fields_set) == {"true_if_unset", "init_input"}
response_data = response_model.dumped_fields_no_meta
else:
response_data = response.json()
assert response_data == {
"default_true": True,
"default_false": False,
"default_none": None,
"default_zero": 0,
"default_str": "foo",
"true_if_unset": True,
}

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": {},
},
]
}

44
tests/test_tutorial/test_request_form_models/test_tutorial003.py

@ -0,0 +1,44 @@
import importlib
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(
name="client",
params=[
"tutorial003_py310",
"tutorial003_an_py310",
],
)
def get_client(request: pytest.FixtureRequest):
mod = importlib.import_module(f"docs_src.request_form_models.{request.param}")
client = TestClient(mod.app)
return client
def test_get_form(client: TestClient):
"""The form has a checkbox that is by default checked"""
response = client.get("/form")
response.raise_for_status()
assert '<input type="checkbox" name="checkbox"' in response.text
assert 'checked="checked"' in response.text
def test_post_form_checked(client: TestClient):
"""When the checkbox is checked, the value is (correctly) True"""
response = client.post("/form", data={"checkbox": "on"})
response.raise_for_status()
assert response.json() == {"checkbox": True}
@pytest.mark.parametrize("data", [{}, {"checkbox": ""}])
def test_post_form_unchecked(client: TestClient, data: dict):
"""
When the checkbox is not checked,
the value is (maybe correctly but undesirably) still True
"""
response = client.post("/form", data=data)
response.raise_for_status()
assert response.json() == {"checkbox": True}

45
tests/test_tutorial/test_request_form_models/test_tutorial004.py

@ -0,0 +1,45 @@
import importlib
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(
name="client",
params=[
"tutorial004_py310",
"tutorial004_an_py310",
],
)
def get_client(request: pytest.FixtureRequest):
mod = importlib.import_module(f"docs_src.request_form_models.{request.param}")
client = TestClient(mod.app)
return client
def test_get_form(client: TestClient):
"""The form has a checkbox that is by default checked"""
response = client.get("/form")
response.raise_for_status()
assert '<input type="checkbox" name="checkbox"' in response.text
assert 'checked="checked"' in response.text
def test_post_form_checked(client: TestClient):
"""When the checkbox is checked, the value is (correctly) True"""
response = client.post("/form", data={"checkbox": "on"})
response.raise_for_status()
assert response.json() == {"checkbox": True}
@pytest.mark.parametrize("data", [{}, {"checkbox": ""}])
def test_post_form_unchecked(client: TestClient, data: dict):
"""
When the checkbox is not checked,
now that we have a model validator that can differentiate defaults vs. undefined,
the checkbox is correctly false.
"""
response = client.post("/form", data=data)
response.raise_for_status()
assert response.json() == {"checkbox": False}
Loading…
Cancel
Save