committed by
GitHub
10 changed files with 649 additions and 7 deletions
@ -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,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,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, 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,286 @@ |
|||
from typing import Annotated |
|||
|
|||
import pytest |
|||
from fastapi import FastAPI, Form |
|||
from pydantic import BaseModel, Field, model_validator |
|||
from starlette.testclient import TestClient |
|||
|
|||
|
|||
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 |
|||
|
|||
@model_validator(mode="before") |
|||
def validate_inputs(cls, value: dict) -> dict: |
|||
return _validate_input(value) |
|||
|
|||
|
|||
class StandardModel(Parent): |
|||
default_true: bool = True |
|||
default_false: bool = False |
|||
default_none: bool | None = None |
|||
default_zero: int = 0 |
|||
default_str: str = "foo" |
|||
true_if_unset: bool | None = None |
|||
|
|||
|
|||
class FieldModel(Parent): |
|||
default_true: bool = Field(default=True) |
|||
default_false: bool = Field(default=False) |
|||
default_none: bool | None = Field(default=None) |
|||
default_zero: int = Field(default=0) |
|||
default_str: str = Field(default="foo") |
|||
true_if_unset: bool | None = Field(default=None) |
|||
|
|||
|
|||
class AnnotatedFieldModel(Parent): |
|||
default_true: Annotated[bool, Field(default=True)] |
|||
default_false: Annotated[bool, Field(default=False)] |
|||
default_none: Annotated[bool | None, Field(default=None)] |
|||
default_zero: Annotated[int, Field(default=0)] |
|||
default_str: Annotated[str, Field(default="foo")] |
|||
true_if_unset: Annotated[bool | None, Field(default=None)] |
|||
|
|||
|
|||
class AnnotatedFormModel(Parent): |
|||
default_true: Annotated[bool, Form(default=True)] |
|||
default_false: Annotated[bool, Form(default=False)] |
|||
default_none: Annotated[bool | None, Form(default=None)] |
|||
default_zero: Annotated[int, Form(default=0)] |
|||
default_str: Annotated[str, Form(default="foo")] |
|||
true_if_unset: Annotated[bool | None, 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": |
|||
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"} |
|||
), |
|||
) |
|||
|
|||
|
|||
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) |
|||
|
|||
|
|||
@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[bool | None, Form()] = None, |
|||
default_zero: Annotated[int, Form()] = 0, |
|||
default_str: Annotated[str, Form()] = "foo", |
|||
true_if_unset: Annotated[bool | None, 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) |
|||
|
|||
|
|||
@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() |
|||
|
|||
|
|||
MODEL_TYPES = { |
|||
"standard": StandardModel, |
|||
"field": FieldModel, |
|||
"annotated-field": AnnotatedFieldModel, |
|||
"annotated-form": AnnotatedFormModel, |
|||
} |
|||
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) |
|||
|
|||
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 |
|||
) |
|||
|
|||
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 |
|||
|
|||
|
|||
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, |
|||
} |
|||
@ -0,0 +1,44 @@ |
|||
import importlib |
|||
|
|||
import pytest |
|||
from fastapi.testclient import TestClient |
|||
|
|||
|
|||
@pytest.fixture( |
|||
name="client", |
|||
params=[ |
|||
"tutorial003_py310", |
|||
"tutorial003_an_py310", |
|||
], |
|||
) |
|||
def get_client(request: pytest.FixtureRequest): |
|||
mod = importlib.import_module(f"docs_src.request_form_models.{request.param}") |
|||
|
|||
client = TestClient(mod.app) |
|||
return client |
|||
|
|||
|
|||
def test_get_form(client: TestClient): |
|||
"""The form has a checkbox that is by default checked""" |
|||
response = client.get("/form") |
|||
response.raise_for_status() |
|||
assert '<input type="checkbox" name="checkbox"' in response.text |
|||
assert 'checked="checked"' in response.text |
|||
|
|||
|
|||
def test_post_form_checked(client: TestClient): |
|||
"""When the checkbox is checked, the value is (correctly) True""" |
|||
response = client.post("/form", data={"checkbox": "on"}) |
|||
response.raise_for_status() |
|||
assert response.json() == {"checkbox": True} |
|||
|
|||
|
|||
@pytest.mark.parametrize("data", [{}, {"checkbox": ""}]) |
|||
def test_post_form_unchecked(client: TestClient, data: dict): |
|||
""" |
|||
When the checkbox is not checked, |
|||
the value is (maybe correctly but undesirably) still True |
|||
""" |
|||
response = client.post("/form", data=data) |
|||
response.raise_for_status() |
|||
assert response.json() == {"checkbox": True} |
|||
@ -0,0 +1,45 @@ |
|||
import importlib |
|||
|
|||
import pytest |
|||
from fastapi.testclient import TestClient |
|||
|
|||
|
|||
@pytest.fixture( |
|||
name="client", |
|||
params=[ |
|||
"tutorial004_py310", |
|||
"tutorial004_an_py310", |
|||
], |
|||
) |
|||
def get_client(request: pytest.FixtureRequest): |
|||
mod = importlib.import_module(f"docs_src.request_form_models.{request.param}") |
|||
|
|||
client = TestClient(mod.app) |
|||
return client |
|||
|
|||
|
|||
def test_get_form(client: TestClient): |
|||
"""The form has a checkbox that is by default checked""" |
|||
response = client.get("/form") |
|||
response.raise_for_status() |
|||
assert '<input type="checkbox" name="checkbox"' in response.text |
|||
assert 'checked="checked"' in response.text |
|||
|
|||
|
|||
def test_post_form_checked(client: TestClient): |
|||
"""When the checkbox is checked, the value is (correctly) True""" |
|||
response = client.post("/form", data={"checkbox": "on"}) |
|||
response.raise_for_status() |
|||
assert response.json() == {"checkbox": True} |
|||
|
|||
|
|||
@pytest.mark.parametrize("data", [{}, {"checkbox": ""}]) |
|||
def test_post_form_unchecked(client: TestClient, data: dict): |
|||
""" |
|||
When the checkbox is not checked, |
|||
now that we have a model validator that can differentiate defaults vs. undefined, |
|||
the checkbox is correctly false. |
|||
""" |
|||
response = client.post("/form", data=data) |
|||
response.raise_for_status() |
|||
assert response.json() == {"checkbox": False} |
|||
Loading…
Reference in new issue