From 0f3e65b00712a59d763cf9c7715cde353bb94b02 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Thu, 5 Sep 2024 16:40:48 +0200 Subject: [PATCH] =?utf8?q?=E2=9C=A8=20Add=20support=20for=20Pydantic=20mod?= =?utf8?q?els=20in=20`Form`=20parameters=20(#12127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- .../tutorial/request-form-models/image01.png | Bin 0 -> 44487 bytes docs/en/docs/tutorial/request-form-models.md | 65 +++++ docs/en/mkdocs.yml | 1 + docs_src/request_form_models/tutorial001.py | 14 + .../request_form_models/tutorial001_an.py | 15 ++ .../tutorial001_an_py39.py | 16 ++ fastapi/dependencies/utils.py | 17 +- .../playwright/request_form_models/image01.py | 36 +++ tests/test_forms_single_model.py | 129 ++++++++++ .../test_request_form_models/__init__.py | 0 .../test_tutorial001.py | 232 +++++++++++++++++ .../test_tutorial001_an.py | 232 +++++++++++++++++ .../test_tutorial001_an_py39.py | 240 ++++++++++++++++++ 13 files changed, 994 insertions(+), 3 deletions(-) create mode 100644 docs/en/docs/img/tutorial/request-form-models/image01.png create mode 100644 docs/en/docs/tutorial/request-form-models.md create mode 100644 docs_src/request_form_models/tutorial001.py create mode 100644 docs_src/request_form_models/tutorial001_an.py create mode 100644 docs_src/request_form_models/tutorial001_an_py39.py create mode 100644 scripts/playwright/request_form_models/image01.py create mode 100644 tests/test_forms_single_model.py create mode 100644 tests/test_tutorial/test_request_form_models/__init__.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001_an.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py 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 index 0000000000000000000000000000000000000000..3fe32c03d589e76abec5e9c6b71374ebd4e8cd2c GIT binary patch literal 44487 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. + +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`: + +
+ +
diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 528c80b8e6..7c810c2d7c 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -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 index 0000000000..98feff0b9f --- /dev/null +++ b/docs_src/request_form_models/tutorial001.py @@ -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 index 0000000000..30483d4455 --- /dev/null +++ b/docs_src/request_form_models/tutorial001_an.py @@ -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 index 0000000000..7cc81aae95 --- /dev/null +++ b/docs_src/request_form_models/tutorial001_an_py39.py @@ -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 diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 7ac18d941c..98ce17b55d 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -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 index 0000000000..15bd3858c5 --- /dev/null +++ b/scripts/playwright/request_form_models/image01.py @@ -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 index 0000000000..7ed3ba3a2d --- /dev/null +++ b/tests/test_forms_single_model.py @@ -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 index 0000000000..e69de29bb2 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 index 0000000000..46c130ee8c --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001.py @@ -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 index 0000000000..4e14d89c84 --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001_an.py @@ -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 index 0000000000..2e6426aa78 --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001_an_py39.py @@ -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"}, + } + }, + }, + } + }, + } -- 2.47.3