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`: + +
+ +
+ +## Forbid Extra Form Fields + +In some special use cases (probably not very common), you might want to **restrict** the form fields to only those declared in the Pydantic model. And **forbid** any **extra** fields. + +/// note + +This is supported since FastAPI version `0.114.0`. 🤓 + +/// + +You can use Pydantic's model configuration to `forbid` any `extra` fields: + +//// tab | Python 3.9+ + +```Python hl_lines="12" +{!> ../../../docs_src/request_form_models/tutorial002_an_py39.py!} +``` + +//// + +//// tab | Python 3.8+ + +```Python hl_lines="11" +{!> ../../../docs_src/request_form_models/tutorial002_an.py!} +``` + +//// + +//// tab | Python 3.8+ non-Annotated + +/// tip + +Prefer to use the `Annotated` version if possible. + +/// + +```Python hl_lines="10" +{!> ../../../docs_src/request_form_models/tutorial002.py!} +``` + +//// + +If a client tries to send some extra data, they will receive an **error** response. + +For example, if the client tries to send the form fields: + +* `username`: `Rick` +* `password`: `Portal Gun` +* `extra`: `Mr. Poopybutthole` + +They will receive an error response telling them that the field `extra` is not allowed: + +```json +{ + "detail": [ + { + "type": "extra_forbidden", + "loc": ["body", "extra"], + "msg": "Extra inputs are not permitted", + "input": "Mr. Poopybutthole" + } + ] +} +``` + +## Summary + +You can use Pydantic models to declare form fields in FastAPI. 😎 diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index 7934beac8..7892ba897 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -129,6 +129,7 @@ nav: - tutorial/extra-models.md - tutorial/response-status-code.md - tutorial/request-forms.md + - tutorial/request-form-models.md - tutorial/request-files.md - tutorial/request-forms-and-files.md - tutorial/handling-errors.md diff --git a/docs/en/overrides/main.html b/docs/en/overrides/main.html index 851f8e895..463c5af3b 100644 --- a/docs/en/overrides/main.html +++ b/docs/en/overrides/main.html @@ -59,7 +59,7 @@
- + 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. + +![Swagger UI interaction](https://fastapi.tiangolo.com/img/index/index-03-swagger-02.png) + +* Alternatieve API-documentatie met ReDoc. + +![ReDoc](https://fastapi.tiangolo.com/img/index/index-06-redoc-02.png) + +### 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: + +![editor ondersteuning](https://fastapi.tiangolo.com/img/vscode-completion.png) + +* in PyCharm: + +![editor ondersteuning](https://fastapi.tiangolo.com/img/pycharm-completion.png) + +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"}, + } + }, + }, + } + }, + }