diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md index 79046a3f6..277c53ce8 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[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. 😎 diff --git a/docs_src/request_form_models/tutorial003.py b/docs_src/request_form_models/tutorial003.py new file mode 100644 index 000000000..b0cbd6ff3 --- /dev/null +++ b/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 = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +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..2c7f6a4b7 --- /dev/null +++ b/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 = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +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..1dfc6f80d --- /dev/null +++ b/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 = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +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..d1d1fb64f --- /dev/null +++ b/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 = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +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..0340d7d7d --- /dev/null +++ b/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 = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +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..d0b45003f --- /dev/null +++ b/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 = """ +
+{% for field_name, field in model.model_fields.items() %} +

+ + {% if field.annotation.__name__ == "bool" %} + + {% else %} + + {% endif %} +

+{% endfor %} + +
+""" +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 227ad837d..f214a32bd 100644 --- a/fastapi/_compat.py +++ b/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: diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 081b63a8b..b0ecd1cd1 100644 --- a/fastapi/dependencies/utils.py +++ b/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 diff --git a/tests/test_forms_defaults.py b/tests/test_forms_defaults.py new file mode 100644 index 000000000..8a4dfc89b --- /dev/null +++ b/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, + } diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py index 880ab3820..c57ee973a 100644 --- a/tests/test_forms_single_model.py +++ b/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": {}, }, ] }