Browse Source

docs for handling default values, pass field to validation context

pull/13464/head
sneakers-the-rat 3 months ago
parent
commit
f63e983b60
No known key found for this signature in database GPG Key ID: 6DCB96EF1E4D232D
  1. 62
      docs/en/docs/tutorial/request-form-models.md
  2. 42
      docs_src/request_form_models/tutorial003.py
  3. 44
      docs_src/request_form_models/tutorial003_an_py39.py
  4. 57
      docs_src/request_form_models/tutorial004.py
  5. 59
      docs_src/request_form_models/tutorial004_an_py39.py
  6. 51
      docs_src/request_form_models/tutorial004_pv1.py
  7. 53
      docs_src/request_form_models/tutorial004_pv1_an_py39.py
  8. 2
      fastapi/_compat.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[10,18:22] *}
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[3,12:24] *}
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. 😎

42
docs_src/request_form_models/tutorial003.py

@ -0,0 +1,42 @@
from pydantic import BaseModel
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
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

44
docs_src/request_form_models/tutorial003_an_py39.py

@ -0,0 +1,44 @@
from typing import Annotated
from pydantic import BaseModel
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
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

57
docs_src/request_form_models/tutorial004.py

@ -0,0 +1,57 @@
from pydantic import BaseModel, ValidationInfo, model_validator
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
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

59
docs_src/request_form_models/tutorial004_an_py39.py

@ -0,0 +1,59 @@
from typing import Annotated
from pydantic import BaseModel, ValidationInfo, model_validator
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
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

51
docs_src/request_form_models/tutorial004_pv1.py

@ -0,0 +1,51 @@
from pydantic import BaseModel, model_validator
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
class MyModel(BaseModel):
checkbox: bool = True
@model_validator(mode="before")
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

53
docs_src/request_form_models/tutorial004_pv1_an_py39.py

@ -0,0 +1,53 @@
from typing import Annotated
from pydantic import BaseModel, model_validator
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from jinja2 import DictLoader, Environment
class MyModel(BaseModel):
checkbox: bool = True
@model_validator(mode="before")
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

2
fastapi/_compat.py

@ -126,7 +126,7 @@ 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:

Loading…
Cancel
Save