Jonny Saunders 3 days ago
committed by GitHub
parent
commit
12edb16e56
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 62
      docs/en/docs/tutorial/request-form-models.md
  2. 46
      docs_src/request_form_models/tutorial003.py
  3. 48
      docs_src/request_form_models/tutorial003_an_py39.py
  4. 60
      docs_src/request_form_models/tutorial004.py
  5. 62
      docs_src/request_form_models/tutorial004_an_py39.py
  6. 54
      docs_src/request_form_models/tutorial004_pv1.py
  7. 56
      docs_src/request_form_models/tutorial004_pv1_an_py39.py
  8. 4
      fastapi/_compat.py
  9. 18
      fastapi/dependencies/utils.py
  10. 326
      tests/test_forms_defaults.py
  11. 4
      tests/test_forms_single_model.py

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

@ -73,6 +73,68 @@ They will receive an error response telling them that the field `extra` is not a
}
```
## 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_py39.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.
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
(model reuse is one of the benefits of building FastAPI on top of pydantic, after all!).
To do this, you can use a [`model_validator`](https://docs.pydantic.dev/latest/concepts/validators/#model-validators)
in the `before` mode - before the defaults from the model are applied,
to differentiate between an explicit `False` value and an unset value.
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.*
So we can additionally use the `'fastapi_field'` passed to the
[validation context](https://docs.pydantic.dev/latest/concepts/validators/#validation-context)
to determine whether our model is being validated from form input.
/// note
Validation context is a pydantic v2 only feature!
///
{* ../../docs_src/request_form_models/tutorial004_an_py39.py hl[7,13:25] *}
And with that, our form model should behave as expected when it is used with a form,
JSON input, or elsewhere in the program!
## Summary
You can use Pydantic models to declare form fields in FastAPI. 😎

46
docs_src/request_form_models/tutorial003.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

48
docs_src/request_form_models/tutorial003_an_py39.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

60
docs_src/request_form_models/tutorial004.py

@ -0,0 +1,60 @@
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
@model_validator(mode="before")
def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict:
# if this model is being used outside of fastapi, return normally
if info.context is None or "fastapi_field" not in info.context:
return value
# check if we are being validated from form input,
# and if so, treat the unset checkbox as False
field_info = info.context["fastapi_field"].field_info
is_form = type(field_info).__name__ == "Form"
if is_form and "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: MyModel = Form()) -> MyModel:
return data

62
docs_src/request_form_models/tutorial004_an_py39.py

@ -0,0 +1,62 @@
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, ValidationInfo, model_validator
class MyModel(BaseModel):
checkbox: bool = True
@model_validator(mode="before")
def handle_defaults(cls, value: dict, info: ValidationInfo) -> dict:
# if this model is being used outside of fastapi, return normally
if info.context is None or "fastapi_field" not in info.context:
return value
# check if we are being validated from form input,
# and if so, treat the unset checkbox as False
field_info = info.context["fastapi_field"].field_info
is_form = type(field_info).__name__ == "Form"
if is_form and "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[MyModel, Form()]) -> MyModel:
return data

54
docs_src/request_form_models/tutorial004_pv1.py

@ -0,0 +1,54 @@
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, root_validator
class MyModel(BaseModel):
checkbox: bool = True
@root_validator(pre=True)
def handle_defaults(cls, value: dict) -> dict:
# We can't tell if we're being validated by fastAPI,
# so we have to just YOLO this.
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: MyModel = Form()) -> MyModel:
return data

56
docs_src/request_form_models/tutorial004_pv1_an_py39.py

@ -0,0 +1,56 @@
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, root_validator
class MyModel(BaseModel):
checkbox: bool = True
@root_validator(pre=True)
def handle_defaults(cls, value: dict) -> dict:
# We can't tell if we're being validated by fastAPI,
# so we have to just YOLO this.
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[MyModel, Form()]) -> MyModel:
return data

4
fastapi/_compat.py

@ -127,7 +127,9 @@ if PYDANTIC_V2:
) -> Tuple[Any, Union[List[Dict[str, Any]], None]]:
try:
return (
self._type_adapter.validate_python(value, from_attributes=True),
self._type_adapter.validate_python(
value, from_attributes=True, context={"fastapi_field": self}
),
None,
)
except ValidationError as exc:

18
fastapi/dependencies/utils.py

@ -714,7 +714,10 @@ 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 field.alias
if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):
@ -724,14 +727,14 @@ 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 == ""
)
or (is_sequence_field(field) and len(value) == 0)
):
if field.required:
return
if form_input or field.required:
return None
else:
return deepcopy(field.default)
return value
@ -864,11 +867,12 @@ async def _extract_form_body(
received_body: FormData,
) -> Dict[str, Any]:
values = {}
field_aliases = {field.alias for field in body_fields}
first_field = body_fields[0]
first_field_info = first_field.field_info
for field in body_fields:
value = _get_multidict_value(field, received_body)
value = _get_multidict_value(field, received_body, form_input=True)
if (
isinstance(first_field_info, params.File)
and is_bytes_field(field)
@ -896,8 +900,10 @@ async def _extract_form_body(
value = serialize_sequence_value(field=field, value=results)
if value is not None:
values[field.alias] = value
# preserve extra keys not in model body fields for validation
for key, value in received_body.items():
if key not in values:
if key not in field_aliases:
values[key] = value
return values

326
tests/test_forms_defaults.py

@ -0,0 +1,326 @@
from typing import Optional
import pytest
from fastapi import FastAPI, Form
from fastapi._compat import PYDANTIC_V2
from pydantic import BaseModel, Field
from starlette.testclient import TestClient
from typing_extensions import Annotated
from .utils import needs_pydanticv2
if PYDANTIC_V2:
from pydantic import model_validator
else:
from pydantic import root_validator
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
if PYDANTIC_V2:
@model_validator(mode="before")
def validate_inputs(cls, value: dict) -> dict:
return _validate_input(value)
else:
@root_validator(pre=True)
def validate_inputs(cls, value: dict) -> dict:
return _validate_input(value)
class StandardModel(Parent):
default_true: bool = True
default_false: bool = False
default_none: Optional[bool] = None
default_zero: int = 0
default_str: str = "foo"
true_if_unset: Optional[bool] = None
class FieldModel(Parent):
default_true: bool = Field(default=True)
default_false: bool = Field(default=False)
default_none: Optional[bool] = Field(default=None)
default_zero: int = Field(default=0)
default_str: str = Field(default="foo")
true_if_unset: Optional[bool] = Field(default=None)
if PYDANTIC_V2:
class AnnotatedFieldModel(Parent):
default_true: Annotated[bool, Field(default=True)]
default_false: Annotated[bool, Field(default=False)]
default_none: Annotated[Optional[bool], Field(default=None)]
default_zero: Annotated[int, Field(default=0)]
default_str: Annotated[str, Field(default="foo")]
true_if_unset: Annotated[Optional[bool], Field(default=None)]
class AnnotatedFormModel(Parent):
default_true: Annotated[bool, Form(default=True)]
default_false: Annotated[bool, Form(default=False)]
default_none: Annotated[Optional[bool], Form(default=None)]
default_zero: Annotated[int, Form(default=0)]
default_str: Annotated[str, Form(default="foo")]
true_if_unset: Annotated[Optional[bool], 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":
if PYDANTIC_V2:
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"}
),
)
else:
return ResponseModel(
init_input=value.init_input,
fields_set=list(value.__fields_set__),
dumped_fields_no_exclude=value.dict(),
dumped_fields_exclude_default=value.dict(exclude_defaults=True),
dumped_fields_exclude_unset=value.dict(exclude_unset=True),
dumped_fields_no_meta=value.dict(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)
if PYDANTIC_V2:
@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[Optional[bool], Form()] = None,
default_zero: Annotated[int, Form()] = 0,
default_str: Annotated[str, Form()] = "foo",
true_if_unset: Annotated[Optional[bool], 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)
if PYDANTIC_V2:
@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()
if PYDANTIC_V2:
MODEL_TYPES = {
"standard": StandardModel,
"field": FieldModel,
"annotated-field": AnnotatedFieldModel,
"annotated-form": AnnotatedFormModel,
}
else:
MODEL_TYPES = {
"standard": StandardModel,
"field": FieldModel,
}
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)
if PYDANTIC_V2:
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
)
else:
dumped_exclude_unset = MODEL_TYPES[model_type](**data).dict(exclude_unset=True)
dumped_exclude_default = MODEL_TYPES[model_type](**data).dict(
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
@needs_pydanticv2
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

@ -104,13 +104,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