diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md index 79046a3f6..00ab1c341 100644 --- a/docs/en/docs/tutorial/request-form-models.md +++ b/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. 😎 diff --git a/docs_src/request_form_models/tutorial003.py b/docs_src/request_form_models/tutorial003.py new file mode 100644 index 000000000..5c2b3dd31 --- /dev/null +++ b/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 = """ +
+""" +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 diff --git a/docs_src/request_form_models/tutorial003_an_py39.py b/docs_src/request_form_models/tutorial003_an_py39.py new file mode 100644 index 000000000..f8e9668b4 --- /dev/null +++ b/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 = """ + +""" +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 diff --git a/docs_src/request_form_models/tutorial004.py b/docs_src/request_form_models/tutorial004.py new file mode 100644 index 000000000..26582986b --- /dev/null +++ b/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 = """ + +""" +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 diff --git a/docs_src/request_form_models/tutorial004_an_py39.py b/docs_src/request_form_models/tutorial004_an_py39.py new file mode 100644 index 000000000..2529576b8 --- /dev/null +++ b/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 = """ + +""" +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 diff --git a/docs_src/request_form_models/tutorial004_pv1.py b/docs_src/request_form_models/tutorial004_pv1.py new file mode 100644 index 000000000..b0b9de62e --- /dev/null +++ b/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 = """ + +""" +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 diff --git a/docs_src/request_form_models/tutorial004_pv1_an_py39.py b/docs_src/request_form_models/tutorial004_pv1_an_py39.py new file mode 100644 index 000000000..ac80f53dd --- /dev/null +++ b/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 = """ + +""" +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 diff --git a/fastapi/_compat.py b/fastapi/_compat.py index c07e4a3b0..a6ec1764d 100644 --- a/fastapi/_compat.py +++ b/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: