From 2c636ca9469c149184f18df400cc23411a6ab5c4 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 13 Nov 2025 10:58:57 +0100 Subject: [PATCH] Add section in docs --- docs/en/docs/tutorial/request-form-models.md | 10 +- docs_src/request_form_models/tutorial001b.py | 19 ++ .../request_form_models/tutorial001b_an.py | 20 ++ .../tutorial001b_an_py39.py | 21 ++ .../test_tutorial001b.py | 188 ++++++++++++++++++ 5 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 docs_src/request_form_models/tutorial001b.py create mode 100644 docs_src/request_form_models/tutorial001b_an.py create mode 100644 docs_src/request_form_models/tutorial001b_an_py39.py create mode 100644 tests/test_tutorial/test_request_form_models/test_tutorial001b.py diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md index 68bdf198e..0064f773a 100644 --- a/docs/en/docs/tutorial/request-form-models.md +++ b/docs/en/docs/tutorial/request-form-models.md @@ -36,6 +36,14 @@ You can verify it in the docs UI at `/docs`: +## Including `File` Fields { #including-file-fields } + +You can also include **file fields** inside a form model by using `UploadFile` or `bytes` as field types and annotating them with `File`. + +If there is at least one `File` field in the form model, **FastAPI** will automatically set the request content media type to `multipart/form-data` for this path operation. + +{* ../../docs_src/request_form_models/tutorial001b_an_py39.py hl[3,12,16] *} + ## Forbid Extra Form Fields { #forbid-extra-form-fields } In some special use cases (probably not very common), you might want to **restrict** the form fields to only those declared in the Pydantic model. And **forbid** any **extra** fields. @@ -75,4 +83,4 @@ They will receive an error response telling them that the field `extra` is not a ## Summary { #summary } -You can use Pydantic models to declare form fields in FastAPI. 😎 +You can use Pydantic models to declare form fields - including file uploads - in FastAPI. 😎 diff --git a/docs_src/request_form_models/tutorial001b.py b/docs_src/request_form_models/tutorial001b.py new file mode 100644 index 000000000..f5ef6fa62 --- /dev/null +++ b/docs_src/request_form_models/tutorial001b.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI, File, Form +from pydantic import BaseModel + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + avatar: bytes = File() + + +@app.post("/users/") +async def create_user(data: FormData = Form()): + return { + "username": data.username, + "password": data.password, + "avatar_file_size": len(data.avatar), + } diff --git a/docs_src/request_form_models/tutorial001b_an.py b/docs_src/request_form_models/tutorial001b_an.py new file mode 100644 index 000000000..41fb26fbf --- /dev/null +++ b/docs_src/request_form_models/tutorial001b_an.py @@ -0,0 +1,20 @@ +from fastapi import FastAPI, File, Form +from pydantic import BaseModel +from typing_extensions import Annotated + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + avatar: bytes = File() + + +@app.post("/users/") +async def create_user(data: Annotated[FormData, Form()]): + return { + "username": data.username, + "password": data.password, + "avatar_file_size": len(data.avatar), + } diff --git a/docs_src/request_form_models/tutorial001b_an_py39.py b/docs_src/request_form_models/tutorial001b_an_py39.py new file mode 100644 index 000000000..4d12e18f8 --- /dev/null +++ b/docs_src/request_form_models/tutorial001b_an_py39.py @@ -0,0 +1,21 @@ +from typing import Annotated + +from fastapi import FastAPI, File, Form +from pydantic import BaseModel + +app = FastAPI() + + +class FormData(BaseModel): + username: str + password: str + avatar: bytes = File() + + +@app.post("/users/") +async def create_user(data: Annotated[FormData, Form()]): + return { + "username": data.username, + "password": data.password, + "avatar_file_size": len(data.avatar), + } diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial001b.py b/tests/test_tutorial/test_request_form_models/test_tutorial001b.py new file mode 100644 index 000000000..72328b116 --- /dev/null +++ b/tests/test_tutorial/test_request_form_models/test_tutorial001b.py @@ -0,0 +1,188 @@ +import importlib + +import pytest +from dirty_equals import IsDict +from fastapi.testclient import TestClient + +from ...utils import needs_py39 + + +@pytest.fixture( + name="client", + params=[ + "tutorial001b", + "tutorial001b_an", + pytest.param("tutorial001b_an_py39", marks=needs_py39), + ], +) +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_post_body_form(client: TestClient): + response = client.post( + "/users/", + data={"username": "Foo", "password": "secret"}, + files={"avatar": ("avatar.png", b"filebytes")}, + ) + assert response.status_code == 200 + assert response.json() == { + "username": "Foo", + "password": "secret", + "avatar_file_size": 9, + } + + +def test_post_body_form_no_avatar(client: TestClient): + response = client.post("/users/", data={"username": "Foo", "password": "secret"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "avatar"], + "msg": "Field required", + "input": {"username": "Foo", "password": "secret"}, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "avatar"], + "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": { + "/users/": { + "post": { + "operationId": "create_user_users__post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/FormData", + }, + }, + }, + "required": True, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {}, + }, + }, + "description": "Successful Response", + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError", + }, + }, + }, + "description": "Validation Error", + }, + }, + "summary": "Create User", + }, + }, + }, + "components": { + "schemas": { + "FormData": { + "properties": { + "avatar": { + "format": "binary", + "title": "Avatar", + "type": "string", + }, + "password": { + "title": "Password", + "type": "string", + }, + "username": { + "title": "Username", + "type": "string", + }, + }, + "required": [ + "username", + "password", + "avatar", + ], + "title": "FormData", + "type": "object", + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError", + }, + "title": "Detail", + "type": "array", + }, + }, + "title": "HTTPValidationError", + "type": "object", + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "integer", + }, + ], + }, + "title": "Location", + "type": "array", + }, + "msg": { + "title": "Message", + "type": "string", + }, + "type": { + "title": "Error Type", + "type": "string", + }, + }, + "required": [ + "loc", + "msg", + "type", + ], + "title": "ValidationError", + "type": "object", + }, + }, + }, + }