]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
✨ Add support for Pydantic models in `Form` parameters (#12129)
authorSebastián Ramírez <tiangolo@gmail.com>
Thu, 5 Sep 2024 15:16:50 +0000 (17:16 +0200)
committerGitHub <noreply@github.com>
Thu, 5 Sep 2024 15:16:50 +0000 (17:16 +0200)
Revert "⏪️ Temporarily revert "✨ Add support for Pydantic models in `Form` pa…"

This reverts commit 8e6cf9ee9c9d87b6b658cc240146121c80f71476.

13 files changed:
docs/en/docs/img/tutorial/request-form-models/image01.png [new file with mode: 0644]
docs/en/docs/tutorial/request-form-models.md [new file with mode: 0644]
docs/en/mkdocs.yml
docs_src/request_form_models/tutorial001.py [new file with mode: 0644]
docs_src/request_form_models/tutorial001_an.py [new file with mode: 0644]
docs_src/request_form_models/tutorial001_an_py39.py [new file with mode: 0644]
fastapi/dependencies/utils.py
scripts/playwright/request_form_models/image01.py [new file with mode: 0644]
tests/test_forms_single_model.py [new file with mode: 0644]
tests/test_tutorial/test_request_form_models/__init__.py [new file with mode: 0644]
tests/test_tutorial/test_request_form_models/test_tutorial001.py [new file with mode: 0644]
tests/test_tutorial/test_request_form_models/test_tutorial001_an.py [new file with mode: 0644]
tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py [new file with mode: 0644]

diff --git a/docs/en/docs/img/tutorial/request-form-models/image01.png b/docs/en/docs/img/tutorial/request-form-models/image01.png
new file mode 100644 (file)
index 0000000..3fe32c0
Binary files /dev/null and b/docs/en/docs/img/tutorial/request-form-models/image01.png differ
diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md
new file mode 100644 (file)
index 0000000..8bb1ffb
--- /dev/null
@@ -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>
index 528c80b8e6b31b7d486dfd2c5b00522c2290d3ca..7c810c2d7c212ff6d3826b0de2408d82a9134749 100644 (file)
@@ -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
diff --git a/docs_src/request_form_models/tutorial001.py b/docs_src/request_form_models/tutorial001.py
new file mode 100644 (file)
index 0000000..98feff0
--- /dev/null
@@ -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
diff --git a/docs_src/request_form_models/tutorial001_an.py b/docs_src/request_form_models/tutorial001_an.py
new file mode 100644 (file)
index 0000000..30483d4
--- /dev/null
@@ -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
diff --git a/docs_src/request_form_models/tutorial001_an_py39.py b/docs_src/request_form_models/tutorial001_an_py39.py
new file mode 100644 (file)
index 0000000..7cc81aa
--- /dev/null
@@ -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
index 7ac18d941cd8524425cd266f2536d108c1fc02d9..98ce17b55d9bab800b2c7163d6533b96576da4a1 100644 (file)
@@ -33,6 +33,7 @@ from fastapi._compat import (
     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,
@@ -56,6 +57,7 @@ from fastapi.security.base import SecurityBase
 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
@@ -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
 
@@ -783,7 +787,8 @@ async def _extract_form_body(
                 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
 
 
@@ -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",)
diff --git a/scripts/playwright/request_form_models/image01.py b/scripts/playwright/request_form_models/image01.py
new file mode 100644 (file)
index 0000000..15bd385
--- /dev/null
@@ -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()
diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py
new file mode 100644 (file)
index 0000000..7ed3ba3
--- /dev/null
@@ -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",
+                },
+            ]
+        }
+    )
diff --git a/tests/test_tutorial/test_request_form_models/__init__.py b/tests/test_tutorial/test_request_form_models/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001.py b/tests/test_tutorial/test_request_form_models/test_tutorial001.py
new file mode 100644 (file)
index 0000000..46c130e
--- /dev/null
@@ -0,0 +1,232 @@
+import pytest
+from dirty_equals import IsDict
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.request_form_models.tutorial001 import app
+
+    client = TestClient(app)
+    return client
+
+
+def test_post_body_form(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo", "password": "secret"})
+    assert response.status_code == 200
+    assert response.json() == {"username": "Foo", "password": "secret"}
+
+
+def test_post_body_form_no_password(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo"})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "password"],
+                    "msg": "Field required",
+                    "input": {"username": "Foo"},
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "password"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+def test_post_body_form_no_username(client: TestClient):
+    response = client.post("/login/", data={"password": "secret"})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "username"],
+                    "msg": "Field required",
+                    "input": {"password": "secret"},
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "username"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+def test_post_body_form_no_data(client: TestClient):
+    response = client.post("/login/")
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "username"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+                {
+                    "type": "missing",
+                    "loc": ["body", "password"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "username"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+                {
+                    "loc": ["body", "password"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ]
+        }
+    )
+
+
+def test_post_body_json(client: TestClient):
+    response = client.post("/login/", json={"username": "Foo", "password": "secret"})
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "username"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+                {
+                    "type": "missing",
+                    "loc": ["body", "password"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "username"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+                {
+                    "loc": ["body", "password"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ]
+        }
+    )
+
+
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/login/": {
+                "post": {
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "summary": "Login",
+                    "operationId": "login_login__post",
+                    "requestBody": {
+                        "content": {
+                            "application/x-www-form-urlencoded": {
+                                "schema": {"$ref": "#/components/schemas/FormData"}
+                            }
+                        },
+                        "required": True,
+                    },
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "FormData": {
+                    "properties": {
+                        "username": {"type": "string", "title": "Username"},
+                        "password": {"type": "string", "title": "Password"},
+                    },
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "title": "FormData",
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py b/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py
new file mode 100644 (file)
index 0000000..4e14d89
--- /dev/null
@@ -0,0 +1,232 @@
+import pytest
+from dirty_equals import IsDict
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.request_form_models.tutorial001_an import app
+
+    client = TestClient(app)
+    return client
+
+
+def test_post_body_form(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo", "password": "secret"})
+    assert response.status_code == 200
+    assert response.json() == {"username": "Foo", "password": "secret"}
+
+
+def test_post_body_form_no_password(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo"})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "password"],
+                    "msg": "Field required",
+                    "input": {"username": "Foo"},
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "password"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+def test_post_body_form_no_username(client: TestClient):
+    response = client.post("/login/", data={"password": "secret"})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "username"],
+                    "msg": "Field required",
+                    "input": {"password": "secret"},
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "username"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+def test_post_body_form_no_data(client: TestClient):
+    response = client.post("/login/")
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "username"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+                {
+                    "type": "missing",
+                    "loc": ["body", "password"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "username"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+                {
+                    "loc": ["body", "password"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ]
+        }
+    )
+
+
+def test_post_body_json(client: TestClient):
+    response = client.post("/login/", json={"username": "Foo", "password": "secret"})
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "username"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+                {
+                    "type": "missing",
+                    "loc": ["body", "password"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "username"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+                {
+                    "loc": ["body", "password"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ]
+        }
+    )
+
+
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/login/": {
+                "post": {
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "summary": "Login",
+                    "operationId": "login_login__post",
+                    "requestBody": {
+                        "content": {
+                            "application/x-www-form-urlencoded": {
+                                "schema": {"$ref": "#/components/schemas/FormData"}
+                            }
+                        },
+                        "required": True,
+                    },
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "FormData": {
+                    "properties": {
+                        "username": {"type": "string", "title": "Username"},
+                        "password": {"type": "string", "title": "Password"},
+                    },
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "title": "FormData",
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+            }
+        },
+    }
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py b/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py
new file mode 100644 (file)
index 0000000..2e6426a
--- /dev/null
@@ -0,0 +1,240 @@
+import pytest
+from dirty_equals import IsDict
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_py39
+
+
+@pytest.fixture(name="client")
+def get_client():
+    from docs_src.request_form_models.tutorial001_an_py39 import app
+
+    client = TestClient(app)
+    return client
+
+
+@needs_py39
+def test_post_body_form(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo", "password": "secret"})
+    assert response.status_code == 200
+    assert response.json() == {"username": "Foo", "password": "secret"}
+
+
+@needs_py39
+def test_post_body_form_no_password(client: TestClient):
+    response = client.post("/login/", data={"username": "Foo"})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "password"],
+                    "msg": "Field required",
+                    "input": {"username": "Foo"},
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "password"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@needs_py39
+def test_post_body_form_no_username(client: TestClient):
+    response = client.post("/login/", data={"password": "secret"})
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "username"],
+                    "msg": "Field required",
+                    "input": {"password": "secret"},
+                }
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "username"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                }
+            ]
+        }
+    )
+
+
+@needs_py39
+def test_post_body_form_no_data(client: TestClient):
+    response = client.post("/login/")
+    assert response.status_code == 422
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "username"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+                {
+                    "type": "missing",
+                    "loc": ["body", "password"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "username"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+                {
+                    "loc": ["body", "password"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ]
+        }
+    )
+
+
+@needs_py39
+def test_post_body_json(client: TestClient):
+    response = client.post("/login/", json={"username": "Foo", "password": "secret"})
+    assert response.status_code == 422, response.text
+    assert response.json() == IsDict(
+        {
+            "detail": [
+                {
+                    "type": "missing",
+                    "loc": ["body", "username"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+                {
+                    "type": "missing",
+                    "loc": ["body", "password"],
+                    "msg": "Field required",
+                    "input": {},
+                },
+            ]
+        }
+    ) | IsDict(
+        # TODO: remove when deprecating Pydantic v1
+        {
+            "detail": [
+                {
+                    "loc": ["body", "username"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+                {
+                    "loc": ["body", "password"],
+                    "msg": "field required",
+                    "type": "value_error.missing",
+                },
+            ]
+        }
+    )
+
+
+@needs_py39
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "openapi": "3.1.0",
+        "info": {"title": "FastAPI", "version": "0.1.0"},
+        "paths": {
+            "/login/": {
+                "post": {
+                    "responses": {
+                        "200": {
+                            "description": "Successful Response",
+                            "content": {"application/json": {"schema": {}}},
+                        },
+                        "422": {
+                            "description": "Validation Error",
+                            "content": {
+                                "application/json": {
+                                    "schema": {
+                                        "$ref": "#/components/schemas/HTTPValidationError"
+                                    }
+                                }
+                            },
+                        },
+                    },
+                    "summary": "Login",
+                    "operationId": "login_login__post",
+                    "requestBody": {
+                        "content": {
+                            "application/x-www-form-urlencoded": {
+                                "schema": {"$ref": "#/components/schemas/FormData"}
+                            }
+                        },
+                        "required": True,
+                    },
+                }
+            }
+        },
+        "components": {
+            "schemas": {
+                "FormData": {
+                    "properties": {
+                        "username": {"type": "string", "title": "Username"},
+                        "password": {"type": "string", "title": "Password"},
+                    },
+                    "type": "object",
+                    "required": ["username", "password"],
+                    "title": "FormData",
+                },
+                "ValidationError": {
+                    "title": "ValidationError",
+                    "required": ["loc", "msg", "type"],
+                    "type": "object",
+                    "properties": {
+                        "loc": {
+                            "title": "Location",
+                            "type": "array",
+                            "items": {
+                                "anyOf": [{"type": "string"}, {"type": "integer"}]
+                            },
+                        },
+                        "msg": {"title": "Message", "type": "string"},
+                        "type": {"title": "Error Type", "type": "string"},
+                    },
+                },
+                "HTTPValidationError": {
+                    "title": "HTTPValidationError",
+                    "type": "object",
+                    "properties": {
+                        "detail": {
+                            "title": "Detail",
+                            "type": "array",
+                            "items": {"$ref": "#/components/schemas/ValidationError"},
+                        }
+                    },
+                },
+            }
+        },
+    }