diff --git a/.github/labeler.yml b/.github/labeler.yml
index 1d49a2411..c5b1f84f3 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -7,6 +7,8 @@ docs:
- all-globs-to-all-files:
- '!fastapi/**'
- '!pyproject.toml'
+ - '!docs/en/data/sponsors.yml'
+ - '!docs/en/overrides/main.html'
lang-all:
- all:
@@ -28,6 +30,8 @@ internal:
- .pre-commit-config.yaml
- pdm_build.py
- requirements*.txt
+ - docs/en/data/sponsors.yml
+ - docs/en/overrides/main.html
- all-globs-to-all-files:
- '!docs/*/docs/**'
- '!fastapi/**'
diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index e46629e9b..52c34a49e 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -113,6 +113,7 @@ jobs:
with:
name: docs-site-${{ matrix.lang }}
path: ./site/**
+ include-hidden-files: true
# https://github.com/marketplace/actions/alls-green#why
docs-all-green: # This job does nothing and is only used for the branch protection
diff --git a/.github/workflows/issue-manager.yml b/.github/workflows/issue-manager.yml
index d5b947a9c..fbb856792 100644
--- a/.github/workflows/issue-manager.yml
+++ b/.github/workflows/issue-manager.yml
@@ -2,7 +2,7 @@ name: Issue Manager
on:
schedule:
- - cron: "10 3 * * *"
+ - cron: "13 22 * * *"
issue_comment:
types:
- created
@@ -16,6 +16,7 @@ on:
permissions:
issues: write
+ pull-requests: write
jobs:
issue-manager:
@@ -35,8 +36,8 @@ jobs:
"delay": 864000,
"message": "Assuming the original need was handled, this will be automatically closed now. But feel free to add more comments or create new issues or PRs."
},
- "changes-requested": {
+ "waiting": {
"delay": 2628000,
- "message": "As this PR had requested changes to be applied but has been inactive for a while, it's now going to be closed. But if there's anyone interested, feel free to create a new PR."
+ "message": "As this PR has been waiting for the original user for a while but seems to be inactive, it's now going to be closed. But if there's anyone interested, feel free to create a new PR."
}
}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 591df634b..5004b94dd 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -35,7 +35,7 @@ jobs:
TIANGOLO_BUILD_PACKAGE: ${{ matrix.package }}
run: python -m build
- name: Publish
- uses: pypa/gh-action-pypi-publish@v1.9.0
+ uses: pypa/gh-action-pypi-publish@v1.10.1
- name: Dump GitHub context
env:
GITHUB_CONTEXT: ${{ toJson(github) }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0458f83ff..fb4b083c4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -37,7 +37,7 @@ jobs:
if: steps.cache.outputs.cache-hit != 'true'
run: pip install -r requirements-tests.txt
- name: Install Pydantic v2
- run: pip install "pydantic>=2.0.2,<3.0.0"
+ run: pip install --upgrade "pydantic>=2.0.2,<3.0.0"
- name: Lint
run: bash scripts/lint.sh
@@ -79,7 +79,7 @@ jobs:
run: pip install "pydantic>=1.10.0,<2.0.0"
- name: Install Pydantic v2
if: matrix.pydantic-version == 'pydantic-v2'
- run: pip install "pydantic>=2.0.2,<3.0.0"
+ run: pip install --upgrade "pydantic>=2.0.2,<3.0.0"
- run: mkdir coverage
- name: Test
run: bash scripts/test.sh
@@ -91,6 +91,7 @@ jobs:
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.pydantic-version }}
path: coverage
+ include-hidden-files: true
coverage-combine:
needs: [test]
@@ -123,6 +124,7 @@ jobs:
with:
name: coverage-html
path: htmlcov
+ include-hidden-files: true
# https://github.com/marketplace/actions/alls-green#why
check: # This job does nothing and is only used for the branch protection
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 317514062..7e58afd4b 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,7 +14,7 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.6.2
+ rev: v0.6.3
hooks:
- id: ruff
args:
diff --git a/README.md b/README.md
index 5554f71d4..3b01b713a 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ The key features are:
-
+
diff --git a/docs/en/data/external_links.yml b/docs/en/data/external_links.yml
index 15f6169ee..63fd3d0cf 100644
--- a/docs/en/data/external_links.yml
+++ b/docs/en/data/external_links.yml
@@ -264,6 +264,14 @@ Articles:
author_link: https://devonray.com
link: https://devonray.com/blog/deploying-a-fastapi-project-using-aws-lambda-aurora-cdk
title: Deployment using Docker, Lambda, Aurora, CDK & GH Actions
+ - author: Shubhendra Kushwaha
+ author_link: https://www.linkedin.com/in/theshubhendra/
+ link: https://theshubhendra.medium.com/mastering-soft-delete-advanced-sqlalchemy-techniques-4678f4738947
+ title: 'Mastering Soft Delete: Advanced SQLAlchemy Techniques'
+ - author: Shubhendra Kushwaha
+ author_link: https://www.linkedin.com/in/theshubhendra/
+ link: https://theshubhendra.medium.com/role-based-row-filtering-advanced-sqlalchemy-techniques-733e6b1328f6
+ title: 'Role based row filtering: Advanced SQLAlchemy Techniques'
German:
- author: Marcel Sander (actidoo)
author_link: https://www.actidoo.com
diff --git a/docs/en/data/sponsors.yml b/docs/en/data/sponsors.yml
index 3a767b6b1..d96646fb3 100644
--- a/docs/en/data/sponsors.yml
+++ b/docs/en/data/sponsors.yml
@@ -17,7 +17,7 @@ gold:
- url: https://www.propelauth.com/?utm_source=fastapi&utm_campaign=1223&utm_medium=mainbadge
title: Auth, user management and more for your B2B product
img: https://fastapi.tiangolo.com/img/sponsors/propelauth.png
- - url: https://docs.withcoherence.com/coherence-templates/full-stack-template/#fastapi?utm_medium=advertising&utm_source=fastapi&utm_campaign=docs
+ - url: https://www.withcoherence.com/?utm_medium=advertising&utm_source=fastapi&utm_campaign=website
title: Coherence
img: https://fastapi.tiangolo.com/img/sponsors/coherence.png
- url: https://www.mongodb.com/developer/languages/python/python-quickstart-fastapi/?utm_campaign=fastapi_framework&utm_source=fastapi_sponsorship&utm_medium=web_referral
diff --git a/docs/en/docs/deployment/cloud.md b/docs/en/docs/deployment/cloud.md
index 3ea5087f8..41ada859d 100644
--- a/docs/en/docs/deployment/cloud.md
+++ b/docs/en/docs/deployment/cloud.md
@@ -14,4 +14,4 @@ You might want to try their services and follow their guides:
* Platform.sh
* Porter
-* Coherence
+* Coherence
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 000000000..3fe32c03d
Binary files /dev/null and b/docs/en/docs/img/tutorial/request-form-models/image01.png differ
diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md
index 2f7fb0cbd..e09eb57b6 100644
--- a/docs/en/docs/release-notes.md
+++ b/docs/en/docs/release-notes.md
@@ -7,8 +7,118 @@ hide:
## Latest Changes
+### Translations
+
+* 🌐 Add Korean translation for `docs/ko/docs/project-generation.md`. PR [#12157](https://github.com/fastapi/fastapi/pull/12157) by [@BORA040126](https://github.com/BORA040126).
+
+### Internal
+
+* 👷 Update `issue-manager.yml`. PR [#12159](https://github.com/fastapi/fastapi/pull/12159) by [@tiangolo](https://github.com/tiangolo).
+* ✏️ Fix typo in `fastapi/params.py`. PR [#12143](https://github.com/fastapi/fastapi/pull/12143) by [@surreal30](https://github.com/surreal30).
+
+## 0.114.0
+
+You can restrict form fields to only include those declared in a Pydantic model and forbid any extra field sent in the request using Pydantic's `model_config = {"extra": "forbid"}`:
+
+```python
+from typing import Annotated
+
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+ username: str
+ password: str
+ model_config = {"extra": "forbid"}
+
+
+@app.post("/login/")
+async def login(data: Annotated[FormData, Form()]):
+ return data
+```
+
+Read the new docs: [Form Models - Forbid Extra Form Fields](https://fastapi.tiangolo.com/tutorial/request-form-models/#forbid-extra-form-fields).
+
+### Features
+
+* ✨ Add support for forbidding extra form fields with Pydantic models. PR [#12134](https://github.com/fastapi/fastapi/pull/12134) by [@tiangolo](https://github.com/tiangolo).
+
+### Docs
+
+* 📝 Update docs, Form Models section title, to match config name. PR [#12152](https://github.com/fastapi/fastapi/pull/12152) by [@tiangolo](https://github.com/tiangolo).
+
+### Internal
+
+* ✅ Update internal tests for latest Pydantic, including CI tweaks to install the latest Pydantic. PR [#12147](https://github.com/fastapi/fastapi/pull/12147) by [@tiangolo](https://github.com/tiangolo).
+
+## 0.113.0
+
+Now you can declare form fields with Pydantic models:
+
+```python
+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
+```
+
+Read the new docs: [Form Models](https://fastapi.tiangolo.com/tutorial/request-form-models/).
+
+### Features
+
+* ✨ Add support for Pydantic models in `Form` parameters. PR [#12129](https://github.com/fastapi/fastapi/pull/12129) by [@tiangolo](https://github.com/tiangolo).
+
+### Internal
+
+* 🔧 Update sponsors: Coherence link. PR [#12130](https://github.com/fastapi/fastapi/pull/12130) by [@tiangolo](https://github.com/tiangolo).
+
+## 0.112.4
+
+This release is mainly a big internal refactor to enable adding support for Pydantic models for `Form` fields, but that feature comes in the next release.
+
+This release shouldn't affect apps using FastAPI in any way. You don't even have to upgrade to this version yet. It's just a checkpoint. 🤓
+
+### Refactors
+
+* ♻️ Refactor deciding if `embed` body fields, do not overwrite fields, compute once per router, refactor internals in preparation for Pydantic models in `Form`, `Query` and others. PR [#12117](https://github.com/fastapi/fastapi/pull/12117) by [@tiangolo](https://github.com/tiangolo).
+
+### Internal
+
+* ⏪️ Temporarily revert "✨ Add support for Pydantic models in `Form` parameters" to make a checkpoint release. PR [#12128](https://github.com/fastapi/fastapi/pull/12128) by [@tiangolo](https://github.com/tiangolo). Restored by PR [#12129](https://github.com/fastapi/fastapi/pull/12129).
+* ✨ Add support for Pydantic models in `Form` parameters. PR [#12127](https://github.com/fastapi/fastapi/pull/12127) by [@tiangolo](https://github.com/tiangolo). Reverted by PR [#12128](https://github.com/fastapi/fastapi/pull/12128) to make a checkpoint release with only refactors. Restored by PR [#12129](https://github.com/fastapi/fastapi/pull/12129).
+
+## 0.112.3
+
+This release is mainly internal refactors, it shouldn't affect apps using FastAPI in any way. You don't even have to upgrade to this version yet. There are a few bigger releases coming right after. 🚀
+
+### Refactors
+
+* ♻️ Refactor internal `check_file_field()`, rename to `ensure_multipart_is_installed()` to clarify its purpose. PR [#12106](https://github.com/fastapi/fastapi/pull/12106) by [@tiangolo](https://github.com/tiangolo).
+* ♻️ Rename internal `create_response_field()` to `create_model_field()` as it's used for more than response models. PR [#12103](https://github.com/fastapi/fastapi/pull/12103) by [@tiangolo](https://github.com/tiangolo).
+* ♻️ Refactor and simplify internal data from `solve_dependencies()` using dataclasses. PR [#12100](https://github.com/fastapi/fastapi/pull/12100) by [@tiangolo](https://github.com/tiangolo).
+* ♻️ Refactor and simplify internal `analyze_param()` to structure data with dataclasses instead of tuple. PR [#12099](https://github.com/fastapi/fastapi/pull/12099) by [@tiangolo](https://github.com/tiangolo).
+* ♻️ Refactor and simplify dependencies data structures with dataclasses. PR [#12098](https://github.com/fastapi/fastapi/pull/12098) by [@tiangolo](https://github.com/tiangolo).
+
### Docs
+* 📝 Add External Link: Techniques and applications of SQLAlchemy global filters in FastAPI. PR [#12109](https://github.com/fastapi/fastapi/pull/12109) by [@TheShubhendra](https://github.com/TheShubhendra).
+* 📝 Add note about `time.perf_counter()` in middlewares. PR [#12095](https://github.com/fastapi/fastapi/pull/12095) by [@tiangolo](https://github.com/tiangolo).
+* 📝 Tweak middleware code sample `time.time()` to `time.perf_counter()`. PR [#11957](https://github.com/fastapi/fastapi/pull/11957) by [@domdent](https://github.com/domdent).
* 🔧 Update sponsors: Coherence. PR [#12093](https://github.com/fastapi/fastapi/pull/12093) by [@tiangolo](https://github.com/tiangolo).
* 📝 Fix async test example not to trigger DeprecationWarning. PR [#12084](https://github.com/fastapi/fastapi/pull/12084) by [@marcinsulikowski](https://github.com/marcinsulikowski).
* 📝 Update `docs_src/path_params_numeric_validations/tutorial006.py`. PR [#11478](https://github.com/fastapi/fastapi/pull/11478) by [@MuhammadAshiqAmeer](https://github.com/MuhammadAshiqAmeer).
@@ -20,11 +130,21 @@ hide:
### Translations
+* 🌐 Add Dutch translation for `docs/nl/docs/features.md`. PR [#12101](https://github.com/fastapi/fastapi/pull/12101) by [@maxscheijen](https://github.com/maxscheijen).
+* 🌐 Add Portuguese translation for `docs/pt/docs/advanced/testing-events.md`. PR [#12108](https://github.com/fastapi/fastapi/pull/12108) by [@ceb10n](https://github.com/ceb10n).
+* 🌐 Add Portuguese translation for `docs/pt/docs/advanced/security/index.md`. PR [#12114](https://github.com/fastapi/fastapi/pull/12114) by [@ceb10n](https://github.com/ceb10n).
* 🌐 Add Dutch translation for `docs/nl/docs/index.md`. PR [#12042](https://github.com/fastapi/fastapi/pull/12042) by [@svlandeg](https://github.com/svlandeg).
* 🌐 Update Chinese translation for `docs/zh/docs/how-to/index.md`. PR [#12070](https://github.com/fastapi/fastapi/pull/12070) by [@synthpop123](https://github.com/synthpop123).
### Internal
+* ⬆ [pre-commit.ci] pre-commit autoupdate. PR [#12115](https://github.com/fastapi/fastapi/pull/12115) by [@pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci).
+* ⬆ Bump pypa/gh-action-pypi-publish from 1.10.0 to 1.10.1. PR [#12120](https://github.com/fastapi/fastapi/pull/12120) by [@dependabot[bot]](https://github.com/apps/dependabot).
+* ⬆ Bump pillow from 10.3.0 to 10.4.0. PR [#12105](https://github.com/fastapi/fastapi/pull/12105) by [@dependabot[bot]](https://github.com/apps/dependabot).
+* 💚 Set `include-hidden-files` to `True` when using the `upload-artifact` GH action. PR [#12118](https://github.com/fastapi/fastapi/pull/12118) by [@svlandeg](https://github.com/svlandeg).
+* ⬆ Bump pypa/gh-action-pypi-publish from 1.9.0 to 1.10.0. PR [#12112](https://github.com/fastapi/fastapi/pull/12112) by [@dependabot[bot]](https://github.com/apps/dependabot).
+* 🔧 Update sponsors link: Coherence. PR [#12097](https://github.com/fastapi/fastapi/pull/12097) by [@tiangolo](https://github.com/tiangolo).
+* 🔧 Update labeler config to handle sponsorships data. PR [#12096](https://github.com/fastapi/fastapi/pull/12096) by [@tiangolo](https://github.com/tiangolo).
* 🔧 Update sponsors, remove Kong. PR [#12085](https://github.com/fastapi/fastapi/pull/12085) by [@tiangolo](https://github.com/tiangolo).
* ⬆ [pre-commit.ci] pre-commit autoupdate. PR [#12076](https://github.com/fastapi/fastapi/pull/12076) by [@pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci).
* 👷 Update `latest-changes` GitHub Action. PR [#12073](https://github.com/fastapi/fastapi/pull/12073) by [@tiangolo](https://github.com/tiangolo).
diff --git a/docs/en/docs/tutorial/middleware.md b/docs/en/docs/tutorial/middleware.md
index 06fb3f504..199b593d3 100644
--- a/docs/en/docs/tutorial/middleware.md
+++ b/docs/en/docs/tutorial/middleware.md
@@ -63,6 +63,12 @@ For example, you could add a custom header `X-Process-Time` containing the time
{!../../../docs_src/middleware/tutorial001.py!}
```
+/// tip
+
+Here we use `time.perf_counter()` instead of `time.time()` because it can be more precise for these use cases. 🤓
+
+///
+
## Other middlewares
You can later read more about other middlewares in the [Advanced User Guide: Advanced Middleware](../advanced/middleware.md){.internal-link target=_blank}.
diff --git a/docs/en/docs/tutorial/request-form-models.md b/docs/en/docs/tutorial/request-form-models.md
new file mode 100644
index 000000000..1440d17b8
--- /dev/null
+++ b/docs/en/docs/tutorial/request-form-models.md
@@ -0,0 +1,134 @@
+# Form Models
+
+You can use **Pydantic models** to declare **form fields** in FastAPI.
+
+/// info
+
+To use forms, first install `python-multipart`.
+
+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/ko/docs/project-generation.md b/docs/ko/docs/project-generation.md
new file mode 100644
index 000000000..019919f77
--- /dev/null
+++ b/docs/ko/docs/project-generation.md
@@ -0,0 +1,28 @@
+# Full Stack FastAPI 템플릿
+
+템플릿은 일반적으로 특정 설정과 함께 제공되지만, 유연하고 커스터마이징이 가능하게 디자인 되었습니다. 이 특성들은 여러분이 프로젝트의 요구사항에 맞춰 수정, 적용을 할 수 있게 해주고, 템플릿이 완벽한 시작점이 되게 해줍니다. 🏁
+
+많은 초기 설정, 보안, 데이터베이스 및 일부 API 엔드포인트가 이미 준비되어 있으므로, 여러분은 이 템플릿을 (프로젝트를) 시작하는 데 사용할 수 있습니다.
+
+GitHub 저장소:
Full Stack FastAPI 템플릿
+
+## Full Stack FastAPI 템플릿 - 기술 스택과 기능들
+
+- ⚡ [**FastAPI**](https://fastapi.tiangolo.com): Python 백엔드 API.
+ - 🧰 [SQLModel](https://sqlmodel.tiangolo.com): Python SQL 데이터 상호작용을 위한 (ORM).
+ - 🔍 [Pydantic](https://docs.pydantic.dev): FastAPI에 의해 사용되는, 데이터 검증과 설정관리.
+ - 💾 [PostgreSQL](https://www.postgresql.org): SQL 데이터베이스.
+- 🚀 [React](https://react.dev): 프론트엔드.
+ - 💃 TypeScript, hooks, Vite 및 기타 현대적인 프론트엔드 스택을 사용.
+ - 🎨 [Chakra UI](https://chakra-ui.com): 프론트엔드 컴포넌트.
+ - 🤖 자동으로 생성된 프론트엔드 클라이언트.
+ - 🧪 E2E 테스트를 위한 Playwright.
+ - 🦇 다크 모드 지원.
+- 🐋 [Docker Compose](https://www.docker.com): 개발 환경과 프로덕션(운영).
+- 🔒 기본으로 지원되는 안전한 비밀번호 해싱.
+- 🔑 JWT 토큰 인증.
+- 📫 이메일 기반 비밀번호 복구.
+- ✅ [Pytest]를 이용한 테스트(https://pytest.org).
+- 📞 [Traefik](https://traefik.io): 리버스 프록시 / 로드 밸런서.
+- 🚢 Docker Compose를 이용한 배포 지침: 자동 HTTPS 인증서를 처리하기 위한 프론트엔드 Traefik 프록시 설정 방법을 포함.
+- 🏭 GitHub Actions를 기반으로 CI (지속적인 통합) 및 CD (지속적인 배포).
diff --git a/docs/nl/docs/features.md b/docs/nl/docs/features.md
new file mode 100644
index 000000000..848b155ec
--- /dev/null
+++ b/docs/nl/docs/features.md
@@ -0,0 +1,201 @@
+# Functionaliteit
+
+## FastAPI functionaliteit
+
+**FastAPI** biedt je het volgende:
+
+### Gebaseerd op open standaarden
+
+*
OpenAPI voor het maken van API's, inclusief declaraties van
padbewerkingen, parameters, request bodies, beveiliging, enz.
+* Automatische datamodel documentatie met
JSON Schema (aangezien OpenAPI zelf is gebaseerd op JSON Schema).
+* Ontworpen op basis van deze standaarden, na zorgvuldig onderzoek. In plaats van achteraf deze laag er bovenop te bouwen.
+* Dit maakt het ook mogelijk om automatisch **clientcode te genereren** in verschillende programmeertalen.
+
+### Automatische documentatie
+
+Interactieve API-documentatie en verkenning van webgebruikersinterfaces. Aangezien dit framework is gebaseerd op OpenAPI, zijn er meerdere documentatie opties mogelijk, waarvan er standaard 2 zijn inbegrepen.
+
+*
Swagger UI, met interactieve interface, maakt het mogelijk je API rechtstreeks vanuit de browser aan te roepen en te testen.
+
+
+
+* Alternatieve API-documentatie met
ReDoc.
+
+
+
+### Gewoon Moderne Python
+
+Het is allemaal gebaseerd op standaard **Python type** declaraties (dankzij Pydantic). Je hoeft dus geen nieuwe syntax te leren. Het is gewoon standaard moderne Python.
+
+Als je een opfriscursus van 2 minuten nodig hebt over het gebruik van Python types (zelfs als je FastAPI niet gebruikt), bekijk dan deze korte tutorial: [Python Types](python-types.md){.internal-link target=_blank}.
+
+Je schrijft gewoon standaard Python met types:
+
+```Python
+from datetime import date
+
+from pydantic import BaseModel
+
+# Declareer een variabele als een str
+# en krijg editorondersteuning in de functie
+def main(user_id: str):
+ return user_id
+
+
+# Een Pydantic model
+class User(BaseModel):
+ id: int
+ name: str
+ joined: date
+```
+
+Vervolgens kan je het op deze manier gebruiken:
+
+```Python
+my_user: User = User(id=3, name="John Doe", joined="2018-07-19")
+
+second_user_data = {
+ "id": 4,
+ "name": "Mary",
+ "joined": "2018-11-30",
+}
+
+my_second_user: User = User(**second_user_data)
+```
+
+/// info
+
+`**second_user_data` betekent:
+
+Geef de sleutels (keys) en waarden (values) van de `second_user_data` dict direct door als sleutel-waarden argumenten, gelijk aan: `User(id=4, name=“Mary”, joined=“2018-11-30”)`
+
+///
+
+### Editor-ondersteuning
+
+Het gehele framework is ontworpen om eenvoudig en intuïtief te zijn in gebruik. Alle beslissingen zijn getest op meerdere code-editors nog voordat het daadwerkelijke ontwikkelen begon, om zo de beste ontwikkelervaring te garanderen.
+
+Uit enquêtes onder Python ontwikkelaars blijkt maar al te duidelijk dat "(automatische) code aanvulling"
een van de meest gebruikte functionaliteiten is.
+
+Het hele **FastAPI** framework is daarop gebaseerd. Automatische code aanvulling werkt overal.
+
+Je hoeft zelden terug te vallen op de documentatie.
+
+Zo kan je editor je helpen:
+
+* in
Visual Studio Code:
+
+
+
+* in
PyCharm:
+
+
+
+Je krijgt autocomletion die je voorheen misschien zelfs voor onmogelijk had gehouden. Zoals bijvoorbeeld de `price` key in een JSON body (die genest had kunnen zijn) die afkomstig is van een request.
+
+Je hoeft niet langer de verkeerde keys in te typen, op en neer te gaan tussen de documentatie, of heen en weer te scrollen om te checken of je `username` of toch `user_name` had gebruikt.
+
+### Kort
+
+Dit framework heeft voor alles verstandige **standaardinstellingen**, met overal optionele configuraties. Alle parameters kunnen worden verfijnd zodat het past bij wat je nodig hebt, om zo de API te kunnen definiëren die jij nodig hebt.
+
+Maar standaard werkt alles **“gewoon”**.
+
+### Validatie
+
+* Validatie voor de meeste (of misschien wel alle?) Python **datatypes**, inclusief:
+ * JSON objecten (`dict`).
+ * JSON array (`list`) die itemtypes definiëren.
+ * String (`str`) velden, die min en max lengtes hebben.
+ * Getallen (`int`, `float`) met min en max waarden, enz.
+
+* Validatie voor meer exotische typen, zoals:
+ * URL.
+ * E-mail.
+ * UUID.
+ * ...en anderen.
+
+Alle validatie wordt uitgevoerd door het beproefde en robuuste **Pydantic**.
+
+### Beveiliging en authenticatie
+
+Beveiliging en authenticatie is geïntegreerd. Zonder compromissen te doen naar databases of datamodellen.
+
+Alle beveiligingsschema's gedefinieerd in OpenAPI, inclusief:
+
+* HTTP Basic.
+* **OAuth2** (ook met **JWT tokens**). Bekijk de tutorial over [OAuth2 with JWT](tutorial/security/oauth2-jwt.md){.internal-link target=_blank}.
+* API keys in:
+ * Headers.
+ * Query parameters.
+ * Cookies, enz.
+
+Plus alle beveiligingsfuncties van Starlette (inclusief **sessiecookies**).
+
+Gebouwd als een herbruikbare tool met componenten die makkelijk te integreren zijn in en met je systemen, datastores, relationele en NoSQL databases, enz.
+
+### Dependency Injection
+
+FastAPI bevat een uiterst eenvoudig, maar uiterst krachtig
Dependency Injection systeem.
+
+* Zelfs dependencies kunnen dependencies hebben, waardoor een hiërarchie of **“graph” van dependencies** ontstaat.
+* Allemaal **automatisch afgehandeld** door het framework.
+* Alle dependencies kunnen data nodig hebben van request, de vereiste **padoperaties veranderen** en automatische documentatie verstrekken.
+* **Automatische validatie** zelfs voor *padoperatie* parameters gedefinieerd in dependencies.
+* Ondersteuning voor complexe gebruikersauthenticatiesystemen, **databaseverbindingen**, enz.
+* **Geen compromisen** met databases, gebruikersinterfaces, enz. Maar eenvoudige integratie met ze allemaal.
+
+### Ongelimiteerde "plug-ins"
+
+Of anders gezegd, je hebt ze niet nodig, importeer en gebruik de code die je nodig hebt.
+
+Elke integratie is ontworpen om eenvoudig te gebruiken (met afhankelijkheden), zodat je een “plug-in" kunt maken in 2 regels code, met dezelfde structuur en syntax die wordt gebruikt voor je *padbewerkingen*.
+
+### Getest
+
+* 100%
van de code is getest.
+* 100%
type geannoteerde codebase.
+* Wordt gebruikt in productietoepassingen.
+
+## Starlette functies
+
+**FastAPI** is volledig verenigbaar met (en gebaseerd op)
Starlette.
+
+`FastAPI` is eigenlijk een subklasse van `Starlette`. Dus als je Starlette al kent of gebruikt, zal de meeste functionaliteit op dezelfde manier werken.
+
+Met **FastAPI** krijg je alle functies van **Starlette** (FastAPI is gewoon Starlette op steroïden):
+
+* Zeer indrukwekkende prestaties. Het is
een van de snelste Python frameworks, vergelijkbaar met **NodeJS** en **Go**.
+* **WebSocket** ondersteuning.
+* Taken in de achtergrond tijdens het proces.
+* Opstart- en afsluit events.
+* Test client gebouwd op HTTPX.
+* **CORS**, GZip, Statische bestanden, Streaming reacties.
+* **Sessie en Cookie** ondersteuning.
+* 100% van de code is getest.
+* 100% type geannoteerde codebase.
+
+## Pydantic functionaliteit
+
+**FastAPI** is volledig verenigbaar met (en gebaseerd op) Pydantic. Dus alle extra
Pydantic code die je nog hebt werkt ook.
+
+Inclusief externe pakketten die ook gebaseerd zijn op Pydantic, zoals
ORMs,
ODMs voor databases.
+
+Dit betekent ook dat je in veel gevallen het object dat je van een request krijgt **direct naar je database** kunt sturen, omdat alles automatisch wordt gevalideerd.
+
+Hetzelfde geldt ook andersom, in veel gevallen kun je dus het object dat je krijgt van de database **direct doorgeven aan de client**.
+
+Met **FastAPI** krijg je alle functionaliteit van **Pydantic** (omdat FastAPI is gebaseerd op Pydantic voor alle dataverwerking):
+
+* **Geen brainfucks**:
+ * Je hoeft geen nieuwe microtaal voor schemadefinities te leren.
+ * Als je bekend bent Python types, weet je hoe je Pydantic moet gebruiken.
+* Werkt goed samen met je **
IDE/
linter/hersenen**:
+ * Doordat pydantic's datastructuren enkel instanties zijn van klassen, die je definieert, werkt automatische aanvulling, linting, mypy en je intuïtie allemaal goed met je gevalideerde data.
+* Valideer **complexe structuren**:
+ * Gebruik van hiërarchische Pydantic modellen, Python `typing`'s `List` en `Dict`, enz.
+ * Met validators kunnen complexe dataschema's duidelijk en eenvoudig worden gedefinieerd, gecontroleerd en gedocumenteerd als JSON Schema.
+ * Je kunt diep **geneste JSON** objecten laten valideren en annoteren.
+* **Uitbreidbaar**:
+ * Met Pydantic kunnen op maat gemaakte datatypen worden gedefinieerd of je kunt validatie uitbreiden met methoden op een model dat is ingericht met de decorator validator.
+* 100% van de code is getest.
diff --git a/docs/pt/docs/advanced/security/index.md b/docs/pt/docs/advanced/security/index.md
new file mode 100644
index 000000000..ae63f1c96
--- /dev/null
+++ b/docs/pt/docs/advanced/security/index.md
@@ -0,0 +1,19 @@
+# Segurança Avançada
+
+## Funcionalidades Adicionais
+
+Existem algumas funcionalidades adicionais para lidar com segurança além das cobertas em [Tutorial - Guia de Usuário: Segurança](../../tutorial/security/index.md){.internal-link target=_blank}.
+
+/// tip | "Dica"
+
+As próximas seções **não são necessariamente "avançadas"**.
+
+E é possível que para o seu caso de uso, a solução está em uma delas.
+
+///
+
+## Leia o Tutorial primeiro
+
+As próximas seções pressupõem que você já leu o principal [Tutorial - Guia de Usuário: Segurança](../../tutorial/security/index.md){.internal-link target=_blank}.
+
+Todas elas são baseadas nos mesmos conceitos, mas permitem algumas funcionalidades extras.
diff --git a/docs/pt/docs/advanced/testing-events.md b/docs/pt/docs/advanced/testing-events.md
new file mode 100644
index 000000000..392fb741c
--- /dev/null
+++ b/docs/pt/docs/advanced/testing-events.md
@@ -0,0 +1,7 @@
+# Testando Eventos: inicialização - encerramento
+
+Quando você precisa que os seus manipuladores de eventos (`startup` e `shutdown`) sejam executados em seus testes, você pode utilizar o `TestClient` usando a instrução `with`:
+
+```Python hl_lines="9-12 20-24"
+{!../../../docs_src/app_testing/tutorial003.py!}
+```
diff --git a/docs_src/middleware/tutorial001.py b/docs_src/middleware/tutorial001.py
index 6bab3410a..e65a7dade 100644
--- a/docs_src/middleware/tutorial001.py
+++ b/docs_src/middleware/tutorial001.py
@@ -7,8 +7,8 @@ app = FastAPI()
@app.middleware("http")
async def add_process_time_header(request: Request, call_next):
- start_time = time.time()
+ start_time = time.perf_counter()
response = await call_next(request)
- process_time = time.time() - start_time
+ process_time = time.perf_counter() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
diff --git a/docs_src/request_form_models/tutorial001.py b/docs_src/request_form_models/tutorial001.py
new file mode 100644
index 000000000..98feff0b9
--- /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 000000000..30483d445
--- /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 000000000..7cc81aae9
--- /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/docs_src/request_form_models/tutorial002.py b/docs_src/request_form_models/tutorial002.py
new file mode 100644
index 000000000..59b329e8d
--- /dev/null
+++ b/docs_src/request_form_models/tutorial002.py
@@ -0,0 +1,15 @@
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+ username: str
+ password: str
+ model_config = {"extra": "forbid"}
+
+
+@app.post("/login/")
+async def login(data: FormData = Form()):
+ return data
diff --git a/docs_src/request_form_models/tutorial002_an.py b/docs_src/request_form_models/tutorial002_an.py
new file mode 100644
index 000000000..bcb022795
--- /dev/null
+++ b/docs_src/request_form_models/tutorial002_an.py
@@ -0,0 +1,16 @@
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+ username: str
+ password: str
+ model_config = {"extra": "forbid"}
+
+
+@app.post("/login/")
+async def login(data: Annotated[FormData, Form()]):
+ return data
diff --git a/docs_src/request_form_models/tutorial002_an_py39.py b/docs_src/request_form_models/tutorial002_an_py39.py
new file mode 100644
index 000000000..3004e0852
--- /dev/null
+++ b/docs_src/request_form_models/tutorial002_an_py39.py
@@ -0,0 +1,17 @@
+from typing import Annotated
+
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+ username: str
+ password: str
+ model_config = {"extra": "forbid"}
+
+
+@app.post("/login/")
+async def login(data: Annotated[FormData, Form()]):
+ return data
diff --git a/docs_src/request_form_models/tutorial002_pv1.py b/docs_src/request_form_models/tutorial002_pv1.py
new file mode 100644
index 000000000..d5f7db2a6
--- /dev/null
+++ b/docs_src/request_form_models/tutorial002_pv1.py
@@ -0,0 +1,17 @@
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+ username: str
+ password: str
+
+ class Config:
+ extra = "forbid"
+
+
+@app.post("/login/")
+async def login(data: FormData = Form()):
+ return data
diff --git a/docs_src/request_form_models/tutorial002_pv1_an.py b/docs_src/request_form_models/tutorial002_pv1_an.py
new file mode 100644
index 000000000..fe9dbc344
--- /dev/null
+++ b/docs_src/request_form_models/tutorial002_pv1_an.py
@@ -0,0 +1,18 @@
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+ username: str
+ password: str
+
+ class Config:
+ extra = "forbid"
+
+
+@app.post("/login/")
+async def login(data: Annotated[FormData, Form()]):
+ return data
diff --git a/docs_src/request_form_models/tutorial002_pv1_an_py39.py b/docs_src/request_form_models/tutorial002_pv1_an_py39.py
new file mode 100644
index 000000000..942d5d411
--- /dev/null
+++ b/docs_src/request_form_models/tutorial002_pv1_an_py39.py
@@ -0,0 +1,19 @@
+from typing import Annotated
+
+from fastapi import FastAPI, Form
+from pydantic import BaseModel
+
+app = FastAPI()
+
+
+class FormData(BaseModel):
+ username: str
+ password: str
+
+ class Config:
+ extra = "forbid"
+
+
+@app.post("/login/")
+async def login(data: Annotated[FormData, Form()]):
+ return data
diff --git a/fastapi/__init__.py b/fastapi/__init__.py
index ac2508d89..dce17360f 100644
--- a/fastapi/__init__.py
+++ b/fastapi/__init__.py
@@ -1,6 +1,6 @@
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
-__version__ = "0.112.2"
+__version__ = "0.114.0"
from starlette import status as status
diff --git a/fastapi/_compat.py b/fastapi/_compat.py
index 06b847b4f..f940d6597 100644
--- a/fastapi/_compat.py
+++ b/fastapi/_compat.py
@@ -279,6 +279,12 @@ if PYDANTIC_V2:
BodyModel: Type[BaseModel] = create_model(model_name, **field_params) # type: ignore[call-overload]
return BodyModel
+ def get_model_fields(model: Type[BaseModel]) -> List[ModelField]:
+ return [
+ ModelField(field_info=field_info, name=name)
+ for name, field_info in model.model_fields.items()
+ ]
+
else:
from fastapi.openapi.constants import REF_PREFIX as REF_PREFIX
from pydantic import AnyUrl as Url # noqa: F401
@@ -513,6 +519,9 @@ else:
BodyModel.__fields__[f.name] = f # type: ignore[index]
return BodyModel
+ def get_model_fields(model: Type[BaseModel]) -> List[ModelField]:
+ return list(model.__fields__.values()) # type: ignore[attr-defined]
+
def _regenerate_error_with_loc(
*, errors: Sequence[Any], loc_prefix: Tuple[Union[str, int], ...]
@@ -532,6 +541,12 @@ def _annotation_is_sequence(annotation: Union[Type[Any], None]) -> bool:
def field_annotation_is_sequence(annotation: Union[Type[Any], None]) -> bool:
+ origin = get_origin(annotation)
+ if origin is Union or origin is UnionType:
+ for arg in get_args(annotation):
+ if field_annotation_is_sequence(arg):
+ return True
+ return False
return _annotation_is_sequence(annotation) or _annotation_is_sequence(
get_origin(annotation)
)
diff --git a/fastapi/dependencies/models.py b/fastapi/dependencies/models.py
index 61ef00638..418c11725 100644
--- a/fastapi/dependencies/models.py
+++ b/fastapi/dependencies/models.py
@@ -1,58 +1,37 @@
-from typing import Any, Callable, List, Optional, Sequence
+from dataclasses import dataclass, field
+from typing import Any, Callable, List, Optional, Sequence, Tuple
from fastapi._compat import ModelField
from fastapi.security.base import SecurityBase
+@dataclass
class SecurityRequirement:
- def __init__(
- self, security_scheme: SecurityBase, scopes: Optional[Sequence[str]] = None
- ):
- self.security_scheme = security_scheme
- self.scopes = scopes
+ security_scheme: SecurityBase
+ scopes: Optional[Sequence[str]] = None
+@dataclass
class Dependant:
- def __init__(
- self,
- *,
- path_params: Optional[List[ModelField]] = None,
- query_params: Optional[List[ModelField]] = None,
- header_params: Optional[List[ModelField]] = None,
- cookie_params: Optional[List[ModelField]] = None,
- body_params: Optional[List[ModelField]] = None,
- dependencies: Optional[List["Dependant"]] = None,
- security_schemes: Optional[List[SecurityRequirement]] = None,
- name: Optional[str] = None,
- call: Optional[Callable[..., Any]] = None,
- request_param_name: Optional[str] = None,
- websocket_param_name: Optional[str] = None,
- http_connection_param_name: Optional[str] = None,
- response_param_name: Optional[str] = None,
- background_tasks_param_name: Optional[str] = None,
- security_scopes_param_name: Optional[str] = None,
- security_scopes: Optional[List[str]] = None,
- use_cache: bool = True,
- path: Optional[str] = None,
- ) -> None:
- self.path_params = path_params or []
- self.query_params = query_params or []
- self.header_params = header_params or []
- self.cookie_params = cookie_params or []
- self.body_params = body_params or []
- self.dependencies = dependencies or []
- self.security_requirements = security_schemes or []
- self.request_param_name = request_param_name
- self.websocket_param_name = websocket_param_name
- self.http_connection_param_name = http_connection_param_name
- self.response_param_name = response_param_name
- self.background_tasks_param_name = background_tasks_param_name
- self.security_scopes = security_scopes
- self.security_scopes_param_name = security_scopes_param_name
- self.name = name
- self.call = call
- self.use_cache = use_cache
- # Store the path to be able to re-generate a dependable from it in overrides
- self.path = path
- # Save the cache key at creation to optimize performance
+ path_params: List[ModelField] = field(default_factory=list)
+ query_params: List[ModelField] = field(default_factory=list)
+ header_params: List[ModelField] = field(default_factory=list)
+ cookie_params: List[ModelField] = field(default_factory=list)
+ body_params: List[ModelField] = field(default_factory=list)
+ dependencies: List["Dependant"] = field(default_factory=list)
+ security_requirements: List[SecurityRequirement] = field(default_factory=list)
+ name: Optional[str] = None
+ call: Optional[Callable[..., Any]] = None
+ request_param_name: Optional[str] = None
+ websocket_param_name: Optional[str] = None
+ http_connection_param_name: Optional[str] = None
+ response_param_name: Optional[str] = None
+ background_tasks_param_name: Optional[str] = None
+ security_scopes_param_name: Optional[str] = None
+ security_scopes: Optional[List[str]] = None
+ use_cache: bool = True
+ path: Optional[str] = None
+ cache_key: Tuple[Optional[Callable[..., Any]], Tuple[str, ...]] = field(init=False)
+
+ def __post_init__(self) -> None:
self.cache_key = (self.call, tuple(sorted(set(self.security_scopes or []))))
diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py
index 3e8e7b410..6083b7319 100644
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -1,6 +1,7 @@
import inspect
from contextlib import AsyncExitStack, contextmanager
from copy import copy, deepcopy
+from dataclasses import dataclass
from typing import (
Any,
Callable,
@@ -32,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,
@@ -54,11 +56,18 @@ from fastapi.logger import logger
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_response_field, get_path_param_names
+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
-from starlette.datastructures import FormData, Headers, QueryParams, UploadFile
+from starlette.datastructures import (
+ FormData,
+ Headers,
+ ImmutableMultiDict,
+ QueryParams,
+ UploadFile,
+)
from starlette.requests import HTTPConnection, Request
from starlette.responses import Response
from starlette.websockets import WebSocket
@@ -79,25 +88,23 @@ multipart_incorrect_install_error = (
)
-def check_file_field(field: ModelField) -> None:
- field_info = field.field_info
- if isinstance(field_info, params.Form):
- try:
- # __version__ is available in both multiparts, and can be mocked
- from multipart import __version__ # type: ignore
+def ensure_multipart_is_installed() -> None:
+ try:
+ # __version__ is available in both multiparts, and can be mocked
+ from multipart import __version__ # type: ignore
- assert __version__
- try:
- # parse_options_header is only available in the right multipart
- from multipart.multipart import parse_options_header # type: ignore
+ assert __version__
+ try:
+ # parse_options_header is only available in the right multipart
+ from multipart.multipart import parse_options_header # type: ignore
- assert parse_options_header
- except ImportError:
- logger.error(multipart_incorrect_install_error)
- raise RuntimeError(multipart_incorrect_install_error) from None
+ assert parse_options_header
except ImportError:
- logger.error(multipart_not_installed_error)
- raise RuntimeError(multipart_not_installed_error) from None
+ logger.error(multipart_incorrect_install_error)
+ raise RuntimeError(multipart_incorrect_install_error) from None
+ except ImportError:
+ logger.error(multipart_not_installed_error)
+ raise RuntimeError(multipart_not_installed_error) from None
def get_param_sub_dependant(
@@ -175,7 +182,7 @@ def get_flat_dependant(
header_params=dependant.header_params.copy(),
cookie_params=dependant.cookie_params.copy(),
body_params=dependant.body_params.copy(),
- security_schemes=dependant.security_requirements.copy(),
+ security_requirements=dependant.security_requirements.copy(),
use_cache=dependant.use_cache,
path=dependant.path,
)
@@ -258,16 +265,16 @@ def get_dependant(
)
for param_name, param in signature_params.items():
is_path_param = param_name in path_param_names
- type_annotation, depends, param_field = analyze_param(
+ param_details = analyze_param(
param_name=param_name,
annotation=param.annotation,
value=param.default,
is_path_param=is_path_param,
)
- if depends is not None:
+ if param_details.depends is not None:
sub_dependant = get_param_sub_dependant(
param_name=param_name,
- depends=depends,
+ depends=param_details.depends,
path=path,
security_scopes=security_scopes,
)
@@ -275,18 +282,18 @@ def get_dependant(
continue
if add_non_field_param_to_dependency(
param_name=param_name,
- type_annotation=type_annotation,
+ type_annotation=param_details.type_annotation,
dependant=dependant,
):
assert (
- param_field is None
+ param_details.field is None
), f"Cannot specify multiple FastAPI annotations for {param_name!r}"
continue
- assert param_field is not None
- if is_body_param(param_field=param_field, is_path_param=is_path_param):
- dependant.body_params.append(param_field)
+ assert param_details.field is not None
+ if isinstance(param_details.field.field_info, params.Body):
+ dependant.body_params.append(param_details.field)
else:
- add_param_to_fields(field=param_field, dependant=dependant)
+ add_param_to_fields(field=param_details.field, dependant=dependant)
return dependant
@@ -314,13 +321,20 @@ def add_non_field_param_to_dependency(
return None
+@dataclass
+class ParamDetails:
+ type_annotation: Any
+ depends: Optional[params.Depends]
+ field: Optional[ModelField]
+
+
def analyze_param(
*,
param_name: str,
annotation: Any,
value: Any,
is_path_param: bool,
-) -> Tuple[Any, Optional[params.Depends], Optional[ModelField]]:
+) -> ParamDetails:
field_info = None
depends = None
type_annotation: Any = Any
@@ -328,6 +342,7 @@ def analyze_param(
if annotation is not inspect.Signature.empty:
use_annotation = annotation
type_annotation = annotation
+ # Extract Annotated info
if get_origin(use_annotation) is Annotated:
annotated_args = get_args(annotation)
type_annotation = annotated_args[0]
@@ -347,6 +362,7 @@ def analyze_param(
)
else:
fastapi_annotation = None
+ # Set default for Annotated FieldInfo
if isinstance(fastapi_annotation, FieldInfo):
# Copy `field_info` because we mutate `field_info.default` below.
field_info = copy_field_info(
@@ -361,9 +377,10 @@ def analyze_param(
field_info.default = value
else:
field_info.default = Required
+ # Get Annotated Depends
elif isinstance(fastapi_annotation, params.Depends):
depends = fastapi_annotation
-
+ # Get Depends from default value
if isinstance(value, params.Depends):
assert depends is None, (
"Cannot specify `Depends` in `Annotated` and default value"
@@ -374,6 +391,7 @@ def analyze_param(
f" default value together for {param_name!r}"
)
depends = value
+ # Get FieldInfo from default value
elif isinstance(value, FieldInfo):
assert field_info is None, (
"Cannot specify FastAPI annotations in `Annotated` and default value"
@@ -383,11 +401,13 @@ def analyze_param(
if PYDANTIC_V2:
field_info.annotation = type_annotation
+ # Get Depends from type annotation
if depends is not None and depends.dependency is None:
# Copy `depends` before mutating it
depends = copy(depends)
depends.dependency = type_annotation
+ # Handle non-param type annotations like Request
if lenient_issubclass(
type_annotation,
(
@@ -403,6 +423,7 @@ def analyze_param(
assert (
field_info is None
), f"Cannot specify FastAPI annotation for type {type_annotation!r}"
+ # Handle default assignations, neither field_info nor depends was not found in Annotated nor default value
elif field_info is None and depends is None:
default_value = value if value is not inspect.Signature.empty else Required
if is_path_param:
@@ -420,7 +441,9 @@ def analyze_param(
field_info = params.Query(annotation=use_annotation, default=default_value)
field = None
+ # It's a field_info, not a dependency
if field_info is not None:
+ # Handle field_info.in_
if is_path_param:
assert isinstance(field_info, params.Path), (
f"Cannot use `{field_info.__class__.__name__}` for path param"
@@ -436,12 +459,14 @@ def analyze_param(
field_info,
param_name,
)
+ if isinstance(field_info, params.Form):
+ ensure_multipart_is_installed()
if not field_info.alias and getattr(field_info, "convert_underscores", None):
alias = param_name.replace("_", "-")
else:
alias = field_info.alias or param_name
field_info.alias = alias
- field = create_response_field(
+ field = create_model_field(
name=param_name,
type_=use_annotation_from_field_info,
default=field_info.default,
@@ -449,27 +474,14 @@ def analyze_param(
required=field_info.default in (Required, Undefined),
field_info=field_info,
)
+ if is_path_param:
+ assert is_scalar_field(
+ field=field
+ ), "Path params must be of one of the supported types"
+ elif isinstance(field_info, params.Query):
+ assert is_scalar_field(field) or is_scalar_sequence_field(field)
- return type_annotation, depends, field
-
-
-def is_body_param(*, param_field: ModelField, is_path_param: bool) -> bool:
- if is_path_param:
- assert is_scalar_field(
- field=param_field
- ), "Path params must be of one of the supported types"
- return False
- elif is_scalar_field(field=param_field):
- return False
- elif isinstance(
- param_field.field_info, (params.Query, params.Header)
- ) and is_scalar_sequence_field(param_field):
- return False
- else:
- assert isinstance(
- param_field.field_info, params.Body
- ), f"Param: {param_field.name} can only be a request body, using Body()"
- return True
+ return ParamDetails(type_annotation=type_annotation, depends=depends, field=field)
def add_param_to_fields(*, field: ModelField, dependant: Dependant) -> None:
@@ -521,6 +533,15 @@ async def solve_generator(
return await stack.enter_async_context(cm)
+@dataclass
+class SolvedDependency:
+ values: Dict[str, Any]
+ errors: List[Any]
+ background_tasks: Optional[StarletteBackgroundTasks]
+ response: Response
+ dependency_cache: Dict[Tuple[Callable[..., Any], Tuple[str]], Any]
+
+
async def solve_dependencies(
*,
request: Union[Request, WebSocket],
@@ -531,13 +552,8 @@ async def solve_dependencies(
dependency_overrides_provider: Optional[Any] = None,
dependency_cache: Optional[Dict[Tuple[Callable[..., Any], Tuple[str]], Any]] = None,
async_exit_stack: AsyncExitStack,
-) -> Tuple[
- Dict[str, Any],
- List[Any],
- Optional[StarletteBackgroundTasks],
- Response,
- Dict[Tuple[Callable[..., Any], Tuple[str]], Any],
-]:
+ embed_body_fields: bool,
+) -> SolvedDependency:
values: Dict[str, Any] = {}
errors: List[Any] = []
if response is None:
@@ -578,28 +594,23 @@ async def solve_dependencies(
dependency_overrides_provider=dependency_overrides_provider,
dependency_cache=dependency_cache,
async_exit_stack=async_exit_stack,
+ embed_body_fields=embed_body_fields,
)
- (
- sub_values,
- sub_errors,
- background_tasks,
- _, # the subdependency returns the same response we have
- sub_dependency_cache,
- ) = solved_result
- dependency_cache.update(sub_dependency_cache)
- if sub_errors:
- errors.extend(sub_errors)
+ background_tasks = solved_result.background_tasks
+ dependency_cache.update(solved_result.dependency_cache)
+ if solved_result.errors:
+ errors.extend(solved_result.errors)
continue
if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache:
solved = dependency_cache[sub_dependant.cache_key]
elif is_gen_callable(call) or is_async_gen_callable(call):
solved = await solve_generator(
- call=call, stack=async_exit_stack, sub_values=sub_values
+ call=call, stack=async_exit_stack, sub_values=solved_result.values
)
elif is_coroutine_callable(call):
- solved = await call(**sub_values)
+ solved = await call(**solved_result.values)
else:
- solved = await run_in_threadpool(call, **sub_values)
+ solved = await run_in_threadpool(call, **solved_result.values)
if sub_dependant.name is not None:
values[sub_dependant.name] = solved
if sub_dependant.cache_key not in dependency_cache:
@@ -626,7 +637,9 @@ async def solve_dependencies(
body_values,
body_errors,
) = await request_body_to_args( # body_params checked above
- required_params=dependant.body_params, received_body=body
+ body_fields=dependant.body_params,
+ received_body=body,
+ embed_body_fields=embed_body_fields,
)
values.update(body_values)
errors.extend(body_errors)
@@ -646,142 +659,206 @@ async def solve_dependencies(
values[dependant.security_scopes_param_name] = SecurityScopes(
scopes=dependant.security_scopes
)
- return values, errors, background_tasks, response, dependency_cache
+ return SolvedDependency(
+ values=values,
+ errors=errors,
+ background_tasks=background_tasks,
+ response=response,
+ dependency_cache=dependency_cache,
+ )
+
+
+def _validate_value_with_model_field(
+ *, field: ModelField, value: Any, values: Dict[str, Any], loc: Tuple[str, ...]
+) -> Tuple[Any, List[Any]]:
+ if value is None:
+ if field.required:
+ return None, [get_missing_field_error(loc=loc)]
+ else:
+ return deepcopy(field.default), []
+ v_, errors_ = field.validate(value, values, loc=loc)
+ if isinstance(errors_, ErrorWrapper):
+ return None, [errors_]
+ elif isinstance(errors_, list):
+ new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
+ return None, new_errors
+ else:
+ return v_, []
+
+
+def _get_multidict_value(field: ModelField, values: Mapping[str, Any]) -> Any:
+ if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)):
+ value = values.getlist(field.alias)
+ else:
+ value = values.get(field.alias, None)
+ if (
+ value is None
+ or (
+ isinstance(field.field_info, params.Form)
+ and isinstance(value, str) # For type checks
+ and value == ""
+ )
+ or (is_sequence_field(field) and len(value) == 0)
+ ):
+ if field.required:
+ return
+ else:
+ return deepcopy(field.default)
+ return value
def request_params_to_args(
- required_params: Sequence[ModelField],
+ fields: Sequence[ModelField],
received_params: Union[Mapping[str, Any], QueryParams, Headers],
) -> Tuple[Dict[str, Any], List[Any]]:
- values = {}
+ values: Dict[str, Any] = {}
errors = []
- for field in required_params:
- if is_scalar_sequence_field(field) and isinstance(
- received_params, (QueryParams, Headers)
- ):
- value = received_params.getlist(field.alias) or field.default
- else:
- value = received_params.get(field.alias)
+ for field in fields:
+ value = _get_multidict_value(field, received_params)
field_info = field.field_info
assert isinstance(
field_info, params.Param
), "Params must be subclasses of Param"
loc = (field_info.in_.value, field.alias)
- if value is None:
- if field.required:
- errors.append(get_missing_field_error(loc=loc))
- else:
- values[field.name] = deepcopy(field.default)
- continue
- v_, errors_ = field.validate(value, values, loc=loc)
- if isinstance(errors_, ErrorWrapper):
- errors.append(errors_)
- elif isinstance(errors_, list):
- new_errors = _regenerate_error_with_loc(errors=errors_, loc_prefix=())
- errors.extend(new_errors)
+ v_, errors_ = _validate_value_with_model_field(
+ field=field, value=value, values=values, loc=loc
+ )
+ if errors_:
+ errors.extend(errors_)
else:
values[field.name] = v_
return values, errors
+def _should_embed_body_fields(fields: List[ModelField]) -> bool:
+ if not fields:
+ return False
+ # More than one dependency could have the same field, it would show up as multiple
+ # fields but it's the same one, so count them by name
+ body_param_names_set = {field.name for field in fields}
+ # A top level field has to be a single field, not multiple
+ if len(body_param_names_set) > 1:
+ return True
+ first_field = fields[0]
+ # If it explicitly specifies it is embedded, it has to be embedded
+ if getattr(first_field.field_info, "embed", None):
+ 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) and not lenient_issubclass(
+ first_field.type_, BaseModel
+ ):
+ return True
+ return False
+
+
+async def _extract_form_body(
+ body_fields: List[ModelField],
+ received_body: FormData,
+) -> Dict[str, Any]:
+ values = {}
+ first_field = body_fields[0]
+ first_field_info = first_field.field_info
+
+ for field in body_fields:
+ value = _get_multidict_value(field, received_body)
+ if (
+ isinstance(first_field_info, params.File)
+ and is_bytes_field(field)
+ and isinstance(value, UploadFile)
+ ):
+ value = await value.read()
+ elif (
+ is_bytes_sequence_field(field)
+ and isinstance(first_field_info, params.File)
+ and value_is_sequence(value)
+ ):
+ # For types
+ assert isinstance(value, sequence_types) # type: ignore[arg-type]
+ results: List[Union[bytes, str]] = []
+
+ async def process_fn(
+ fn: Callable[[], Coroutine[Any, Any, Any]],
+ ) -> None:
+ result = await fn()
+ results.append(result) # noqa: B023
+
+ async with anyio.create_task_group() as tg:
+ for sub_value in value:
+ tg.start_soon(process_fn, sub_value.read)
+ value = serialize_sequence_value(field=field, value=results)
+ if value is not None:
+ values[field.name] = value
+ for key, value in received_body.items():
+ if key not in values:
+ values[key] = value
+ return values
+
+
async def request_body_to_args(
- required_params: List[ModelField],
+ body_fields: List[ModelField],
received_body: Optional[Union[Dict[str, Any], FormData]],
+ embed_body_fields: bool,
) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
- values = {}
+ values: Dict[str, Any] = {}
errors: List[Dict[str, Any]] = []
- if required_params:
- field = required_params[0]
- field_info = field.field_info
- embed = getattr(field_info, "embed", None)
- field_alias_omitted = len(required_params) == 1 and not embed
- if field_alias_omitted:
- received_body = {field.alias: received_body}
-
- for field in required_params:
- loc: Tuple[str, ...]
- if field_alias_omitted:
- loc = ("body",)
- else:
- loc = ("body", field.alias)
-
- value: Optional[Any] = None
- if received_body is not None:
- if (is_sequence_field(field)) and isinstance(received_body, FormData):
- value = received_body.getlist(field.alias)
- else:
- try:
- value = received_body.get(field.alias)
- except AttributeError:
- errors.append(get_missing_field_error(loc))
- continue
- if (
- value is None
- or (isinstance(field_info, params.Form) and value == "")
- or (
- isinstance(field_info, params.Form)
- and is_sequence_field(field)
- and len(value) == 0
- )
- ):
- if field.required:
- errors.append(get_missing_field_error(loc))
- else:
- values[field.name] = deepcopy(field.default)
+ assert body_fields, "request_body_to_args() should be called with fields"
+ 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(fields_to_extract, received_body)
+
+ if single_not_embedded_field:
+ loc: Tuple[str, ...] = ("body",)
+ v_, errors_ = _validate_value_with_model_field(
+ field=first_field, value=body_to_process, values=values, loc=loc
+ )
+ return {first_field.name: v_}, errors_
+ for field in body_fields:
+ loc = ("body", field.alias)
+ value: Optional[Any] = None
+ if body_to_process is not None:
+ try:
+ value = body_to_process.get(field.alias)
+ # If the received body is a list, not a dict
+ except AttributeError:
+ errors.append(get_missing_field_error(loc))
continue
- if (
- isinstance(field_info, params.File)
- and is_bytes_field(field)
- and isinstance(value, UploadFile)
- ):
- value = await value.read()
- elif (
- is_bytes_sequence_field(field)
- and isinstance(field_info, params.File)
- and value_is_sequence(value)
- ):
- # For types
- assert isinstance(value, sequence_types) # type: ignore[arg-type]
- results: List[Union[bytes, str]] = []
-
- async def process_fn(
- fn: Callable[[], Coroutine[Any, Any, Any]],
- ) -> None:
- result = await fn()
- results.append(result) # noqa: B023
-
- async with anyio.create_task_group() as tg:
- for sub_value in value:
- tg.start_soon(process_fn, sub_value.read)
- value = serialize_sequence_value(field=field, value=results)
-
- v_, errors_ = field.validate(value, values, loc=loc)
-
- if isinstance(errors_, list):
- errors.extend(errors_)
- elif errors_:
- errors.append(errors_)
- else:
- values[field.name] = v_
+ v_, errors_ = _validate_value_with_model_field(
+ field=field, value=value, values=values, loc=loc
+ )
+ if errors_:
+ errors.extend(errors_)
+ else:
+ values[field.name] = v_
return values, errors
-def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]:
- flat_dependant = get_flat_dependant(dependant)
+def get_body_field(
+ *, flat_dependant: Dependant, name: str, embed_body_fields: bool
+) -> Optional[ModelField]:
+ """
+ Get a ModelField representing the request body for a path operation, combining
+ all body parameters into a single field if necessary.
+
+ Used to check if it's form data (with `isinstance(body_field, params.Form)`)
+ or JSON and to generate the JSON Schema for a request body.
+
+ This is **not** used to validate/parse the request body, that's done with each
+ individual body parameter.
+ """
if not flat_dependant.body_params:
return None
first_param = flat_dependant.body_params[0]
- field_info = first_param.field_info
- embed = getattr(field_info, "embed", None)
- body_param_names_set = {param.name for param in flat_dependant.body_params}
- if len(body_param_names_set) == 1 and not embed:
- check_file_field(first_param)
+ if not embed_body_fields:
return first_param
- # If one field requires to embed, all have to be embedded
- # in case a sub-dependency is evaluated with a single unique body field
- # That is combined (embedded) with other body fields
- for param in flat_dependant.body_params:
- setattr(param.field_info, "embed", True) # noqa: B010
model_name = "Body_" + name
BodyModel = create_body_model(
fields=flat_dependant.body_params, model_name=model_name
@@ -807,12 +884,11 @@ def get_body_field(*, dependant: Dependant, name: str) -> Optional[ModelField]:
]
if len(set(body_param_media_types)) == 1:
BodyFieldInfo_kwargs["media_type"] = body_param_media_types[0]
- final_field = create_response_field(
+ final_field = create_model_field(
name="body",
type_=BodyModel,
required=required,
alias="body",
field_info=BodyFieldInfo(**BodyFieldInfo_kwargs),
)
- check_file_field(final_field)
return final_field
diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py
index 0d5f27af4..7ddaace25 100644
--- a/fastapi/param_functions.py
+++ b/fastapi/param_functions.py
@@ -1282,7 +1282,7 @@ def Body( # noqa: N802
),
] = _Unset,
embed: Annotated[
- bool,
+ Union[bool, None],
Doc(
"""
When `embed` is `True`, the parameter will be expected in a JSON body as a
@@ -1294,7 +1294,7 @@ def Body( # noqa: N802
[FastAPI docs for Body - Multiple Parameters](https://fastapi.tiangolo.com/tutorial/body-multiple-params/#embed-a-single-body-parameter).
"""
),
- ] = False,
+ ] = None,
media_type: Annotated[
str,
Doc(
diff --git a/fastapi/params.py b/fastapi/params.py
index cc2a5c13c..90ca7cb01 100644
--- a/fastapi/params.py
+++ b/fastapi/params.py
@@ -479,7 +479,7 @@ class Body(FieldInfo):
*,
default_factory: Union[Callable[[], Any], None] = _Unset,
annotation: Optional[Any] = None,
- embed: bool = False,
+ embed: Union[bool, None] = None,
media_type: str = "application/json",
alias: Optional[str] = None,
alias_priority: Union[int, None] = _Unset,
@@ -556,7 +556,7 @@ class Body(FieldInfo):
kwargs["examples"] = examples
if regex is not None:
warnings.warn(
- "`regex` has been depreacated, please use `pattern` instead",
+ "`regex` has been deprecated, please use `pattern` instead",
category=DeprecationWarning,
stacklevel=4,
)
@@ -642,7 +642,6 @@ class Form(Body):
default=default,
default_factory=default_factory,
annotation=annotation,
- embed=True,
media_type=media_type,
alias=alias,
alias_priority=alias_priority,
diff --git a/fastapi/routing.py b/fastapi/routing.py
index 49f1b6013..86e303602 100644
--- a/fastapi/routing.py
+++ b/fastapi/routing.py
@@ -33,8 +33,10 @@ from fastapi._compat import (
from fastapi.datastructures import Default, DefaultPlaceholder
from fastapi.dependencies.models import Dependant
from fastapi.dependencies.utils import (
+ _should_embed_body_fields,
get_body_field,
get_dependant,
+ get_flat_dependant,
get_parameterless_sub_dependant,
get_typed_return_annotation,
solve_dependencies,
@@ -49,7 +51,7 @@ from fastapi.exceptions import (
from fastapi.types import DecoratedCallable, IncEx
from fastapi.utils import (
create_cloned_field,
- create_response_field,
+ create_model_field,
generate_unique_id,
get_value_or_default,
is_body_allowed_for_status_code,
@@ -225,6 +227,7 @@ def get_request_handler(
response_model_exclude_defaults: bool = False,
response_model_exclude_none: bool = False,
dependency_overrides_provider: Optional[Any] = None,
+ embed_body_fields: bool = False,
) -> Callable[[Request], Coroutine[Any, Any, Response]]:
assert dependant.call is not None, "dependant.call must be a function"
is_coroutine = asyncio.iscoroutinefunction(dependant.call)
@@ -291,27 +294,36 @@ def get_request_handler(
body=body,
dependency_overrides_provider=dependency_overrides_provider,
async_exit_stack=async_exit_stack,
+ embed_body_fields=embed_body_fields,
)
- values, errors, background_tasks, sub_response, _ = solved_result
+ errors = solved_result.errors
if not errors:
raw_response = await run_endpoint_function(
- dependant=dependant, values=values, is_coroutine=is_coroutine
+ dependant=dependant,
+ values=solved_result.values,
+ is_coroutine=is_coroutine,
)
if isinstance(raw_response, Response):
if raw_response.background is None:
- raw_response.background = background_tasks
+ raw_response.background = solved_result.background_tasks
response = raw_response
else:
- response_args: Dict[str, Any] = {"background": background_tasks}
+ response_args: Dict[str, Any] = {
+ "background": solved_result.background_tasks
+ }
# If status_code was set, use it, otherwise use the default from the
# response class, in the case of redirect it's 307
current_status_code = (
- status_code if status_code else sub_response.status_code
+ status_code
+ if status_code
+ else solved_result.response.status_code
)
if current_status_code is not None:
response_args["status_code"] = current_status_code
- if sub_response.status_code:
- response_args["status_code"] = sub_response.status_code
+ if solved_result.response.status_code:
+ response_args["status_code"] = (
+ solved_result.response.status_code
+ )
content = await serialize_response(
field=response_field,
response_content=raw_response,
@@ -326,7 +338,7 @@ def get_request_handler(
response = actual_response_class(content, **response_args)
if not is_body_allowed_for_status_code(response.status_code):
response.body = b""
- response.headers.raw.extend(sub_response.headers.raw)
+ response.headers.raw.extend(solved_result.response.headers.raw)
if errors:
validation_error = RequestValidationError(
_normalize_errors(errors), body=body
@@ -346,7 +358,9 @@ def get_request_handler(
def get_websocket_app(
- dependant: Dependant, dependency_overrides_provider: Optional[Any] = None
+ dependant: Dependant,
+ dependency_overrides_provider: Optional[Any] = None,
+ embed_body_fields: bool = False,
) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]:
async def app(websocket: WebSocket) -> None:
async with AsyncExitStack() as async_exit_stack:
@@ -359,12 +373,14 @@ def get_websocket_app(
dependant=dependant,
dependency_overrides_provider=dependency_overrides_provider,
async_exit_stack=async_exit_stack,
+ embed_body_fields=embed_body_fields,
)
- values, errors, _, _2, _3 = solved_result
- if errors:
- raise WebSocketRequestValidationError(_normalize_errors(errors))
+ if solved_result.errors:
+ raise WebSocketRequestValidationError(
+ _normalize_errors(solved_result.errors)
+ )
assert dependant.call is not None, "dependant.call must be a function"
- await dependant.call(**values)
+ await dependant.call(**solved_result.values)
return app
@@ -390,11 +406,15 @@ class APIWebSocketRoute(routing.WebSocketRoute):
0,
get_parameterless_sub_dependant(depends=depends, path=self.path_format),
)
-
+ self._flat_dependant = get_flat_dependant(self.dependant)
+ self._embed_body_fields = _should_embed_body_fields(
+ self._flat_dependant.body_params
+ )
self.app = websocket_session(
get_websocket_app(
dependant=self.dependant,
dependency_overrides_provider=dependency_overrides_provider,
+ embed_body_fields=self._embed_body_fields,
)
)
@@ -488,7 +508,7 @@ class APIRoute(routing.Route):
status_code
), f"Status code {status_code} must not have a response body"
response_name = "Response_" + self.unique_id
- self.response_field = create_response_field(
+ self.response_field = create_model_field(
name=response_name,
type_=self.response_model,
mode="serialization",
@@ -521,7 +541,7 @@ class APIRoute(routing.Route):
additional_status_code
), f"Status code {additional_status_code} must not have a response body"
response_name = f"Response_{additional_status_code}_{self.unique_id}"
- response_field = create_response_field(name=response_name, type_=model)
+ response_field = create_model_field(name=response_name, type_=model)
response_fields[additional_status_code] = response_field
if response_fields:
self.response_fields: Dict[Union[int, str], ModelField] = response_fields
@@ -535,7 +555,15 @@ class APIRoute(routing.Route):
0,
get_parameterless_sub_dependant(depends=depends, path=self.path_format),
)
- self.body_field = get_body_field(dependant=self.dependant, name=self.unique_id)
+ self._flat_dependant = get_flat_dependant(self.dependant)
+ self._embed_body_fields = _should_embed_body_fields(
+ self._flat_dependant.body_params
+ )
+ self.body_field = get_body_field(
+ flat_dependant=self._flat_dependant,
+ name=self.unique_id,
+ embed_body_fields=self._embed_body_fields,
+ )
self.app = request_response(self.get_route_handler())
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
@@ -552,6 +580,7 @@ class APIRoute(routing.Route):
response_model_exclude_defaults=self.response_model_exclude_defaults,
response_model_exclude_none=self.response_model_exclude_none,
dependency_overrides_provider=self.dependency_overrides_provider,
+ embed_body_fields=self._embed_body_fields,
)
def matches(self, scope: Scope) -> Tuple[Match, Scope]:
diff --git a/fastapi/utils.py b/fastapi/utils.py
index 5c2538fac..4c7350fea 100644
--- a/fastapi/utils.py
+++ b/fastapi/utils.py
@@ -60,9 +60,9 @@ def get_path_param_names(path: str) -> Set[str]:
return set(re.findall("{(.*?)}", path))
-def create_response_field(
+def create_model_field(
name: str,
- type_: Type[Any],
+ type_: Any,
class_validators: Optional[Dict[str, Validator]] = None,
default: Optional[Any] = Undefined,
required: Union[bool, UndefinedType] = Undefined,
@@ -71,9 +71,6 @@ def create_response_field(
alias: Optional[str] = None,
mode: Literal["validation", "serialization"] = "validation",
) -> ModelField:
- """
- Create a new response field. Raises if type_ is invalid.
- """
class_validators = class_validators or {}
if PYDANTIC_V2:
field_info = field_info or FieldInfo(
@@ -135,7 +132,7 @@ def create_cloned_field(
use_type.__fields__[f.name] = create_cloned_field(
f, cloned_types=cloned_types
)
- new_field = create_response_field(name=field.name, type_=use_type)
+ new_field = create_model_field(name=field.name, type_=use_type)
new_field.has_alias = field.has_alias # type: ignore[attr-defined]
new_field.alias = field.alias # type: ignore[misc]
new_field.class_validators = field.class_validators # type: ignore[attr-defined]
diff --git a/requirements-docs.txt b/requirements-docs.txt
index ab2b0165b..332fd1857 100644
--- a/requirements-docs.txt
+++ b/requirements-docs.txt
@@ -8,7 +8,7 @@ pyyaml >=5.3.1,<7.0.0
# For Material for MkDocs, Chinese search
jieba==0.42.1
# For image processing by Material for MkDocs
-pillow==10.3.0
+pillow==10.4.0
# For image processing by Material for MkDocs
cairosvg==2.7.1
mkdocstrings[python]==0.25.1
diff --git a/requirements-tests.txt b/requirements-tests.txt
index 08561d23a..de5fdb8a2 100644
--- a/requirements-tests.txt
+++ b/requirements-tests.txt
@@ -3,7 +3,7 @@
pytest >=7.1.3,<8.0.0
coverage[toml] >= 6.5.0,< 8.0
mypy ==1.8.0
-ruff ==0.6.1
+ruff ==0.6.3
dirty-equals ==0.6.0
# TODO: once removing databases from tutorial, upgrade SQLAlchemy
# probably when including SQLModel
diff --git a/scripts/playwright/request_form_models/image01.py b/scripts/playwright/request_form_models/image01.py
new file mode 100644
index 000000000..15bd3858c
--- /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_compat.py b/tests/test_compat.py
index bf268b860..270475bf3 100644
--- a/tests/test_compat.py
+++ b/tests/test_compat.py
@@ -1,11 +1,13 @@
-from typing import List, Union
+from typing import Any, Dict, List, Union
from fastapi import FastAPI, UploadFile
from fastapi._compat import (
ModelField,
Undefined,
_get_model_config,
+ get_model_fields,
is_bytes_sequence_annotation,
+ is_scalar_field,
is_uploadfile_sequence_annotation,
)
from fastapi.testclient import TestClient
@@ -91,3 +93,12 @@ def test_is_uploadfile_sequence_annotation():
# and other types, but I'm not even sure it's a good idea to support it as a first
# class "feature"
assert is_uploadfile_sequence_annotation(Union[List[str], List[UploadFile]])
+
+
+def test_is_pv1_scalar_field():
+ # For coverage
+ class Model(BaseModel):
+ foo: Union[str, Dict[str, Any]]
+
+ fields = get_model_fields(Model)
+ assert not is_scalar_field(fields[0])
diff --git a/tests/test_forms_single_model.py b/tests/test_forms_single_model.py
new file mode 100644
index 000000000..7ed3ba3a2
--- /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_forms_single_param.py b/tests/test_forms_single_param.py
new file mode 100644
index 000000000..3bb951441
--- /dev/null
+++ b/tests/test_forms_single_param.py
@@ -0,0 +1,99 @@
+from fastapi import FastAPI, Form
+from fastapi.testclient import TestClient
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+
+@app.post("/form/")
+def post_form(username: Annotated[str, Form()]):
+ return username
+
+
+client = TestClient(app)
+
+
+def test_single_form_field():
+ response = client.post("/form/", data={"username": "Rick"})
+ assert response.status_code == 200, response.text
+ assert response.json() == "Rick"
+
+
+def test_openapi_schema():
+ 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": {
+ "/form/": {
+ "post": {
+ "summary": "Post Form",
+ "operationId": "post_form_form__post",
+ "requestBody": {
+ "content": {
+ "application/x-www-form-urlencoded": {
+ "schema": {
+ "$ref": "#/components/schemas/Body_post_form_form__post"
+ }
+ }
+ },
+ "required": True,
+ },
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ },
+ },
+ }
+ }
+ },
+ "components": {
+ "schemas": {
+ "Body_post_form_form__post": {
+ "properties": {"username": {"type": "string", "title": "Username"}},
+ "type": "object",
+ "required": ["username"],
+ "title": "Body_post_form_form__post",
+ },
+ "HTTPValidationError": {
+ "properties": {
+ "detail": {
+ "items": {"$ref": "#/components/schemas/ValidationError"},
+ "type": "array",
+ "title": "Detail",
+ }
+ },
+ "type": "object",
+ "title": "HTTPValidationError",
+ },
+ "ValidationError": {
+ "properties": {
+ "loc": {
+ "items": {
+ "anyOf": [{"type": "string"}, {"type": "integer"}]
+ },
+ "type": "array",
+ "title": "Location",
+ },
+ "msg": {"type": "string", "title": "Message"},
+ "type": {"type": "string", "title": "Error Type"},
+ },
+ "type": "object",
+ "required": ["loc", "msg", "type"],
+ "title": "ValidationError",
+ },
+ }
+ },
+ }
diff --git a/tests/test_openapi_examples.py b/tests/test_openapi_examples.py
index 6597e5058..b3f83ae23 100644
--- a/tests/test_openapi_examples.py
+++ b/tests/test_openapi_examples.py
@@ -155,13 +155,26 @@ def test_openapi_schema():
"requestBody": {
"content": {
"application/json": {
- "schema": {
- "allOf": [{"$ref": "#/components/schemas/Item"}],
- "title": "Item",
- "examples": [
- {"data": "Data in Body examples, example1"}
- ],
- },
+ "schema": IsDict(
+ {
+ "$ref": "#/components/schemas/Item",
+ "examples": [
+ {"data": "Data in Body examples, example1"}
+ ],
+ }
+ )
+ | IsDict(
+ {
+ # TODO: remove when deprecating Pydantic v1
+ "allOf": [
+ {"$ref": "#/components/schemas/Item"}
+ ],
+ "title": "Item",
+ "examples": [
+ {"data": "Data in Body examples, example1"}
+ ],
+ }
+ ),
"examples": {
"Example One": {
"summary": "Example One Summary",
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 000000000..e69de29bb
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 000000000..46c130ee8
--- /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 000000000..4e14d89c8
--- /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 000000000..2e6426aa7
--- /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"},
+ }
+ },
+ },
+ }
+ },
+ }
diff --git a/tests/test_tutorial/test_request_form_models/test_tutorial002.py b/tests/test_tutorial/test_request_form_models/test_tutorial002.py
new file mode 100644
index 000000000..76f480001
--- /dev/null
+++ b/tests/test_tutorial/test_request_form_models/test_tutorial002.py
@@ -0,0 +1,196 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client():
+ from docs_src.request_form_models.tutorial002 import app
+
+ client = TestClient(app)
+ return client
+
+
+@needs_pydanticv2
+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_pydanticv2
+def test_post_body_extra_form(client: TestClient):
+ response = client.post(
+ "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+ )
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "extra_forbidden",
+ "loc": ["body", "extra"],
+ "msg": "Extra inputs are not permitted",
+ "input": "extra",
+ }
+ ]
+ }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_password(client: TestClient):
+ response = client.post("/login/", data={"username": "Foo"})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "password"],
+ "msg": "Field required",
+ "input": {"username": "Foo"},
+ }
+ ]
+ }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_username(client: TestClient):
+ response = client.post("/login/", data={"password": "secret"})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "username"],
+ "msg": "Field required",
+ "input": {"password": "secret"},
+ }
+ ]
+ }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_data(client: TestClient):
+ response = client.post("/login/")
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "username"],
+ "msg": "Field required",
+ "input": {},
+ },
+ {
+ "type": "missing",
+ "loc": ["body", "password"],
+ "msg": "Field required",
+ "input": {},
+ },
+ ]
+ }
+
+
+@needs_pydanticv2
+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() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "username"],
+ "msg": "Field required",
+ "input": {},
+ },
+ {
+ "type": "missing",
+ "loc": ["body", "password"],
+ "msg": "Field required",
+ "input": {},
+ },
+ ]
+ }
+
+
+@needs_pydanticv2
+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"},
+ },
+ "additionalProperties": False,
+ "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_tutorial002_an.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_an.py
new file mode 100644
index 000000000..179b2977d
--- /dev/null
+++ b/tests/test_tutorial/test_request_form_models/test_tutorial002_an.py
@@ -0,0 +1,196 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client():
+ from docs_src.request_form_models.tutorial002_an import app
+
+ client = TestClient(app)
+ return client
+
+
+@needs_pydanticv2
+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_pydanticv2
+def test_post_body_extra_form(client: TestClient):
+ response = client.post(
+ "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+ )
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "extra_forbidden",
+ "loc": ["body", "extra"],
+ "msg": "Extra inputs are not permitted",
+ "input": "extra",
+ }
+ ]
+ }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_password(client: TestClient):
+ response = client.post("/login/", data={"username": "Foo"})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "password"],
+ "msg": "Field required",
+ "input": {"username": "Foo"},
+ }
+ ]
+ }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_username(client: TestClient):
+ response = client.post("/login/", data={"password": "secret"})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "username"],
+ "msg": "Field required",
+ "input": {"password": "secret"},
+ }
+ ]
+ }
+
+
+@needs_pydanticv2
+def test_post_body_form_no_data(client: TestClient):
+ response = client.post("/login/")
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "username"],
+ "msg": "Field required",
+ "input": {},
+ },
+ {
+ "type": "missing",
+ "loc": ["body", "password"],
+ "msg": "Field required",
+ "input": {},
+ },
+ ]
+ }
+
+
+@needs_pydanticv2
+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() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "username"],
+ "msg": "Field required",
+ "input": {},
+ },
+ {
+ "type": "missing",
+ "loc": ["body", "password"],
+ "msg": "Field required",
+ "input": {},
+ },
+ ]
+ }
+
+
+@needs_pydanticv2
+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"},
+ },
+ "additionalProperties": False,
+ "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_tutorial002_an_py39.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_an_py39.py
new file mode 100644
index 000000000..510ad9d7c
--- /dev/null
+++ b/tests/test_tutorial/test_request_form_models/test_tutorial002_an_py39.py
@@ -0,0 +1,203 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_py39, needs_pydanticv2
+
+
+@pytest.fixture(name="client")
+def get_client():
+ from docs_src.request_form_models.tutorial002_an_py39 import app
+
+ client = TestClient(app)
+ return client
+
+
+@needs_pydanticv2
+@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_pydanticv2
+@needs_py39
+def test_post_body_extra_form(client: TestClient):
+ response = client.post(
+ "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+ )
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "extra_forbidden",
+ "loc": ["body", "extra"],
+ "msg": "Extra inputs are not permitted",
+ "input": "extra",
+ }
+ ]
+ }
+
+
+@needs_pydanticv2
+@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() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "password"],
+ "msg": "Field required",
+ "input": {"username": "Foo"},
+ }
+ ]
+ }
+
+
+@needs_pydanticv2
+@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() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "username"],
+ "msg": "Field required",
+ "input": {"password": "secret"},
+ }
+ ]
+ }
+
+
+@needs_pydanticv2
+@needs_py39
+def test_post_body_form_no_data(client: TestClient):
+ response = client.post("/login/")
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "username"],
+ "msg": "Field required",
+ "input": {},
+ },
+ {
+ "type": "missing",
+ "loc": ["body", "password"],
+ "msg": "Field required",
+ "input": {},
+ },
+ ]
+ }
+
+
+@needs_pydanticv2
+@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() == {
+ "detail": [
+ {
+ "type": "missing",
+ "loc": ["body", "username"],
+ "msg": "Field required",
+ "input": {},
+ },
+ {
+ "type": "missing",
+ "loc": ["body", "password"],
+ "msg": "Field required",
+ "input": {},
+ },
+ ]
+ }
+
+
+@needs_pydanticv2
+@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"},
+ },
+ "additionalProperties": False,
+ "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_tutorial002_pv1.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1.py
new file mode 100644
index 000000000..249b9379d
--- /dev/null
+++ b/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1.py
@@ -0,0 +1,189 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_pydanticv1
+
+
+@pytest.fixture(name="client")
+def get_client():
+ from docs_src.request_form_models.tutorial002_pv1 import app
+
+ client = TestClient(app)
+ return client
+
+
+@needs_pydanticv1
+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_pydanticv1
+def test_post_body_extra_form(client: TestClient):
+ response = client.post(
+ "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+ )
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.extra",
+ "loc": ["body", "extra"],
+ "msg": "extra fields not permitted",
+ }
+ ]
+ }
+
+
+@needs_pydanticv1
+def test_post_body_form_no_password(client: TestClient):
+ response = client.post("/login/", data={"username": "Foo"})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "password"],
+ "msg": "field required",
+ }
+ ]
+ }
+
+
+@needs_pydanticv1
+def test_post_body_form_no_username(client: TestClient):
+ response = client.post("/login/", data={"password": "secret"})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "username"],
+ "msg": "field required",
+ }
+ ]
+ }
+
+
+@needs_pydanticv1
+def test_post_body_form_no_data(client: TestClient):
+ response = client.post("/login/")
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "username"],
+ "msg": "field required",
+ },
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "password"],
+ "msg": "field required",
+ },
+ ]
+ }
+
+
+@needs_pydanticv1
+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() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "username"],
+ "msg": "field required",
+ },
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "password"],
+ "msg": "field required",
+ },
+ ]
+ }
+
+
+@needs_pydanticv1
+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"},
+ },
+ "additionalProperties": False,
+ "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_tutorial002_pv1_an.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an.py
new file mode 100644
index 000000000..44cb3c32b
--- /dev/null
+++ b/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an.py
@@ -0,0 +1,196 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_pydanticv1
+
+
+@pytest.fixture(name="client")
+def get_client():
+ from docs_src.request_form_models.tutorial002_pv1_an import app
+
+ client = TestClient(app)
+ return client
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+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"}
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_extra_form(client: TestClient):
+ response = client.post(
+ "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+ )
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.extra",
+ "loc": ["body", "extra"],
+ "msg": "extra fields not permitted",
+ }
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_form_no_password(client: TestClient):
+ response = client.post("/login/", data={"username": "Foo"})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "password"],
+ "msg": "field required",
+ }
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_form_no_username(client: TestClient):
+ response = client.post("/login/", data={"password": "secret"})
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "username"],
+ "msg": "field required",
+ }
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+def test_post_body_form_no_data(client: TestClient):
+ response = client.post("/login/")
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "username"],
+ "msg": "field required",
+ },
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "password"],
+ "msg": "field required",
+ },
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+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() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "username"],
+ "msg": "field required",
+ },
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "password"],
+ "msg": "field required",
+ },
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+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"},
+ },
+ "additionalProperties": False,
+ "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_tutorial002_pv1_an_p39.py b/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an_p39.py
new file mode 100644
index 000000000..899549e40
--- /dev/null
+++ b/tests/test_tutorial/test_request_form_models/test_tutorial002_pv1_an_p39.py
@@ -0,0 +1,203 @@
+import pytest
+from fastapi.testclient import TestClient
+
+from tests.utils import needs_py39, needs_pydanticv1
+
+
+@pytest.fixture(name="client")
+def get_client():
+ from docs_src.request_form_models.tutorial002_pv1_an_py39 import app
+
+ client = TestClient(app)
+ return client
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@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"}
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@needs_py39
+def test_post_body_extra_form(client: TestClient):
+ response = client.post(
+ "/login/", data={"username": "Foo", "password": "secret", "extra": "extra"}
+ )
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.extra",
+ "loc": ["body", "extra"],
+ "msg": "extra fields not permitted",
+ }
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@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() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "password"],
+ "msg": "field required",
+ }
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@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() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "username"],
+ "msg": "field required",
+ }
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@needs_py39
+def test_post_body_form_no_data(client: TestClient):
+ response = client.post("/login/")
+ assert response.status_code == 422
+ assert response.json() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "username"],
+ "msg": "field required",
+ },
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "password"],
+ "msg": "field required",
+ },
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@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() == {
+ "detail": [
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "username"],
+ "msg": "field required",
+ },
+ {
+ "type": "value_error.missing",
+ "loc": ["body", "password"],
+ "msg": "field required",
+ },
+ ]
+ }
+
+
+# TODO: remove when deprecating Pydantic v1
+@needs_pydanticv1
+@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"},
+ },
+ "additionalProperties": False,
+ "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"},
+ }
+ },
+ },
+ }
+ },
+ }