Skip to content

Commit

Permalink
✨ Add support for Pydantic models in Form parameters (fastapi#12129)
Browse files Browse the repository at this point in the history
Revert "⏪️ Temporarily revert "✨ Add support for Pydantic models in `Form` pa…"

This reverts commit 8e6cf9e.
  • Loading branch information
tiangolo authored Sep 5, 2024
1 parent 965fc83 commit 7bad7c0
Show file tree
Hide file tree
Showing 13 changed files with 994 additions and 3 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 65 additions & 0 deletions docs/en/docs/tutorial/request-form-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Form Models

You can use Pydantic models to declare form fields in FastAPI.

/// info

To use forms, first install <a href="https://github.com/Kludex/python-multipart" class="external-link" target="_blank">`python-multipart`</a>.

Make sure you create a [virtual environment](../virtual-environments.md){.internal-link target=_blank}, activate it, and then install it, for example:

```console
$ pip install python-multipart
```

///

/// note

This is supported since FastAPI version `0.113.0`. 🤓

///

## Pydantic Models for Forms

You just need to declare a Pydantic model with the fields you want to receive as form fields, and then declare the parameter as `Form`:

//// tab | Python 3.9+

```Python hl_lines="9-11 15"
{!> ../../../docs_src/request_form_models/tutorial001_an_py39.py!}
```

////

//// tab | Python 3.8+

```Python hl_lines="8-10 14"
{!> ../../../docs_src/request_form_models/tutorial001_an.py!}
```

////

//// tab | Python 3.8+ non-Annotated

/// tip

Prefer to use the `Annotated` version if possible.

///

```Python hl_lines="7-9 13"
{!> ../../../docs_src/request_form_models/tutorial001.py!}
```

////

FastAPI will extract the data for each field from the form data in the request and give you the Pydantic model you defined.

## Check the Docs

You can verify it in the docs UI at `/docs`:

<div class="screenshot">
<img src="/img/tutorial/request-form-models/image01.png">
</div>
1 change: 1 addition & 0 deletions docs/en/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ nav:
- tutorial/extra-models.md
- tutorial/response-status-code.md
- tutorial/request-forms.md
- tutorial/request-form-models.md
- tutorial/request-files.md
- tutorial/request-forms-and-files.md
- tutorial/handling-errors.md
Expand Down
14 changes: 14 additions & 0 deletions docs_src/request_form_models/tutorial001.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from fastapi import FastAPI, Form
from pydantic import BaseModel

app = FastAPI()


class FormData(BaseModel):
username: str
password: str


@app.post("/login/")
async def login(data: FormData = Form()):
return data
15 changes: 15 additions & 0 deletions docs_src/request_form_models/tutorial001_an.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from fastapi import FastAPI, Form
from pydantic import BaseModel
from typing_extensions import Annotated

app = FastAPI()


class FormData(BaseModel):
username: str
password: str


@app.post("/login/")
async def login(data: Annotated[FormData, Form()]):
return data
16 changes: 16 additions & 0 deletions docs_src/request_form_models/tutorial001_an_py39.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from typing import Annotated

from fastapi import FastAPI, Form
from pydantic import BaseModel

app = FastAPI()


class FormData(BaseModel):
username: str
password: str


@app.post("/login/")
async def login(data: Annotated[FormData, Form()]):
return data
17 changes: 14 additions & 3 deletions fastapi/dependencies/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
field_annotation_is_scalar,
get_annotation_from_field_info,
get_missing_field_error,
get_model_fields,
is_bytes_field,
is_bytes_sequence_field,
is_scalar_field,
Expand All @@ -56,6 +57,7 @@
from fastapi.security.oauth2 import OAuth2, SecurityScopes
from fastapi.security.open_id_connect_url import OpenIdConnect
from fastapi.utils import create_model_field, get_path_param_names
from pydantic import BaseModel
from pydantic.fields import FieldInfo
from starlette.background import BackgroundTasks as StarletteBackgroundTasks
from starlette.concurrency import run_in_threadpool
Expand Down Expand Up @@ -743,7 +745,9 @@ def _should_embed_body_fields(fields: List[ModelField]) -> bool:
return True
# If it's a Form (or File) field, it has to be a BaseModel to be top level
# otherwise it has to be embedded, so that the key value pair can be extracted
if isinstance(first_field.field_info, params.Form):
if isinstance(first_field.field_info, params.Form) and not lenient_issubclass(
first_field.type_, BaseModel
):
return True
return False

Expand Down Expand Up @@ -783,7 +787,8 @@ async def process_fn(
for sub_value in value:
tg.start_soon(process_fn, sub_value.read)
value = serialize_sequence_value(field=field, value=results)
values[field.name] = value
if value is not None:
values[field.name] = value
return values


Expand All @@ -798,8 +803,14 @@ async def request_body_to_args(
single_not_embedded_field = len(body_fields) == 1 and not embed_body_fields
first_field = body_fields[0]
body_to_process = received_body

fields_to_extract: List[ModelField] = body_fields

if single_not_embedded_field and lenient_issubclass(first_field.type_, BaseModel):
fields_to_extract = get_model_fields(first_field.type_)

if isinstance(received_body, FormData):
body_to_process = await _extract_form_body(body_fields, received_body)
body_to_process = await _extract_form_body(fields_to_extract, received_body)

if single_not_embedded_field:
loc: Tuple[str, ...] = ("body",)
Expand Down
36 changes: 36 additions & 0 deletions scripts/playwright/request_form_models/image01.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import subprocess
import time

import httpx
from playwright.sync_api import Playwright, sync_playwright


# Run playwright codegen to generate the code below, copy paste the sections in run()
def run(playwright: Playwright) -> None:
browser = playwright.chromium.launch(headless=False)
context = browser.new_context()
page = context.new_page()
page.goto("http://localhost:8000/docs")
page.get_by_role("button", name="POST /login/ Login").click()
page.get_by_role("button", name="Try it out").click()
page.screenshot(path="docs/en/docs/img/tutorial/request-form-models/image01.png")

# ---------------------
context.close()
browser.close()


process = subprocess.Popen(
["fastapi", "run", "docs_src/request_form_models/tutorial001.py"]
)
try:
for _ in range(3):
try:
response = httpx.get("http://localhost:8000/docs")
except httpx.ConnectError:
time.sleep(1)
break
with sync_playwright() as playwright:
run(playwright)
finally:
process.terminate()
129 changes: 129 additions & 0 deletions tests/test_forms_single_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
from typing import List, Optional

from dirty_equals import IsDict
from fastapi import FastAPI, Form
from fastapi.testclient import TestClient
from pydantic import BaseModel
from typing_extensions import Annotated

app = FastAPI()


class FormModel(BaseModel):
username: str
lastname: str
age: Optional[int] = None
tags: List[str] = ["foo", "bar"]


@app.post("/form/")
def post_form(user: Annotated[FormModel, Form()]):
return user


client = TestClient(app)


def test_send_all_data():
response = client.post(
"/form/",
data={
"username": "Rick",
"lastname": "Sanchez",
"age": "70",
"tags": ["plumbus", "citadel"],
},
)
assert response.status_code == 200, response.text
assert response.json() == {
"username": "Rick",
"lastname": "Sanchez",
"age": 70,
"tags": ["plumbus", "citadel"],
}


def test_defaults():
response = client.post("/form/", data={"username": "Rick", "lastname": "Sanchez"})
assert response.status_code == 200, response.text
assert response.json() == {
"username": "Rick",
"lastname": "Sanchez",
"age": None,
"tags": ["foo", "bar"],
}


def test_invalid_data():
response = client.post(
"/form/",
data={
"username": "Rick",
"lastname": "Sanchez",
"age": "seventy",
"tags": ["plumbus", "citadel"],
},
)
assert response.status_code == 422, response.text
assert response.json() == IsDict(
{
"detail": [
{
"type": "int_parsing",
"loc": ["body", "age"],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "seventy",
}
]
}
) | IsDict(
# TODO: remove when deprecating Pydantic v1
{
"detail": [
{
"loc": ["body", "age"],
"msg": "value is not a valid integer",
"type": "type_error.integer",
}
]
}
)


def test_no_data():
response = client.post("/form/")
assert response.status_code == 422, response.text
assert response.json() == IsDict(
{
"detail": [
{
"type": "missing",
"loc": ["body", "username"],
"msg": "Field required",
"input": {"tags": ["foo", "bar"]},
},
{
"type": "missing",
"loc": ["body", "lastname"],
"msg": "Field required",
"input": {"tags": ["foo", "bar"]},
},
]
}
) | IsDict(
# TODO: remove when deprecating Pydantic v1
{
"detail": [
{
"loc": ["body", "username"],
"msg": "field required",
"type": "value_error.missing",
},
{
"loc": ["body", "lastname"],
"msg": "field required",
"type": "value_error.missing",
},
]
}
)
Empty file.
Loading

0 comments on commit 7bad7c0

Please sign in to comment.