mirror of https://github.com/tiangolo/fastapi.git
Squashed commit of the following:
commit1b8d0d73f8Author: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Wed Aug 20 03:24:25 2025 -0700 ok but seriously commitd3ccab4948Author: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Tue Aug 19 23:38:25 2025 -0700 rm being able to determine the input format of a model commitfec0a068edMerge:3f2e0f57cad08bbcAuthor: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Mon Apr 14 20:03:14 2025 -0700 Merge branch 'form-defaults' of https://github.com/sneakers-the-rat/fastapi into form-defaults commit3f2e0f572fAuthor: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Mon Apr 14 20:01:50 2025 -0700 lint commitcad08bbc4dAuthor: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue Apr 15 02:47:42 2025 +0000 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks commitf63e983b60Author: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Mon Apr 14 19:46:34 2025 -0700 docs for handling default values, pass field to validation context commit529d486a7bMerge:a9acab81159824eaAuthor: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Sat Mar 8 17:45:40 2025 -0800 Merge branch 'form-defaults' of https://github.com/sneakers-the-rat/fastapi into form-defaults commita9acab81c4Author: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Sat Mar 8 17:42:38 2025 -0800 lint commit159824ea93Author: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun Mar 9 01:42:31 2025 +0000 🎨 [pre-commit.ci] Auto format from pre-commit.com hooks commite76184380dAuthor: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Sat Mar 8 17:42:21 2025 -0800 pydantic v1 compat commit64f25284e8Author: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Sat Mar 8 17:38:03 2025 -0800 fix handling form data with fields that are not annotated as Form() commit7fade13ac8Author: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Sat Mar 8 17:37:32 2025 -0800 fix just the extra values problem (again, purposefully with failing tests to demonstrate the problem, fixing in next commit) commit49f6b8397dAuthor: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Sat Mar 8 17:36:03 2025 -0800 add failing tests for empty input values to get a CI run baseline for them commit15eb6782dcAuthor: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Thu Mar 6 19:35:53 2025 -0800 mypy lint commit1a58af44dfAuthor: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Thu Mar 6 19:31:28 2025 -0800 finish pydantic 1 compat commita2ad8b187fAuthor: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Thu Mar 6 19:22:11 2025 -0800 python 3.8 and pydantic 1 compat commit76c4d317fdAuthor: sneakers-the-rat <sneakers-the-rat@protonmail.com> Date: Thu Mar 6 19:06:44 2025 -0800 don't prefill defaults in form input
This commit is contained in:
parent
a372edf7e8
commit
978351dbe6
|
|
@ -73,6 +73,60 @@ 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.
|
||||
|
||||
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_py39.py hl[7,13:25] *}
|
||||
|
||||
## Summary { #summary }
|
||||
|
||||
You can use Pydantic models to declare form fields in FastAPI. 😎
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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, root_validator
|
||||
|
||||
|
||||
class MyModel(BaseModel):
|
||||
checkbox: bool = True
|
||||
|
||||
|
||||
class MyModelForm(BaseModel):
|
||||
@root_validator(pre=True)
|
||||
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
|
||||
|
|
@ -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, root_validator
|
||||
|
||||
|
||||
class MyModel(BaseModel):
|
||||
checkbox: bool = True
|
||||
|
||||
|
||||
class MyModelForm(MyModel):
|
||||
@root_validator(pre=True)
|
||||
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: Annotated[MyModelForm, Form()]) -> MyModel:
|
||||
data = cast(MyModel, data)
|
||||
return data
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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…
Reference in New Issue