diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8157e364b..85f9c4afd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,22 +44,45 @@ jobs: run: bash scripts/lint.sh test: - runs-on: ubuntu-latest strategy: matrix: - python-version: - - "3.14" - - "3.13" - - "3.12" - - "3.11" - - "3.10" - - "3.9" - - "3.8" - pydantic-version: ["pydantic-v1", "pydantic-v2"] - exclude: - - python-version: "3.14" + os: [ windows-latest, macos-latest ] + python-version: [ "3.14" ] + pydantic-version: [ "pydantic-v2" ] + include: + - os: macos-latest + python-version: "3.8" pydantic-version: "pydantic-v1" + - os: windows-latest + python-version: "3.8" + pydantic-version: "pydantic-v2" + coverage: coverage + - os: ubuntu-latest + python-version: "3.9" + pydantic-version: "pydantic-v1" + coverage: coverage + - os: macos-latest + python-version: "3.10" + pydantic-version: "pydantic-v2" + - os: windows-latest + python-version: "3.11" + pydantic-version: "pydantic-v1" + - os: ubuntu-latest + python-version: "3.12" + pydantic-version: "pydantic-v2" + - os: macos-latest + python-version: "3.13" + pydantic-version: "pydantic-v1" + - os: windows-latest + python-version: "3.13" + pydantic-version: "pydantic-v2" + coverage: coverage + - os: ubuntu-latest + python-version: "3.14" + pydantic-version: "pydantic-v2" + coverage: coverage fail-fast: false + runs-on: ${{ matrix.os }} steps: - name: Dump GitHub context env: @@ -96,10 +119,12 @@ jobs: env: COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }} CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }} + # Do not store coverage for all possible combinations to avoid file size max errors in Smokeshow - name: Store coverage files + if: matrix.coverage == 'coverage' uses: actions/upload-artifact@v5 with: - name: coverage-${{ matrix.python-version }}-${{ matrix.pydantic-version }} + name: coverage-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.pydantic-version }} path: coverage include-hidden-files: true diff --git a/docs/en/docs/_llm-test.md b/docs/en/docs/_llm-test.md index e72450b91..9f216f9d7 100644 --- a/docs/en/docs/_llm-test.md +++ b/docs/en/docs/_llm-test.md @@ -15,7 +15,7 @@ Use as follows: The tests: -## Code snippets { #code-snippets} +## Code snippets { #code-snippets } //// tab | Test @@ -53,7 +53,7 @@ See for example section `### Quotes` in `docs/de/llm-prompt.md`. //// -## Quotes in code snippets { #quotes-in-code-snippets} +## Quotes in code snippets { #quotes-in-code-snippets } //// tab | Test diff --git a/docs/en/docs/index.md b/docs/en/docs/index.md index df03b7675..a0a5de3b7 100644 --- a/docs/en/docs/index.md +++ b/docs/en/docs/index.md @@ -52,13 +52,13 @@ The key features are: -### Keystone Sponsor +### Keystone Sponsor { #keystone-sponsor } {% for sponsor in sponsors.keystone -%} {% endfor -%} -### Gold and Silver Sponsors +### Gold and Silver Sponsors { #gold-and-silver-sponsors } {% for sponsor in sponsors.gold -%} diff --git a/docs/en/docs/release-notes.md b/docs/en/docs/release-notes.md index bb1e95aa9..02fae0e62 100644 --- a/docs/en/docs/release-notes.md +++ b/docs/en/docs/release-notes.md @@ -7,15 +7,38 @@ hide: ## Latest Changes +## 0.124.4 + +### Fixes + +* 🐛 Fix parameter aliases. PR [#14371](https://github.com/fastapi/fastapi/pull/14371) by [@YuriiMotov](https://github.com/YuriiMotov). + +## 0.124.3 + +### Fixes + +* 🐛 Fix support for tagged union with discriminator inside of `Annotated` with `Body()`. PR [#14512](https://github.com/fastapi/fastapi/pull/14512) by [@tiangolo](https://github.com/tiangolo). + +### Refactors + +* ✅ Add set of tests for request parameters and alias. PR [#14358](https://github.com/fastapi/fastapi/pull/14358) by [@YuriiMotov](https://github.com/YuriiMotov). + ### Docs +* 📝 Tweak links format. PR [#14505](https://github.com/fastapi/fastapi/pull/14505) by [@tiangolo](https://github.com/tiangolo). * 📝 Update docs about re-raising validation errors, do not include string as is to not leak information. PR [#14487](https://github.com/fastapi/fastapi/pull/14487) by [@tiangolo](https://github.com/tiangolo). * 🔥 Remove external links section. PR [#14486](https://github.com/fastapi/fastapi/pull/14486) by [@tiangolo](https://github.com/tiangolo). ### Translations +* 🌐 Sync Russian docs. PR [#14509](https://github.com/fastapi/fastapi/pull/14509) by [@YuriiMotov](https://github.com/YuriiMotov). * 🌐 Sync German docs. PR [#14488](https://github.com/fastapi/fastapi/pull/14488) by [@nilslindemann](https://github.com/nilslindemann). +### Internal + +* 👷 Tweak coverage to not pass Smokeshow max file size limit. PR [#14507](https://github.com/fastapi/fastapi/pull/14507) by [@tiangolo](https://github.com/tiangolo). +* ✅ Expand test matrix to include Windows and MacOS. PR [#14171](https://github.com/fastapi/fastapi/pull/14171) by [@svlandeg](https://github.com/svlandeg). + ## 0.124.2 ### Fixes diff --git a/docs/ru/docs/_llm-test.md b/docs/ru/docs/_llm-test.md index 476cc1924..9a15f6bb2 100644 --- a/docs/ru/docs/_llm-test.md +++ b/docs/ru/docs/_llm-test.md @@ -15,7 +15,7 @@ Тесты: -## Фрагменты кода { #code-snippets} +## Фрагменты кода { #code-snippets } //// tab | Тест @@ -53,7 +53,7 @@ LLM, вероятно, переведёт это неправильно. Инт //// -## Кавычки во фрагментах кода { #quotes-in-code-snippets} +## Кавычки во фрагментах кода { #quotes-in-code-snippets } //// tab | Тест diff --git a/docs/ru/docs/advanced/additional-responses.md b/docs/ru/docs/advanced/additional-responses.md index c63c0c08b..1fc3715e4 100644 --- a/docs/ru/docs/advanced/additional-responses.md +++ b/docs/ru/docs/advanced/additional-responses.md @@ -175,7 +175,7 @@ Например, вы можете добавить дополнительный тип содержимого `image/png`, объявив, что ваша операция пути может возвращать JSON‑объект (с типом содержимого `application/json`) или PNG‑изображение: -{* ../../docs_src/additional_responses/tutorial002.py hl[19:24,28] *} +{* ../../docs_src/additional_responses/tutorial002_py310.py hl[17:22,26] *} /// note | Примечание @@ -237,7 +237,7 @@ new_dict = {**old_dict, "new key": "new value"} Например: -{* ../../docs_src/additional_responses/tutorial004.py hl[13:17,26] *} +{* ../../docs_src/additional_responses/tutorial004_py310.py hl[11:15,24] *} ## Дополнительная информация об ответах OpenAPI { #more-information-about-openapi-responses } diff --git a/docs/ru/docs/advanced/advanced-dependencies.md b/docs/ru/docs/advanced/advanced-dependencies.md index 339c0a363..cc6691b30 100644 --- a/docs/ru/docs/advanced/advanced-dependencies.md +++ b/docs/ru/docs/advanced/advanced-dependencies.md @@ -144,7 +144,7 @@ checker(q="somequery") ### Фоновые задачи и зависимости с `yield`, технические детали { #background-tasks-and-dependencies-with-yield-technical-details } -До FastAPI 0.106.0 вызывать исключения после `yield` было невозможно: код после `yield` в зависимостях выполнялся уже после отправки ответа, поэтому [Обработчики исключений](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank} к тому моменту уже отработали. +До FastAPI 0.106.0 вызывать исключения после `yield` было невозможно: код после `yield` в зависимостях выполнялся уже после отправки ответа, поэтому [Обработчики исключений](../tutorial/handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank} к тому моменту уже отработали. Так было сделано в основном для того, чтобы можно было использовать те же объекты, «отданные» зависимостями через `yield`, внутри фоновых задач, потому что код после `yield` выполнялся после завершения фоновых задач. diff --git a/docs/ru/docs/advanced/behind-a-proxy.md b/docs/ru/docs/advanced/behind-a-proxy.md index 281cb7f73..7119efe2d 100644 --- a/docs/ru/docs/advanced/behind-a-proxy.md +++ b/docs/ru/docs/advanced/behind-a-proxy.md @@ -64,7 +64,7 @@ https://mysuperapp.com/items/ /// -### Как работают пересылаемые заголовки прокси +### Как работают пересылаемые заголовки прокси { #how-proxy-forwarded-headers-work } Ниже показано, как прокси добавляет пересылаемые заголовки между клиентом и сервером приложения: @@ -443,6 +443,14 @@ $ fastapi run main.py --forwarded-allow-ips="*" --root-path /api/v1 /// +/// note | Технические детали + +Свойство `servers` в спецификации OpenAPI является необязательным. + +Если вы не укажете параметр `servers`, а `root_path` равен `/`, то свойство `servers` в сгенерированной схеме OpenAPI по умолчанию будет опущено. Это эквивалентно серверу со значением `url` равным `/`. + +/// + ### Отключить автоматическое добавление сервера из `root_path` { #disable-automatic-server-from-root-path } Если вы не хотите, чтобы FastAPI добавлял автоматический сервер, используя `root_path`, укажите параметр `root_path_in_servers=False`: diff --git a/docs/ru/docs/advanced/dataclasses.md b/docs/ru/docs/advanced/dataclasses.md index 816f74404..c37ce3023 100644 --- a/docs/ru/docs/advanced/dataclasses.md +++ b/docs/ru/docs/advanced/dataclasses.md @@ -4,7 +4,7 @@ FastAPI построен поверх **Pydantic**, и я показывал в Но FastAPI также поддерживает использование `dataclasses` тем же способом: -{* ../../docs_src/dataclasses/tutorial001.py hl[1,7:12,19:20] *} +{* ../../docs_src/dataclasses/tutorial001_py310.py hl[1,6:11,18:19] *} Это по-прежнему поддерживается благодаря **Pydantic**, так как в нём есть встроенная поддержка `dataclasses`. @@ -32,7 +32,7 @@ FastAPI построен поверх **Pydantic**, и я показывал в Вы также можете использовать `dataclasses` в параметре `response_model`: -{* ../../docs_src/dataclasses/tutorial002.py hl[1,7:13,19] *} +{* ../../docs_src/dataclasses/tutorial002_py310.py hl[1,6:12,18] *} Этот dataclass будет автоматически преобразован в Pydantic dataclass. @@ -48,7 +48,7 @@ FastAPI построен поверх **Pydantic**, и я показывал в В таком случае вы можете просто заменить стандартные `dataclasses` на `pydantic.dataclasses`, которая является полностью совместимой заменой (drop-in replacement): -{* ../../docs_src/dataclasses/tutorial003.py hl[1,5,8:11,14:17,23:25,28] *} +{* ../../docs_src/dataclasses/tutorial003_py310.py hl[1,4,7:10,13:16,22:24,27] *} 1. Мы по-прежнему импортируем `field` из стандартных `dataclasses`. diff --git a/docs/ru/docs/advanced/openapi-callbacks.md b/docs/ru/docs/advanced/openapi-callbacks.md index faf58370b..de7e28301 100644 --- a/docs/ru/docs/advanced/openapi-callbacks.md +++ b/docs/ru/docs/advanced/openapi-callbacks.md @@ -31,7 +31,7 @@ Эта часть вполне обычна, большая часть кода вам уже знакома: -{* ../../docs_src/openapi_callbacks/tutorial001.py hl[9:13,36:53] *} +{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[7:11,34:51] *} /// tip | Совет @@ -90,7 +90,7 @@ httpx.post(callback_url, json={"description": "Invoice paid", "paid": True}) Сначала создайте новый `APIRouter`, который будет содержать один или несколько обратных вызовов. -{* ../../docs_src/openapi_callbacks/tutorial001.py hl[3,25] *} +{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[1,23] *} ### Создайте *операцию пути* для обратного вызова { #create-the-callback-path-operation } @@ -101,7 +101,7 @@ httpx.post(callback_url, json={"description": "Invoice paid", "paid": True}) * Вероятно, в ней должно быть объявление тела запроса, например `body: InvoiceEvent`. * А также может быть объявление модели ответа, например `response_model=InvoiceEventReceived`. -{* ../../docs_src/openapi_callbacks/tutorial001.py hl[16:18,21:22,28:32] *} +{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[14:16,19:20,26:30] *} Есть 2 основных отличия от обычной *операции пути*: @@ -169,7 +169,7 @@ https://www.external.org/events/invoices/2expen51ve Теперь используйте параметр `callbacks` в *декораторе операции пути вашего API*, чтобы передать атрибут `.routes` (это, по сути, просто `list` маршрутов/*операций пути*) из этого маршрутизатора обратных вызовов: -{* ../../docs_src/openapi_callbacks/tutorial001.py hl[35] *} +{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[33] *} /// tip | Совет diff --git a/docs/ru/docs/advanced/path-operation-advanced-configuration.md b/docs/ru/docs/advanced/path-operation-advanced-configuration.md index fcb3cd47f..78a16a558 100644 --- a/docs/ru/docs/advanced/path-operation-advanced-configuration.md +++ b/docs/ru/docs/advanced/path-operation-advanced-configuration.md @@ -50,7 +50,7 @@ Эта часть не попадёт в документацию, но другие инструменты (например, Sphinx) смогут использовать остальное. -{* ../../docs_src/path_operation_advanced_configuration/tutorial004.py hl[19:29] *} +{* ../../docs_src/path_operation_advanced_configuration/tutorial004_py310.py hl[17:27] *} ## Дополнительные ответы { #additional-responses } @@ -155,13 +155,13 @@ //// tab | Pydantic v2 -{* ../../docs_src/path_operation_advanced_configuration/tutorial007.py hl[17:22, 24] *} +{* ../../docs_src/path_operation_advanced_configuration/tutorial007_py39.py hl[15:20, 22] *} //// //// tab | Pydantic v1 -{* ../../docs_src/path_operation_advanced_configuration/tutorial007_pv1.py hl[17:22, 24] *} +{* ../../docs_src/path_operation_advanced_configuration/tutorial007_pv1_py39.py hl[15:20, 22] *} //// @@ -179,13 +179,13 @@ //// tab | Pydantic v2 -{* ../../docs_src/path_operation_advanced_configuration/tutorial007.py hl[26:33] *} +{* ../../docs_src/path_operation_advanced_configuration/tutorial007_py39.py hl[24:31] *} //// //// tab | Pydantic v1 -{* ../../docs_src/path_operation_advanced_configuration/tutorial007_pv1.py hl[26:33] *} +{* ../../docs_src/path_operation_advanced_configuration/tutorial007_pv1_py39.py hl[24:31] *} //// diff --git a/docs/ru/docs/advanced/response-directly.md b/docs/ru/docs/advanced/response-directly.md index febd40ed4..3c10633e9 100644 --- a/docs/ru/docs/advanced/response-directly.md +++ b/docs/ru/docs/advanced/response-directly.md @@ -34,7 +34,7 @@ В таких случаях вы можете использовать `jsonable_encoder` для преобразования данных перед передачей их в ответ: -{* ../../docs_src/response_directly/tutorial001.py hl[6:7,21:22] *} +{* ../../docs_src/response_directly/tutorial001_py310.py hl[5:6,20:21] *} /// note | Технические детали diff --git a/docs/ru/docs/advanced/settings.md b/docs/ru/docs/advanced/settings.md index a335548c3..0ef46fb13 100644 --- a/docs/ru/docs/advanced/settings.md +++ b/docs/ru/docs/advanced/settings.md @@ -148,7 +148,7 @@ $ ADMIN_EMAIL="deadpool@example.com" APP_NAME="ChimichangApp" fastapi run main.p Продолжая предыдущий пример, ваш файл `config.py` может выглядеть так: -{* ../../docs_src/settings/app02/config.py hl[10] *} +{* ../../docs_src/settings/app02_an_py39/config.py hl[10] *} Обратите внимание, что теперь мы не создаем экземпляр по умолчанию `settings = Settings()`. @@ -174,7 +174,7 @@ $ ADMIN_EMAIL="deadpool@example.com" APP_NAME="ChimichangApp" fastapi run main.p Далее будет очень просто предоставить другой объект настроек во время тестирования, создав переопределение зависимости для `get_settings`: -{* ../../docs_src/settings/app02/test_main.py hl[9:10,13,21] *} +{* ../../docs_src/settings/app02_an_py39/test_main.py hl[9:10,13,21] *} В переопределении зависимости мы задаем новое значение `admin_email` при создании нового объекта `Settings`, а затем возвращаем этот новый объект. @@ -217,7 +217,7 @@ APP_NAME="ChimichangApp" //// tab | Pydantic v2 -{* ../../docs_src/settings/app03_an/config.py hl[9] *} +{* ../../docs_src/settings/app03_an_py39/config.py hl[9] *} /// tip | Совет @@ -229,7 +229,7 @@ APP_NAME="ChimichangApp" //// tab | Pydantic v1 -{* ../../docs_src/settings/app03_an/config_pv1.py hl[9:10] *} +{* ../../docs_src/settings/app03_an_py39/config_pv1.py hl[9:10] *} /// tip | Совет diff --git a/docs/ru/docs/deployment/cloud.md b/docs/ru/docs/deployment/cloud.md index a400d1843..955db2a15 100644 --- a/docs/ru/docs/deployment/cloud.md +++ b/docs/ru/docs/deployment/cloud.md @@ -4,11 +4,19 @@ В большинстве случаев у основных облачных провайдеров есть руководства по развертыванию FastAPI на их платформе. +## FastAPI Cloud { #fastapi-cloud } + +**FastAPI Cloud** создан тем же автором и командой, стоящими за **FastAPI**. + +Он упрощает процесс **создания образа**, **развертывания** и **доступа** к API с минимальными усилиями. + +Он переносит тот же **опыт разработчика** создания приложений с FastAPI на их **развертывание** в облаке. 🎉 + +FastAPI Cloud — основной спонсор и источник финансирования для open source проектов *FastAPI and friends*. ✨ + ## Облачные провайдеры — спонсоры { #cloud-providers-sponsors } -Некоторые облачные провайдеры ✨ [**спонсируют FastAPI**](../help-fastapi.md#sponsor-the-author){.internal-link target=_blank} ✨ — это обеспечивает непрерывное и здоровое развитие FastAPI и его экосистемы. - -И это показывает их искреннюю приверженность FastAPI и его сообществу (вам): они не только хотят предоставить вам хороший сервис, но и стремятся гарантировать, что у вас будет хороший и стабильный фреймворк — FastAPI. 🙇 +Некоторые другие облачные провайдеры ✨ [**спонсируют FastAPI**](../help-fastapi.md#sponsor-the-author){.internal-link target=_blank} ✨ тоже. 🙇 Возможно, вы захотите попробовать их сервисы и воспользоваться их руководствами: diff --git a/docs/ru/docs/deployment/fastapicloud.md b/docs/ru/docs/deployment/fastapicloud.md new file mode 100644 index 000000000..9e7430ecb --- /dev/null +++ b/docs/ru/docs/deployment/fastapicloud.md @@ -0,0 +1,65 @@ +# FastAPI Cloud { #fastapi-cloud } + +Вы можете развернуть своё приложение FastAPI в FastAPI Cloud одной командой, присоединяйтесь к списку ожидания, если ещё не сделали этого. 🚀 + +## Вход { #login } + +Убедитесь, что у вас уже есть аккаунт **FastAPI Cloud** (мы пригласили вас из списка ожидания 😉). + +Затем выполните вход: + +
+ +```console +$ fastapi login + +You are logged in to FastAPI Cloud 🚀 +``` + +
+ +## Деплой { #deploy } + +Теперь разверните приложение одной командой: + +
+ +```console +$ fastapi deploy + +Deploying to FastAPI Cloud... + +✅ Deployment successful! + +🐔 Ready the chicken! Your app is ready at https://myapp.fastapicloud.dev +``` + +
+ +Вот и всё! Теперь вы можете открыть своё приложение по этому URL. ✨ + +## О FastAPI Cloud { #about-fastapi-cloud } + +**FastAPI Cloud** создан тем же автором и командой, что и **FastAPI**. + +Он упрощает процесс **создания образа**, **развертывания** и **доступа** к API с минимальными усилиями. + +Он переносит тот же **опыт разработчика**, что вы получаете при создании приложений на FastAPI, на их **развертывание** в облаке. 🎉 + +Он также возьмёт на себя большинство вещей, которые требуются при развертывании приложения, например: + +* HTTPS +* Репликация с автоматическим масштабированием на основе запросов +* и т.д. + +FastAPI Cloud — основной спонсор и источник финансирования open source‑проектов «FastAPI и друзья». ✨ + +## Развертывание у других облачных провайдеров { #deploy-to-other-cloud-providers } + +FastAPI — проект с открытым исходным кодом и основан на стандартах. Вы можете развернуть приложения FastAPI у любого облачного провайдера на ваш выбор. + +Следуйте руководствам вашего облачного провайдера, чтобы развернуть приложения FastAPI у них. 🤓 + +## Развертывание на собственном сервере { #deploy-your-own-server } + +Позже в этом руководстве по **развертыванию** я также расскажу все детали — чтобы вы понимали, что происходит, что нужно сделать и как развернуть приложения FastAPI самостоятельно, в том числе на собственных серверах. 🤓 diff --git a/docs/ru/docs/deployment/index.md b/docs/ru/docs/deployment/index.md index c85fa0d52..ffb77641d 100644 --- a/docs/ru/docs/deployment/index.md +++ b/docs/ru/docs/deployment/index.md @@ -12,10 +12,12 @@ ## Стратегии развёртывания { #deployment-strategies } -В зависимости от вашего конкретного случая, есть несколько способов сделать это. +Есть несколько способов сделать это, в зависимости от вашего конкретного случая и используемых вами инструментов. Вы можете **развернуть сервер** самостоятельно, используя различные инструменты. Например, можно использовать **облачный сервис**, который выполнит часть работы за вас. Также возможны и другие варианты. +Например, мы, команда, стоящая за FastAPI, создали **FastAPI Cloud**, чтобы сделать развёртывание приложений FastAPI в облаке как можно более простым и прямолинейным, с тем же удобством для разработчика, что и при работе с FastAPI. + В этом блоке я покажу вам некоторые из основных концепций, которые вы, вероятно, должны иметь в виду при развертывании приложения **FastAPI** (хотя большинство из них применимо к любому другому типу веб-приложений). В последующих разделах вы узнаете больше деталей и методов, необходимых для этого. ✨ diff --git a/docs/ru/docs/how-to/authentication-error-status-code.md b/docs/ru/docs/how-to/authentication-error-status-code.md new file mode 100644 index 000000000..5675cecc5 --- /dev/null +++ b/docs/ru/docs/how-to/authentication-error-status-code.md @@ -0,0 +1,17 @@ +# Использование старых статус-кодов ошибок аутентификации 403 { #use-old-403-authentication-error-status-codes } + +До версии FastAPI `0.122.0`, когда встроенные утилиты безопасности возвращали ошибку клиенту после неудачной аутентификации, они использовали HTTP статус-код `403 Forbidden`. + +Начиная с версии FastAPI `0.122.0`, используется более подходящий HTTP статус-код `401 Unauthorized`, и в ответе возвращается имеющий смысл HTTP-заголовок `WWW-Authenticate` в соответствии со спецификациями HTTP, RFC 7235, RFC 9110. + +Но если по какой-то причине ваши клиенты зависят от старого поведения, вы можете вернуть его, переопределив метод `make_not_authenticated_error` в ваших Security-классах. + +Например, вы можете создать подкласс `HTTPBearer`, который будет возвращать ошибку `403 Forbidden` вместо стандартной `401 Unauthorized`: + +{* ../../docs_src/authentication_error_status_code/tutorial001_an_py39.py hl[9:13] *} + +/// tip | Совет + +Обратите внимание, что функция возвращает экземпляр исключения, не вызывает его. Выброс выполняется остальным внутренним кодом. + +/// diff --git a/docs/ru/docs/how-to/configure-swagger-ui.md b/docs/ru/docs/how-to/configure-swagger-ui.md index 4793cc9db..9d104423d 100644 --- a/docs/ru/docs/how-to/configure-swagger-ui.md +++ b/docs/ru/docs/how-to/configure-swagger-ui.md @@ -40,7 +40,7 @@ FastAPI включает некоторые параметры конфигур Это включает следующие настройки по умолчанию: -{* ../../fastapi/openapi/docs.py ln[8:23] hl[17:23] *} +{* ../../fastapi/openapi/docs.py ln[9:24] hl[18:24] *} Вы можете переопределить любую из них, указав другое значение в аргументе `swagger_ui_parameters`. diff --git a/docs/ru/docs/how-to/custom-request-and-route.md b/docs/ru/docs/how-to/custom-request-and-route.md index 1b8d7f7ed..feef9670a 100644 --- a/docs/ru/docs/how-to/custom-request-and-route.md +++ b/docs/ru/docs/how-to/custom-request-and-route.md @@ -42,7 +42,7 @@ Таким образом, один и тот же класс маршрута сможет обрабатывать как gzip-сжатые, так и несжатые запросы. -{* ../../docs_src/custom_request_and_route/tutorial001.py hl[8:15] *} +{* ../../docs_src/custom_request_and_route/tutorial001_an_py310.py hl[9:16] *} ### Создать пользовательский класс `GzipRoute` { #create-a-custom-gziproute-class } @@ -54,7 +54,7 @@ Здесь мы используем её, чтобы создать `GzipRequest` из исходного HTTP-запроса. -{* ../../docs_src/custom_request_and_route/tutorial001.py hl[18:26] *} +{* ../../docs_src/custom_request_and_route/tutorial001_an_py310.py hl[19:27] *} /// note | Технические детали @@ -92,18 +92,18 @@ Нужно лишь обработать запрос внутри блока `try`/`except`: -{* ../../docs_src/custom_request_and_route/tutorial002.py hl[13,15] *} +{* ../../docs_src/custom_request_and_route/tutorial002_an_py310.py hl[14,16] *} Если произойдёт исключение, экземпляр `Request` всё ещё будет в области видимости, поэтому мы сможем прочитать тело запроса и использовать его при обработке ошибки: -{* ../../docs_src/custom_request_and_route/tutorial002.py hl[16:18] *} +{* ../../docs_src/custom_request_and_route/tutorial002_an_py310.py hl[17:19] *} ## Пользовательский класс `APIRoute` в роутере { #custom-apiroute-class-in-a-router } Вы также можете задать параметр `route_class` у `APIRouter`: -{* ../../docs_src/custom_request_and_route/tutorial003.py hl[26] *} +{* ../../docs_src/custom_request_and_route/tutorial003_py310.py hl[26] *} В этом примере *операции пути*, объявленные в `router`, будут использовать пользовательский класс `TimedRoute` и получат дополнительный HTTP-заголовок `X-Response-Time` в ответе с временем, затраченным на формирование ответа: -{* ../../docs_src/custom_request_and_route/tutorial003.py hl[13:20] *} +{* ../../docs_src/custom_request_and_route/tutorial003_py310.py hl[13:20] *} diff --git a/docs/ru/docs/index.md b/docs/ru/docs/index.md index 75cd63223..b562cbe5b 100644 --- a/docs/ru/docs/index.md +++ b/docs/ru/docs/index.md @@ -52,14 +52,20 @@ FastAPI — это современный, быстрый (высокопрои -{% if sponsors %} +### Ключевой-спонсор { #keystone-sponsor } + +{% for sponsor in sponsors.keystone -%} + +{% endfor -%} + +### Золотые и серебряные спонсоры { #gold-and-silver-sponsors } + {% for sponsor in sponsors.gold -%} {% endfor -%} {%- for sponsor in sponsors.silver -%} {% endfor %} -{% endif %} @@ -444,6 +450,58 @@ item: Item * **сессии с использованием cookie** * ...и многое другое. +### Разверните приложение (опционально) { #deploy-your-app-optional } + +При желании вы можете развернуть своё приложение FastAPI в FastAPI Cloud, присоединяйтесь к списку ожидания, если ещё не сделали этого. 🚀 + +Если у вас уже есть аккаунт **FastAPI Cloud** (мы пригласили вас из списка ожидания 😉), вы можете развернуть ваше приложение одной командой. + +Перед развертыванием убедитесь, что вы вошли в систему: + +
+ +```console +$ fastapi login + +You are logged in to FastAPI Cloud 🚀 +``` + +
+ +Затем разверните приложение: + +
+ +```console +$ fastapi deploy + +Deploying to FastAPI Cloud... + +✅ Deployment successful! + +🐔 Ready the chicken! Your app is ready at https://myapp.fastapicloud.dev +``` + +
+ +Вот и всё! Теперь вы можете открыть ваше приложение по этой ссылке. ✨ + +#### О FastAPI Cloud { #about-fastapi-cloud } + +**FastAPI Cloud** создан тем же автором и командой, что и **FastAPI**. + +Он упрощает процесс **создания образа**, **развертывания** и **доступа** к API при минимальных усилиях. + +Он переносит тот же **опыт разработчика**, что и при создании приложений на FastAPI, на их **развертывание** в облаке. 🎉 + +FastAPI Cloud — основной спонсор и источник финансирования для проектов с открытым исходным кодом из экосистемы *FastAPI and friends*. ✨ + +#### Развертывание у других облачных провайдеров { #deploy-to-other-cloud-providers } + +FastAPI — это open source и стандартизированный фреймворк. Вы можете развернуть приложения FastAPI у любого облачного провайдера на ваш выбор. + +Следуйте руководствам вашего облачного провайдера по развертыванию приложений FastAPI. 🤓 + ## Производительность { #performance } Независимые бенчмарки TechEmpower показывают приложения **FastAPI**, работающие под управлением Uvicorn, как один из самых быстрых доступных фреймворков Python, уступающий только самим Starlette и Uvicorn (используются внутри FastAPI). (*) diff --git a/docs/ru/docs/project-generation.md b/docs/ru/docs/project-generation.md index 8c5681115..dbedf76fe 100644 --- a/docs/ru/docs/project-generation.md +++ b/docs/ru/docs/project-generation.md @@ -13,8 +13,8 @@ - 🔍 [Pydantic](https://docs.pydantic.dev), используется FastAPI, для валидации данных и управления настройками. - 💾 [PostgreSQL](https://www.postgresql.org) в качестве SQL‑базы данных. - 🚀 [React](https://react.dev) для фронтенда. - - 💃 Используются TypeScript, хуки, [Vite](https://vitejs.dev) и другие части современного фронтенд‑стека. - - 🎨 [Chakra UI](https://chakra-ui.com) для компонентов фронтенда. + - 💃 Используются TypeScript, хуки, Vite и другие части современного фронтенд‑стека. + - 🎨 [Tailwind CSS](https://tailwindcss.com) и [shadcn/ui](https://ui.shadcn.com) для компонентов фронтенда. - 🤖 Автоматически сгенерированный фронтенд‑клиент. - 🧪 [Playwright](https://playwright.dev) для End‑to‑End тестирования. - 🦇 Поддержка тёмной темы. diff --git a/docs/ru/docs/resources/index.md b/docs/ru/docs/resources/index.md index 54be4e5fd..faf80f7f4 100644 --- a/docs/ru/docs/resources/index.md +++ b/docs/ru/docs/resources/index.md @@ -1,3 +1,3 @@ # Ресурсы { #resources } -Дополнительные ресурсы, внешние ссылки, статьи и многое другое. ✈️ +Дополнительные ресурсы, внешние ссылки и многое другое. ✈️ diff --git a/docs/ru/docs/tutorial/bigger-applications.md b/docs/ru/docs/tutorial/bigger-applications.md index b832383cc..5e5d6ada9 100644 --- a/docs/ru/docs/tutorial/bigger-applications.md +++ b/docs/ru/docs/tutorial/bigger-applications.md @@ -85,17 +85,13 @@ from app.routers import items Точно также, как и в случае с классом `FastAPI`, вам нужно импортировать и создать объект класса `APIRouter`. -```Python hl_lines="1 3" title="app/routers/users.py" -{!../../docs_src/bigger_applications/app/routers/users.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/routers/users.py hl[1,3] title["app/routers/users.py"] *} ### Создание *эндпоинтов* с помощью `APIRouter` { #path-operations-with-apirouter } В дальнейшем используйте `APIRouter` для объявления *эндпоинтов*, точно также, как вы используете класс `FastAPI`: -```Python hl_lines="6 11 16" title="app/routers/users.py" -{!../../docs_src/bigger_applications/app/routers/users.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/routers/users.py hl[6,11,16] title["app/routers/users.py"] *} Вы можете думать об `APIRouter` как об "уменьшенной версии" класса FastAPI`. @@ -119,35 +115,7 @@ from app.routers import items Теперь мы воспользуемся простой зависимостью, чтобы прочитать кастомизированный `X-Token` из заголовка: -//// tab | Python 3.9+ - -```Python hl_lines="3 6-8" title="app/dependencies.py" -{!> ../../docs_src/bigger_applications/app_an_py39/dependencies.py!} -``` - -//// - -//// tab | Python 3.8+ - -```Python hl_lines="1 5-7" title="app/dependencies.py" -{!> ../../docs_src/bigger_applications/app_an/dependencies.py!} -``` - -//// - -//// tab | Python 3.8+ non-Annotated - -/// tip | Подсказка - -Мы рекомендуем использовать версию `Annotated`, когда это возможно. - -/// - -```Python hl_lines="1 4-6" title="app/dependencies.py" -{!> ../../docs_src/bigger_applications/app/dependencies.py!} -``` - -//// +{* ../../docs_src/bigger_applications/app_an_py39/dependencies.py hl[3,6:8] title["app/dependencies.py"] *} /// tip | Подсказка @@ -180,9 +148,7 @@ from app.routers import items Таким образом, вместо того чтобы добавлять все эти свойства в функцию каждого отдельного *эндпоинта*, мы добавим их в `APIRouter`. -```Python hl_lines="5-10 16 21" title="app/routers/items.py" -{!../../docs_src/bigger_applications/app/routers/items.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/routers/items.py hl[5:10,16,21] title["app/routers/items.py"] *} Так как каждый *эндпоинт* начинается с символа `/`: @@ -241,9 +207,7 @@ async def read_item(item_id: str): Мы используем операцию относительного импорта `..` для импорта зависимости: -```Python hl_lines="3" title="app/routers/items.py" -{!../../docs_src/bigger_applications/app/routers/items.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/routers/items.py hl[3] title["app/routers/items.py"] *} #### Как работает относительный импорт? { #how-relative-imports-work } @@ -313,9 +277,7 @@ from ...dependencies import get_token_header Но помимо этого мы можем добавить новые теги для каждого отдельного *эндпоинта*, а также некоторые дополнительные ответы (`responses`), характерные для данного *эндпоинта*: -```Python hl_lines="30-31" title="app/routers/items.py" -{!../../docs_src/bigger_applications/app/routers/items.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/routers/items.py hl[30:31] title["app/routers/items.py"] *} /// tip | Подсказка @@ -341,17 +303,13 @@ from ...dependencies import get_token_header Мы даже можем объявить [глобальные зависимости](dependencies/global-dependencies.md){.internal-link target=_blank}, которые будут объединены с зависимостями для каждого отдельного маршрутизатора: -```Python hl_lines="1 3 7" title="app/main.py" -{!../../docs_src/bigger_applications/app/main.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/main.py hl[1,3,7] title["app/main.py"] *} ### Импорт `APIRouter` { #import-the-apirouter } Теперь мы импортируем другие суб-модули, содержащие `APIRouter`: -```Python hl_lines="4-5" title="app/main.py" -{!../../docs_src/bigger_applications/app/main.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/main.py hl[4:5] title["app/main.py"] *} Так как файлы `app/routers/users.py` и `app/routers/items.py` являются суб-модулями одного и того же Python-пакета `app`, то мы сможем их импортировать, воспользовавшись операцией относительного импорта `.`. @@ -414,17 +372,13 @@ from .routers.users import router Поэтому, для того чтобы использовать обе эти переменные в одном файле, мы импортировали соответствующие суб-модули: -```Python hl_lines="5" title="app/main.py" -{!../../docs_src/bigger_applications/app/main.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/main.py hl[5] title["app/main.py"] *} ### Подключение маршрутизаторов (`APIRouter`) для `users` и для `items` { #include-the-apirouters-for-users-and-items } Давайте подключим маршрутизаторы (`router`) из суб-модулей `users` и `items`: -```Python hl_lines="10-11" title="app/main.py" -{!../../docs_src/bigger_applications/app/main.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/main.py hl[10:11] title["app/main.py"] *} /// info | Примечание @@ -465,17 +419,13 @@ from .routers.users import router В данном примере это сделать очень просто. Но давайте предположим, что поскольку файл используется для нескольких проектов, то мы не можем модифицировать его, добавляя префиксы (`prefix`), зависимости (`dependencies`), теги (`tags`), и т.д. непосредственно в `APIRouter`: -```Python hl_lines="3" title="app/internal/admin.py" -{!../../docs_src/bigger_applications/app/internal/admin.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/internal/admin.py hl[3] title["app/internal/admin.py"] *} Но, несмотря на это, мы хотим использовать кастомный префикс (`prefix`) для подключенного маршрутизатора (`APIRouter`), в результате чего, каждая *операция пути* будет начинаться с `/admin`. Также мы хотим защитить наш маршрутизатор с помощью зависимостей, созданных для нашего проекта. И ещё мы хотим включить теги (`tags`) и ответы (`responses`). Мы можем применить все вышеперечисленные настройки, не изменяя начальный `APIRouter`. Нам всего лишь нужно передать нужные параметры в `app.include_router()`. -```Python hl_lines="14-17" title="app/main.py" -{!../../docs_src/bigger_applications/app/main.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/main.py hl[14:17] title["app/main.py"] *} Таким образом, оригинальный `APIRouter` не будет модифицирован, и мы сможем использовать файл `app/internal/admin.py` сразу в нескольких проектах организации. @@ -496,9 +446,7 @@ from .routers.users import router Здесь мы это делаем ... просто, чтобы показать, что это возможно 🤷: -```Python hl_lines="21-23" title="app/main.py" -{!../../docs_src/bigger_applications/app/main.py!} -``` +{* ../../docs_src/bigger_applications/app_an_py39/main.py hl[21:23] title["app/main.py"] *} и это будет работать корректно вместе с другими *эндпоинтами*, добавленными с помощью `app.include_router()`. diff --git a/docs/ru/docs/tutorial/cookie-param-models.md b/docs/ru/docs/tutorial/cookie-param-models.md index daac764e3..182813afd 100644 --- a/docs/ru/docs/tutorial/cookie-param-models.md +++ b/docs/ru/docs/tutorial/cookie-param-models.md @@ -50,7 +50,7 @@ Вы можете сконфигурировать Pydantic-модель так, чтобы запретить (`forbid`) любые дополнительные (`extra`) поля: -{* ../../docs_src/cookie_param_models/tutorial002_an_py39.py hl[10] *} +{* ../../docs_src/cookie_param_models/tutorial002_an_py310.py hl[10] *} Если клиент попробует отправить **дополнительные cookies**, то в ответ он получит **ошибку**. diff --git a/docs/ru/docs/tutorial/first-steps.md b/docs/ru/docs/tutorial/first-steps.md index c82118cbe..6f59d7205 100644 --- a/docs/ru/docs/tutorial/first-steps.md +++ b/docs/ru/docs/tutorial/first-steps.md @@ -143,6 +143,42 @@ OpenAPI определяет схему API для вашего API. И эта Вы также можете использовать её для автоматической генерации кода для клиентов, которые взаимодействуют с вашим API. Например, для фронтенд-, мобильных или IoT-приложений. +### Разверните приложение (необязательно) { #deploy-your-app-optional } + +При желании вы можете развернуть своё приложение FastAPI в FastAPI Cloud, перейдите и присоединитесь к списку ожидания, если ещё не сделали этого. 🚀 + +Если у вас уже есть аккаунт **FastAPI Cloud** (мы пригласили вас из списка ожидания 😉), вы можете развернуть приложение одной командой. + +Перед развертыванием убедитесь, что вы вошли в систему: + +
+ +```console +$ fastapi login + +You are logged in to FastAPI Cloud 🚀 +``` + +
+ +Затем разверните приложение: + +
+ +```console +$ fastapi deploy + +Deploying to FastAPI Cloud... + +✅ Deployment successful! + +🐔 Ready the chicken! Your app is ready at https://myapp.fastapicloud.dev +``` + +
+ +Готово! Теперь вы можете открыть своё приложение по этому URL. ✨ + ## Рассмотрим поэтапно { #recap-step-by-step } ### Шаг 1: импортируйте `FastAPI` { #step-1-import-fastapi } @@ -314,6 +350,26 @@ https://example.com/items/foo Многие другие объекты и модели будут автоматически преобразованы в JSON (включая ORM и т. п.). Попробуйте использовать те, что вам привычнее, с высокой вероятностью они уже поддерживаются. +### Шаг 6: разверните приложение { #step-6-deploy-it } + +Разверните приложение в **FastAPI Cloud** одной командой: `fastapi deploy`. 🎉 + +#### О FastAPI Cloud { #about-fastapi-cloud } + +**FastAPI Cloud** создан тем же автором и командой, что и **FastAPI**. + +Он упрощает процесс **создания образа**, **развертывания** и **доступа** к API с минимальными усилиями. + +Он переносит тот же **опыт разработчика** при создании приложений с FastAPI на их **развертывание** в облаке. 🎉 + +FastAPI Cloud — основной спонсор и источник финансирования для open-source проектов «FastAPI и друзья». ✨ + +#### Развертывание у других облачных провайдеров { #deploy-to-other-cloud-providers } + +FastAPI — open-source и основан на стандартах. Вы можете развернуть приложения FastAPI у любого облачного провайдера по вашему выбору. + +Следуйте руководствам вашего облачного провайдера, чтобы развернуть приложения FastAPI у них. 🤓 + ## Резюме { #recap } * Импортируйте `FastAPI`. @@ -321,3 +377,4 @@ https://example.com/items/foo * Напишите **декоратор операции пути**, например `@app.get("/")`. * Определите **функцию операции пути**; например, `def root(): ...`. * Запустите сервер разработки командой `fastapi dev`. +* При желании разверните приложение командой `fastapi deploy`. diff --git a/docs/ru/docs/tutorial/handling-errors.md b/docs/ru/docs/tutorial/handling-errors.md index 2378c8b04..63ca8665e 100644 --- a/docs/ru/docs/tutorial/handling-errors.md +++ b/docs/ru/docs/tutorial/handling-errors.md @@ -81,7 +81,7 @@ ## Установка пользовательских обработчиков исключений { #install-custom-exception-handlers } -Вы можете добавить пользовательские обработчики исключений с помощью то же самое исключение - утилиты от Starlette. +Вы можете добавить пользовательские обработчики исключений с помощью тех же утилит обработки исключений из Starlette. Допустим, у вас есть пользовательское исключение `UnicornException`, которое вы (или используемая вами библиотека) можете `вызвать`. @@ -117,7 +117,7 @@ Вы можете переопределить эти обработчики исключений на свои собственные. -### Переопределение исключений проверки запроса { #override-request-validation-exceptions } +### Переопределение обработчика исключений проверки запроса { #override-request-validation-exceptions } Когда запрос содержит недопустимые данные, **FastAPI** внутренне вызывает ошибку `RequestValidationError`. @@ -127,7 +127,7 @@ Обработчик исключения получит объект `Request` и исключение. -{* ../../docs_src/handling_errors/tutorial004.py hl[2,14:16] *} +{* ../../docs_src/handling_errors/tutorial004.py hl[2,14:19] *} Теперь, если перейти к `/items/foo`, то вместо стандартной JSON-ошибки с: @@ -149,36 +149,17 @@ вы получите текстовую версию: ``` -1 validation error -path -> item_id - value is not a valid integer (type=type_error.integer) +Validation errors: +Field: ('path', 'item_id'), Error: Input should be a valid integer, unable to parse string as an integer ``` -#### `RequestValidationError` или `ValidationError` { #requestvalidationerror-vs-validationerror } - -/// warning | Внимание - -Это технические детали, которые можно пропустить, если они не важны для вас сейчас. - -/// - -`RequestValidationError` является подклассом Pydantic `ValidationError`. - -**FastAPI** использует его для того, чтобы, если вы используете Pydantic-модель в `response_model`, и ваши данные содержат ошибку, вы увидели ошибку в журнале. - -Но клиент/пользователь этого не увидит. Вместо этого клиент получит сообщение "Internal Server Error" с кодом состояния HTTP `500`. - -Так и должно быть, потому что если в вашем *ответе* или где-либо в вашем коде (не в *запросе* клиента) возникает Pydantic `ValidationError`, то это действительно ошибка в вашем коде. - -И пока вы не устраните ошибку, ваши клиенты/пользователи не должны иметь доступа к внутренней информации о ней, так как это может привести к уязвимости в системе безопасности. - ### Переопределите обработчик ошибок `HTTPException` { #override-the-httpexception-error-handler } Аналогичным образом можно переопределить обработчик `HTTPException`. Например, для этих ошибок можно вернуть обычный текстовый ответ вместо JSON: -{* ../../docs_src/handling_errors/tutorial004.py hl[3:4,9:11,22] *} +{* ../../docs_src/handling_errors/tutorial004.py hl[3:4,9:11,25] *} /// note | Технические детали @@ -188,6 +169,14 @@ path -> item_id /// +/// warning | Внимание + +Имейте в виду, что `RequestValidationError` содержит информацию об имени файла и строке, где произошла ошибка валидации, чтобы вы могли при желании отобразить её в логах с релевантными данными. + +Но это означает, что если вы просто преобразуете её в строку и вернёте эту информацию напрямую, вы можете допустить небольшую утечку информации о своей системе, поэтому здесь код извлекает и показывает каждую ошибку отдельно. + +/// + ### Используйте тело `RequestValidationError` { #use-the-requestvalidationerror-body } Ошибка `RequestValidationError` содержит полученное `тело` с недопустимыми данными. diff --git a/docs/ru/docs/tutorial/security/index.md b/docs/ru/docs/tutorial/security/index.md index 8fb4bf24f..ebac013b6 100644 --- a/docs/ru/docs/tutorial/security/index.md +++ b/docs/ru/docs/tutorial/security/index.md @@ -1,4 +1,4 @@ -# Настройка авторизации +# Настройка авторизации { #security } Существует множество способов обеспечения безопасности, аутентификации и авторизации. @@ -10,11 +10,11 @@ Но сначала давайте рассмотрим некоторые небольшие концепции. -## Куда-то торопишься? +## Куда-то торопишься? { #in-a-hurry } Если вам не нужна информация о каких-либо из следующих терминов и вам просто нужно добавить защиту с аутентификацией на основе логина и пароля *прямо сейчас*, переходите к следующим главам. -## OAuth2 +## OAuth2 { #oauth2 } OAuth2 - это протокол, который определяет несколько способов обработки аутентификации и авторизации. @@ -24,7 +24,7 @@ OAuth2 включает в себя способы аутентификации Это то, что используют под собой все кнопки "вход с помощью Facebook, Google, X (Twitter), GitHub" на страницах авторизации. -### OAuth 1 +### OAuth 1 { #oauth-1 } Ранее использовался протокол OAuth 1, который сильно отличается от OAuth2 и является более сложным, поскольку он включал прямые описания того, как шифровать сообщение. @@ -34,11 +34,11 @@ OAuth2 не указывает, как шифровать сообщение, о /// tip | Подсказка -В разделе **Развертывание** вы увидите [как настроить протокол HTTPS бесплатно, используя Traefik и Let's Encrypt.](https://fastapi.tiangolo.com/ru/deployment/https/) +В разделе **Развертывание** вы увидите как настроить протокол HTTPS бесплатно, используя Traefik и Let's Encrypt. /// -## OpenID Connect +## OpenID Connect { #openid-connect } OpenID Connect - это еще один протокол, основанный на **OAuth2**. @@ -48,7 +48,7 @@ OpenID Connect - это еще один протокол, основанный Но вход в Facebook не поддерживает OpenID Connect. У него есть собственная вариация OAuth2. -### OpenID (не "OpenID Connect") +### OpenID (не "OpenID Connect") { #openid-not-openid-connect } Также ранее использовался стандарт "OpenID", который пытался решить ту же проблему, что и **OpenID Connect**, но не был основан на OAuth2. @@ -56,7 +56,7 @@ OpenID Connect - это еще один протокол, основанный В настоящее время не очень популярен и не используется. -## OpenAPI +## OpenAPI { #openapi } OpenAPI (ранее известный как Swagger) - это открытая спецификация для создания API (в настоящее время является частью Linux Foundation). @@ -97,7 +97,7 @@ OpenAPI может использовать следующие схемы авт /// -## Преимущества **FastAPI** +## Преимущества **FastAPI** { #fastapi-utilities } Fast API предоставляет несколько инструментов для каждой из этих схем безопасности в модуле `fastapi.security`, которые упрощают использование этих механизмов безопасности. diff --git a/docs/ru/docs/tutorial/sql-databases.md b/docs/ru/docs/tutorial/sql-databases.md index c44f37b9a..1d0346533 100644 --- a/docs/ru/docs/tutorial/sql-databases.md +++ b/docs/ru/docs/tutorial/sql-databases.md @@ -63,9 +63,9 @@ $ pip install sqlmodel * `table=True` сообщает SQLModel, что это *модель-таблица*, она должна представлять **таблицу** в SQL базе данных, это не просто *модель данных* (как обычный класс Pydantic). -* `Field(primary_key=True)` сообщает SQLModel, что `id` — это **первичный ключ** в SQL базе данных (подробнее о первичных ключах можно узнать в документации SQLModel). +* `Field(primary_key=True)` сообщает SQLModel, что `id` — это **первичный ключ** в SQL базе данных (подробнее о первичных ключах SQL можно узнать в документации SQLModel). - Благодаря типу `int | None`, SQLModel будет знать, что этот столбец должен быть `INTEGER` в SQL базе данных и должен допускать значение `NULL`. + **Примечание:** Мы используем `int | None` для поля первичного ключа, чтобы в Python-коде можно было *создать объект без `id`* (`id=None`), предполагая, что база данных *сгенерирует его при сохранении*. SQLModel понимает, что база данных предоставит `id`, и *определяет столбец как `INTEGER` (не `NULL`)* в схеме базы данных. См. документацию SQLModel о первичных ключах для подробностей. * `Field(index=True)` сообщает SQLModel, что нужно создать **SQL индекс** для этого столбца, что позволит быстрее выполнять выборки при чтении данных, отфильтрованных по этому столбцу. @@ -107,7 +107,7 @@ $ pip install sqlmodel Здесь мы создаём таблицы в обработчике события запуска приложения. -Для продакшна вы, вероятно, будете использовать скрипт миграций, который выполняется до запуска приложения. 🤓 +Для продакшн вы, вероятно, будете использовать скрипт миграций, который выполняется до запуска приложения. 🤓 /// tip | Подсказка diff --git a/docs/ru/docs/tutorial/testing.md b/docs/ru/docs/tutorial/testing.md index 0224798b1..7354ed895 100644 --- a/docs/ru/docs/tutorial/testing.md +++ b/docs/ru/docs/tutorial/testing.md @@ -121,63 +121,13 @@ $ pip install httpx Обе *операции пути* требуют наличия в запросе заголовка `X-Token`. -//// tab | Python 3.10+ - -```Python -{!> ../../docs_src/app_testing/app_b_an_py310/main.py!} -``` - -//// - -//// tab | Python 3.9+ - -```Python -{!> ../../docs_src/app_testing/app_b_an_py39/main.py!} -``` - -//// - -//// tab | Python 3.8+ - -```Python -{!> ../../docs_src/app_testing/app_b_an/main.py!} -``` - -//// - -//// tab | Python 3.10+ без Annotated - -/// tip | Подсказка - -По возможности используйте версию с `Annotated`. - -/// - -```Python -{!> ../../docs_src/app_testing/app_b_py310/main.py!} -``` - -//// - -//// tab | Python 3.8+ без Annotated - -/// tip | Подсказка - -По возможности используйте версию с `Annotated`. - -/// - -```Python -{!> ../../docs_src/app_testing/app_b/main.py!} -``` - -//// +{* ../../docs_src/app_testing/app_b_an_py310/main.py *} ### Расширенный файл тестов { #extended-testing-file } Теперь обновим файл `test_main.py`, добавив в него тестов: -{* ../../docs_src/app_testing/app_b/test_main.py *} +{* ../../docs_src/app_testing/app_b_an_py310/test_main.py *} Если Вы не знаете, как передать информацию в запросе, можете воспользоваться поисковиком (погуглить) и задать вопрос: "Как передать информацию в запросе с помощью `httpx`", можно даже спросить: "Как передать информацию в запросе с помощью `requests`", поскольку дизайн HTTPX основан на дизайне Requests. diff --git a/docs/ru/docs/virtual-environments.md b/docs/ru/docs/virtual-environments.md index 5153cd486..43136298a 100644 --- a/docs/ru/docs/virtual-environments.md +++ b/docs/ru/docs/virtual-environments.md @@ -242,6 +242,26 @@ $ python -m pip install --upgrade pip +/// tip | Подсказка + +Иногда при попытке обновить pip вы можете получить ошибку **`No module named pip`**. + +Если это произошло, установите и обновите pip с помощью команды ниже: + +
+ +```console +$ python -m ensurepip --upgrade + +---> 100% +``` + +
+ +Эта команда установит pip, если он ещё не установлен, а также гарантирует, что установленная версия pip будет не старее, чем версия, доступная в `ensurepip`. + +/// + ## Добавление `.gitignore` { #add-gitignore } Если вы используете **Git** (а вам стоит его использовать), добавьте файл `.gitignore`, чтобы исключить из Git всё, что находится в вашей `.venv`. @@ -834,7 +854,7 @@ I solemnly swear 🐺 * Управлять **виртуальным окружением** ваших проектов * Устанавливать **пакеты** * Управлять **зависимостями и версиями** пакетов вашего проекта -* Обеспечивать наличие **точного** набора пакетов и версий к установке, включая их зависимости, чтобы вы были уверены, что сможете запускать проект в продакшне точно так же, как и на компьютере при разработке — это называется **locking** +* Обеспечивать наличие **точного** набора пакетов и версий к установке, включая их зависимости, чтобы вы были уверены, что сможете запускать проект в продакшн точно так же, как и на компьютере при разработке — это называется **locking** * И многое другое ## Заключение { #conclusion } diff --git a/fastapi/__init__.py b/fastapi/__init__.py index 8de426ad4..e02969c55 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.124.2" +__version__ = "0.124.4" from starlette import status as status diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index b5e6e8983..97a48bf86 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -18,7 +18,7 @@ from typing import ( from fastapi._compat import may_v1, shared from fastapi.openapi.constants import REF_TEMPLATE from fastapi.types import IncEx, ModelNameMap, UnionType -from pydantic import BaseModel, ConfigDict, TypeAdapter, create_model +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, create_model from pydantic import PydanticSchemaGenerationError as PydanticSchemaGenerationError from pydantic import PydanticUndefinedAnnotation as PydanticUndefinedAnnotation from pydantic import ValidationError as ValidationError @@ -50,6 +50,45 @@ UndefinedType = PydanticUndefinedType evaluate_forwardref = eval_type_lenient Validator = Any +# TODO: remove when dropping support for Pydantic < v2.12.3 +_Attrs = { + "default": ..., + "default_factory": None, + "alias": None, + "alias_priority": None, + "validation_alias": None, + "serialization_alias": None, + "title": None, + "field_title_generator": None, + "description": None, + "examples": None, + "exclude": None, + "exclude_if": None, + "discriminator": None, + "deprecated": None, + "json_schema_extra": None, + "frozen": None, + "validate_default": None, + "repr": True, + "init": None, + "init_var": None, + "kw_only": None, +} + + +# TODO: remove when dropping support for Pydantic < v2.12.3 +def asdict(field_info: FieldInfo) -> Dict[str, Any]: + attributes = {} + for attr in _Attrs: + value = getattr(field_info, attr, Undefined) + if value is not Undefined: + attributes[attr] = value + return { + "annotation": field_info.annotation, + "metadata": field_info.metadata, + "attributes": attributes, + } + class BaseConfig: pass @@ -71,6 +110,18 @@ class ModelField: a = self.field_info.alias return a if a is not None else self.name + @property + def validation_alias(self) -> Union[str, None]: + va = self.field_info.validation_alias + if isinstance(va, str) and va: + return va + return None + + @property + def serialization_alias(self) -> Union[str, None]: + sa = self.field_info.serialization_alias + return sa or None + @property def required(self) -> bool: return self.field_info.is_required() @@ -95,10 +146,15 @@ class ModelField: warnings.simplefilter( "ignore", category=UnsupportedFieldAttributeWarning ) + # TODO: remove after dropping support for Python 3.8 and + # setting the min Pydantic to v2.12.3 that adds asdict() + field_dict = asdict(self.field_info) annotated_args = ( - self.field_info.annotation, - *self.field_info.metadata, - self.field_info, + field_dict["annotation"], + *field_dict["metadata"], + # this FieldInfo needs to be created again so that it doesn't include + # the old field info metadata and only the rest of the attributes + Field(**field_dict["attributes"]), ) self._type_adapter: TypeAdapter[Any] = TypeAdapter( Annotated[annotated_args], @@ -207,12 +263,17 @@ def get_schema_from_model_field( if (separate_input_output_schemas or _has_computed_fields(field)) else "validation" ) + field_alias = ( + (field.validation_alias or field.alias) + if field.mode == "validation" + else (field.serialization_alias or field.alias) + ) # This expects that GenerateJsonSchema was already used to generate the definitions json_schema = field_mapping[(field, override_mode or field.mode)] if "$ref" not in json_schema: # TODO remove when deprecating Pydantic v1 # Ref: https://github.com/pydantic/pydantic/blob/d61792cc42c80b13b23e3ffa74bc37ec7c77f7d1/pydantic/schema.py#L207 - json_schema["title"] = field.field_info.title or field.alias.title().replace( + json_schema["title"] = field.field_info.title or field_alias.title().replace( "_", " " ) return json_schema diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 262dba6fd..cc7e55b4b 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -752,7 +752,7 @@ def _validate_value_with_model_field( def _get_multidict_value( field: ModelField, values: Mapping[str, Any], alias: Union[str, None] = None ) -> Any: - alias = alias or field.alias + alias = alias or get_validation_alias(field) if is_sequence_field(field) and isinstance(values, (ImmutableMultiDict, Headers)): value = values.getlist(alias) else: @@ -809,15 +809,13 @@ def request_params_to_args( field.field_info, "convert_underscores", default_convert_underscores ) if convert_underscores: - alias = ( - field.alias - if field.alias != field.name - else field.name.replace("_", "-") - ) + alias = get_validation_alias(field) + if alias == field.name: + alias = alias.replace("_", "-") value = _get_multidict_value(field, received_params, alias=alias) if value is not None: - params_to_process[field.alias] = value - processed_keys.add(alias or field.alias) + params_to_process[get_validation_alias(field)] = value + processed_keys.add(alias or get_validation_alias(field)) for key in received_params.keys(): if key not in processed_keys: @@ -847,7 +845,7 @@ def request_params_to_args( assert isinstance(field_info, (params.Param, temp_pydantic_v1_params.Param)), ( "Params must be subclasses of Param" ) - loc = (field_info.in_.value, field.alias) + loc = (field_info.in_.value, get_validation_alias(field)) v_, errors_ = _validate_value_with_model_field( field=field, value=value, values=values, loc=loc ) @@ -936,8 +934,8 @@ async def _extract_form_body( tg.start_soon(process_fn, sub_value.read) value = serialize_sequence_value(field=field, value=results) if value is not None: - values[field.alias] = value - field_aliases = {field.alias for field in body_fields} + values[get_validation_alias(field)] = value + field_aliases = {get_validation_alias(field) for field in body_fields} for key in received_body.keys(): if key not in field_aliases: param_values = received_body.getlist(key) @@ -979,11 +977,11 @@ async def request_body_to_args( ) return {first_field.name: v_}, errors_ for field in body_fields: - loc = ("body", field.alias) + loc = ("body", get_validation_alias(field)) value: Optional[Any] = None if body_to_process is not None: try: - value = body_to_process.get(field.alias) + value = body_to_process.get(get_validation_alias(field)) # If the received body is a list, not a dict except AttributeError: errors.append(get_missing_field_error(loc)) @@ -1062,3 +1060,8 @@ def get_body_field( field_info=BodyFieldInfo(**BodyFieldInfo_kwargs), ) return final_field + + +def get_validation_alias(field: ModelField) -> str: + va = getattr(field, "validation_alias", None) + return va or field.alias diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index 06c14861a..9fe2044f2 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -19,6 +19,7 @@ from fastapi.dependencies.utils import ( _get_flat_fields_from_params, get_flat_dependant, get_flat_params, + get_validation_alias, ) from fastapi.encoders import jsonable_encoder from fastapi.openapi.constants import METHODS_WITH_BODY, REF_PREFIX @@ -141,7 +142,7 @@ def _get_openapi_operation_parameters( field_mapping=field_mapping, separate_input_output_schemas=separate_input_output_schemas, ) - name = param.alias + name = get_validation_alias(param) convert_underscores = getattr( param.field_info, "convert_underscores", @@ -149,7 +150,7 @@ def _get_openapi_operation_parameters( ) if ( param_type == ParamTypes.header - and param.alias == param.name + and name == param.name and convert_underscores ): name = param.name.replace("_", "-") diff --git a/fastapi/params.py b/fastapi/params.py index 6d07df35e..b6d0f08e3 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -115,6 +115,10 @@ class Param(FieldInfo): # type: ignore[misc] else: kwargs["deprecated"] = deprecated if PYDANTIC_V2: + if serialization_alias in (_Unset, None) and isinstance(alias, str): + serialization_alias = alias + if validation_alias in (_Unset, None): + validation_alias = alias kwargs.update( { "annotation": annotation, @@ -571,6 +575,10 @@ class Body(FieldInfo): # type: ignore[misc] else: kwargs["deprecated"] = deprecated if PYDANTIC_V2: + if serialization_alias in (_Unset, None) and isinstance(alias, str): + serialization_alias = alias + if validation_alias in (_Unset, None): + validation_alias = alias kwargs.update( { "annotation": annotation, diff --git a/pyproject.toml b/pyproject.toml index f8d5fa7c7..ef4440b1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -196,6 +196,7 @@ source = [ "tests", "fastapi" ] +relative_files = true context = '${CONTEXT}' dynamic_context = "test_function" omit = [ diff --git a/tests/test_request_params/__init__.py b/tests/test_request_params/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_request_params/test_body/__init__.py b/tests/test_request_params/test_body/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_request_params/test_body/test_list.py b/tests/test_request_params/test_body/test_list.py new file mode 100644 index 000000000..18a2a2f30 --- /dev/null +++ b/tests/test_request_params/test_body/test_list.py @@ -0,0 +1,483 @@ +from typing import List, Union + +import pytest +from dirty_equals import IsDict, IsOneOf, IsPartialDict +from fastapi import Body, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/required-list-str", operation_id="required_list_str") +async def read_required_list_str(p: Annotated[List[str], Body(embed=True)]): + return {"p": p} + + +class BodyModelRequiredListStr(BaseModel): + p: List[str] + + +@app.post("/model-required-list-str", operation_id="model_required_list_str") +def read_model_required_list_str(p: BodyModelRequiredListStr): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p": { + "items": {"type": "string"}, + "title": "P", + "type": "array", + }, + }, + "required": ["p"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize("json", [None, {}]) +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str_missing(path: str, json: Union[dict, None]): + client = TestClient(app) + response = client.post(path, json=json) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": IsOneOf(["body", "p"], ["body"]), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + { + "detail": [ + { + "loc": IsOneOf(["body", "p"], ["body"]), + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str(path: str): + client = TestClient(app) + response = client.post(path, json={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias + + +@app.post("/required-list-alias", operation_id="required_list_alias") +async def read_required_list_alias( + p: Annotated[List[str], Body(embed=True, alias="p_alias")], +): + return {"p": p} + + +class BodyModelRequiredListAlias(BaseModel): + p: List[str] = Field(alias="p_alias") + + +@app.post("/model-required-list-alias", operation_id="model_required_list_alias") +async def read_model_required_list_alias(p: BodyModelRequiredListAlias): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias", + "/model-required-list-alias", + ], +) +def test_required_list_str_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_alias": { + "items": {"type": "string"}, + "title": "P Alias", + "type": "array", + }, + }, + "required": ["p_alias"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize("json", [None, {}]) +@pytest.mark.parametrize( + "path", + ["/required-list-alias", "/model-required-list-alias"], +) +def test_required_list_alias_missing(path: str, json: Union[dict, None]): + client = TestClient(app) + response = client.post(path, json=json) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": IsOneOf(["body", "p_alias"], ["body"]), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": IsOneOf(["body", "p_alias"], ["body"]), + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-list-alias", "/model-required-list-alias"], +) +def test_required_list_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": ["hello", "world"]}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": ["hello", "world"]}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-list-alias", "/model-required-list-alias"], +) +def test_required_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Validation alias + + +@app.post( + "/required-list-validation-alias", operation_id="required_list_validation_alias" +) +def read_required_list_validation_alias( + p: Annotated[List[str], Body(embed=True, validation_alias="p_val_alias")], +): + return {"p": p} + + +class BodyModelRequiredListValidationAlias(BaseModel): + p: List[str] = Field(validation_alias="p_val_alias") + + +@app.post( + "/model-required-list-validation-alias", + operation_id="model_required_list_validation_alias", +) +async def read_model_required_list_validation_alias( + p: BodyModelRequiredListValidationAlias, +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-list-validation-alias", "/model-required-list-validation-alias"], +) +def test_required_list_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": { + "items": {"type": "string"}, + "title": "P Val Alias", + "type": "array", + }, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize("json", [None, {}]) +@pytest.mark.parametrize( + "path", + [ + "/required-list-validation-alias", + "/model-required-list-validation-alias", + ], +) +def test_required_list_validation_alias_missing(path: str, json: Union[dict, None]): + client = TestClient(app) + response = client.post(path, json=json) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": IsOneOf(["body"], ["body", "p_val_alias"]), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-validation-alias", + "/model-required-list-validation-alias", + ], +) +def test_required_list_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": ["hello", "world"]}) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, IsPartialDict({"p": ["hello", "world"]})), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-validation-alias", + "/model-required-list-validation-alias", + ], +) +def test_required_list_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_val_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/required-list-alias-and-validation-alias", + operation_id="required_list_alias_and_validation_alias", +) +def read_required_list_alias_and_validation_alias( + p: Annotated[ + List[str], Body(embed=True, alias="p_alias", validation_alias="p_val_alias") + ], +): + return {"p": p} + + +class BodyModelRequiredListAliasAndValidationAlias(BaseModel): + p: List[str] = Field(alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-required-list-alias-and-validation-alias", + operation_id="model_required_list_alias_and_validation_alias", +) +def read_model_required_list_alias_and_validation_alias( + p: BodyModelRequiredListAliasAndValidationAlias, +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": { + "items": {"type": "string"}, + "title": "P Val Alias", + "type": "array", + }, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize("json", [None, {}]) +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_missing(path: str, json): + client = TestClient(app) + response = client.post(path, json=json) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": IsOneOf(["body"], ["body", "p_val_alias"]), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": ["hello", "world"]}) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {"p": ["hello", "world"]}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_alias": ["hello", "world"]}) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p_alias": ["hello", "world"]}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_val_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} diff --git a/tests/test_request_params/test_body/test_optional_list.py b/tests/test_request_params/test_body/test_optional_list.py new file mode 100644 index 000000000..e2ecdc7f4 --- /dev/null +++ b/tests/test_request_params/test_body/test_optional_list.py @@ -0,0 +1,575 @@ +from typing import List, Optional + +import pytest +from dirty_equals import IsDict +from fastapi import Body, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/optional-list-str", operation_id="optional_list_str") +async def read_optional_list_str( + p: Annotated[Optional[List[str]], Body(embed=True)] = None, +): + return {"p": p} + + +class BodyModelOptionalListStr(BaseModel): + p: Optional[List[str]] = None + + +@app.post("/model-optional-list-str", operation_id="model_optional_list_str") +async def read_model_optional_list_str(p: BodyModelOptionalListStr): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p": {"items": {"type": "string"}, "type": "array", "title": "P"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +def test_optional_list_str_missing(): + client = TestClient(app) + response = client.post("/optional-list-str") + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +def test_model_optional_list_str_missing(): + client = TestClient(app) + response = client.post("/model-optional-list-str") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + }, + ], + } + ) | IsDict( + { + # TODO: remove when deprecating Pydantic v1 + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + }, + ], + } + ) + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str_missing_empty_dict(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str(path: str): + client = TestClient(app) + response = client.post(path, json={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias + + +@app.post("/optional-list-alias", operation_id="optional_list_alias") +async def read_optional_list_alias( + p: Annotated[Optional[List[str]], Body(embed=True, alias="p_alias")] = None, +): + return {"p": p} + + +class BodyModelOptionalListAlias(BaseModel): + p: Optional[List[str]] = Field(None, alias="p_alias") + + +@app.post("/model-optional-list-alias", operation_id="model_optional_list_alias") +async def read_model_optional_list_alias(p: BodyModelOptionalListAlias): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias", + "/model-optional-list-alias", + ], +) +def test_optional_list_str_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_alias": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_alias": { + "items": {"type": "string"}, + "type": "array", + "title": "P Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) + + +def test_optional_list_alias_missing(): + client = TestClient(app) + response = client.post("/optional-list-alias") + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +def test_model_optional_list_alias_missing(): + client = TestClient(app) + response = client.post("/model-optional-list-alias") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + }, + ], + } + ) | IsDict( + { + # TODO: remove when deprecating Pydantic v1 + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + }, + ], + } + ) + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_missing_empty_dict(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_alias": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Validation alias + + +@app.post( + "/optional-list-validation-alias", operation_id="optional_list_validation_alias" +) +def read_optional_list_validation_alias( + p: Annotated[ + Optional[List[str]], Body(embed=True, validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class BodyModelOptionalListValidationAlias(BaseModel): + p: Optional[List[str]] = Field(None, validation_alias="p_val_alias") + + +@app.post( + "/model-optional-list-validation-alias", + operation_id="model_optional_list_validation_alias", +) +def read_model_optional_list_validation_alias( + p: BodyModelOptionalListValidationAlias, +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_val_alias": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_val_alias": { + "items": {"type": "string"}, + "type": "array", + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) + + +def test_optional_list_validation_alias_missing(): + client = TestClient(app) + response = client.post("/optional-list-validation-alias") + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +def test_model_optional_list_validation_alias_missing(): + client = TestClient(app) + response = client.post("/model-optional-list-validation-alias") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + }, + ], + } + ) | IsDict( + { + # TODO: remove when deprecating Pydantic v1 + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + }, + ], + } + ) + + +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_missing_empty_dict(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-validation-alias", + "/model-optional-list-validation-alias", + ], +) +def test_optional_list_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-validation-alias", + "/model-optional-list-validation-alias", + ], +) +def test_optional_list_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_val_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/optional-list-alias-and-validation-alias", + operation_id="optional_list_alias_and_validation_alias", +) +def read_optional_list_alias_and_validation_alias( + p: Annotated[ + Optional[List[str]], + Body(embed=True, alias="p_alias", validation_alias="p_val_alias"), + ] = None, +): + return {"p": p} + + +class BodyModelOptionalListAliasAndValidationAlias(BaseModel): + p: Optional[List[str]] = Field( + None, alias="p_alias", validation_alias="p_val_alias" + ) + + +@app.post( + "/model-optional-list-alias-and-validation-alias", + operation_id="model_optional_list_alias_and_validation_alias", +) +def read_model_optional_list_alias_and_validation_alias( + p: BodyModelOptionalListAliasAndValidationAlias, +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_val_alias": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_val_alias": { + "items": {"type": "string"}, + "type": "array", + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) + + +def test_optional_list_alias_and_validation_alias_missing(): + client = TestClient(app) + response = client.post("/optional-list-alias-and-validation-alias") + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +def test_model_optional_list_alias_and_validation_alias_missing(): + client = TestClient(app) + response = client.post("/model-optional-list-alias-and-validation-alias") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + }, + ], + } + ) | IsDict( + { + # TODO: remove when deprecating Pydantic v1 + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + }, + ], + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_missing_empty_dict(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_alias": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_val_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + assert response.json() == { + "p": [ + "hello", + "world", + ] + } diff --git a/tests/test_request_params/test_body/test_optional_str.py b/tests/test_request_params/test_body/test_optional_str.py new file mode 100644 index 000000000..a49f5a367 --- /dev/null +++ b/tests/test_request_params/test_body/test_optional_str.py @@ -0,0 +1,544 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import Body, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/optional-str", operation_id="optional_str") +async def read_optional_str(p: Annotated[Optional[str], Body(embed=True)] = None): + return {"p": p} + + +class BodyModelOptionalStr(BaseModel): + p: Optional[str] = None + + +@app.post("/model-optional-str", operation_id="model_optional_str") +async def read_model_optional_str(p: BodyModelOptionalStr): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p": {"type": "string", "title": "P"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +def test_optional_str_missing(): + client = TestClient(app) + response = client.post("/optional-str") + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +def test_model_optional_str_missing(): + client = TestClient(app) + response = client.post("/model-optional-str") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + }, + ], + } + ) | IsDict( + { + # TODO: remove when deprecating Pydantic v1 + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + }, + ], + } + ) + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_missing_empty_dict(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str(path: str): + client = TestClient(app) + response = client.post(path, json={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.post("/optional-alias", operation_id="optional_alias") +async def read_optional_alias( + p: Annotated[Optional[str], Body(embed=True, alias="p_alias")] = None, +): + return {"p": p} + + +class BodyModelOptionalAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias") + + +@app.post("/model-optional-alias", operation_id="model_optional_alias") +async def read_model_optional_alias(p: BodyModelOptionalAlias): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-alias", + "/model-optional-alias", + ], +) +def test_optional_str_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_alias": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_alias": {"type": "string", "title": "P Alias"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +def test_optional_alias_missing(): + client = TestClient(app) + response = client.post("/optional-alias") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +def test_model_optional_alias_missing(): + client = TestClient(app) + response = client.post("/model-optional-alias") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + }, + ], + } + ) | IsDict( + { + # TODO: remove when deprecating Pydantic v1 + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + }, + ], + } + ) + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_model_optional_alias_missing_empty_dict(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.post("/optional-validation-alias", operation_id="optional_validation_alias") +def read_optional_validation_alias( + p: Annotated[ + Optional[str], Body(embed=True, validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class BodyModelOptionalValidationAlias(BaseModel): + p: Optional[str] = Field(None, validation_alias="p_val_alias") + + +@app.post( + "/model-optional-validation-alias", operation_id="model_optional_validation_alias" +) +def read_model_optional_validation_alias( + p: BodyModelOptionalValidationAlias, +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_optional_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_val_alias": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_val_alias": {"type": "string", "title": "P Val Alias"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@needs_pydanticv2 +def test_optional_validation_alias_missing(): + client = TestClient(app) + response = client.post("/optional-validation-alias") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +def test_model_optional_validation_alias_missing(): + client = TestClient(app) + response = client.post("/model-optional-validation-alias") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + }, + ], + } + ) | IsDict( + { + # TODO: remove when deprecating Pydantic v1 + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + }, + ], + } + ) + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_model_optional_validation_alias_missing_empty_dict(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_val_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/optional-alias-and-validation-alias", + operation_id="optional_alias_and_validation_alias", +) +def read_optional_alias_and_validation_alias( + p: Annotated[ + Optional[str], Body(embed=True, alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class BodyModelOptionalAliasAndValidationAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-optional-alias-and-validation-alias", + operation_id="model_optional_alias_and_validation_alias", +) +def read_model_optional_alias_and_validation_alias( + p: BodyModelOptionalAliasAndValidationAlias, +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_val_alias": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_val_alias": {"type": "string", "title": "P Val Alias"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@needs_pydanticv2 +def test_optional_alias_and_validation_alias_missing(): + client = TestClient(app) + response = client.post("/optional-alias-and-validation-alias") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +def test_model_optional_alias_and_validation_alias_missing(): + client = TestClient(app) + response = client.post("/model-optional-alias-and-validation-alias") + assert response.status_code == 422, response.text + assert response.json() == IsDict( + { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + }, + ], + } + ) | IsDict( + { + # TODO: remove when deprecating Pydantic v1 + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + }, + ], + } + ) + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_model_optional_alias_and_validation_alias_missing_empty_dict(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_val_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_body/test_required_str.py b/tests/test_request_params/test_body/test_required_str.py new file mode 100644 index 000000000..18c660cad --- /dev/null +++ b/tests/test_request_params/test_body/test_required_str.py @@ -0,0 +1,472 @@ +from typing import Any, Dict, Union + +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import Body, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/required-str", operation_id="required_str") +async def read_required_str(p: Annotated[str, Body(embed=True)]): + return {"p": p} + + +class BodyModelRequiredStr(BaseModel): + p: str + + +@app.post("/model-required-str", operation_id="model_required_str") +async def read_model_required_str(p: BodyModelRequiredStr): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p": {"title": "P", "type": "string"}, + }, + "required": ["p"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize("json", [None, {}]) +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_missing(path: str, json: Union[Dict[str, Any], None]): + client = TestClient(app) + response = client.post(path, json=json) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": IsOneOf(["body"], ["body", "p"]), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": IsOneOf(["body"], ["body", "p"]), + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str(path: str): + client = TestClient(app) + response = client.post(path, json={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.post("/required-alias", operation_id="required_alias") +async def read_required_alias( + p: Annotated[str, Body(embed=True, alias="p_alias")], +): + return {"p": p} + + +class BodyModelRequiredAlias(BaseModel): + p: str = Field(alias="p_alias") + + +@app.post("/model-required-alias", operation_id="model_required_alias") +async def read_model_required_alias(p: BodyModelRequiredAlias): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + [ + "/required-alias", + "/model-required-alias", + ], +) +def test_required_str_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_alias": {"title": "P Alias", "type": "string"}, + }, + "required": ["p_alias"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize("json", [None, {}]) +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_alias_missing(path: str, json: Union[Dict[str, Any], None]): + client = TestClient(app) + response = client.post(path, json=json) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": IsOneOf(["body", "p_alias"], ["body"]), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": IsOneOf(["body", "p_alias"], ["body"]), + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": "hello"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": IsOneOf(["body", "p_alias"], ["body"]), + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_alias": "hello"}) + assert response.status_code == 200, response.text + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.post("/required-validation-alias", operation_id="required_validation_alias") +def read_required_validation_alias( + p: Annotated[str, Body(embed=True, validation_alias="p_val_alias")], +): + return {"p": p} + + +class BodyModelRequiredValidationAlias(BaseModel): + p: str = Field(validation_alias="p_val_alias") + + +@app.post( + "/model-required-validation-alias", operation_id="model_required_validation_alias" +) +def read_model_required_validation_alias( + p: BodyModelRequiredValidationAlias, +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-validation-alias", "/model-required-validation-alias"], +) +def test_required_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": {"title": "P Val Alias", "type": "string"}, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize("json", [None, {}]) +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_missing( + path: str, json: Union[Dict[str, Any], None] +): + client = TestClient(app) + response = client.post(path, json=json) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": IsOneOf(["body", "p_val_alias"], ["body"]), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": "hello"}) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_val_alias": "hello"}) + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/required-alias-and-validation-alias", + operation_id="required_alias_and_validation_alias", +) +def read_required_alias_and_validation_alias( + p: Annotated[ + str, Body(embed=True, alias="p_alias", validation_alias="p_val_alias") + ], +): + return {"p": p} + + +class BodyModelRequiredAliasAndValidationAlias(BaseModel): + p: str = Field(alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-required-alias-and-validation-alias", + operation_id="model_required_alias_and_validation_alias", +) +def read_model_required_alias_and_validation_alias( + p: BodyModelRequiredAliasAndValidationAlias, +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": {"title": "P Val Alias", "type": "string"}, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize("json", [None, {}]) +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_missing( + path: str, json: Union[Dict[str, Any], None] +): + client = TestClient(app) + response = client.post(path, json=json) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": IsOneOf(["body"], ["body", "p_val_alias"]), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, json={"p": "hello"}) + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_alias": "hello"}) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p_alias": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, json={"p_val_alias": "hello"}) + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_body/utils.py b/tests/test_request_params/test_body/utils.py new file mode 100644 index 000000000..5151a82d3 --- /dev/null +++ b/tests/test_request_params/test_body/utils.py @@ -0,0 +1,7 @@ +from typing import Any, Dict + + +def get_body_model_name(openapi: Dict[str, Any], path: str) -> str: + body = openapi["paths"][path]["post"]["requestBody"] + body_schema = body["content"]["application/json"]["schema"] + return body_schema.get("$ref", "").split("/")[-1] diff --git a/tests/test_request_params/test_cookie/__init__.py b/tests/test_request_params/test_cookie/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_request_params/test_cookie/test_list.py b/tests/test_request_params/test_cookie/test_list.py new file mode 100644 index 000000000..4ae80e001 --- /dev/null +++ b/tests/test_request_params/test_cookie/test_list.py @@ -0,0 +1,3 @@ +# Currently, there is no way to pass multiple cookies with the same name. +# The only way to pass multiple values for cookie params is to serialize them using +# a comma as a delimiter, but this is not currently supported by Starlette. diff --git a/tests/test_request_params/test_cookie/test_optional_list.py b/tests/test_request_params/test_cookie/test_optional_list.py new file mode 100644 index 000000000..4ae80e001 --- /dev/null +++ b/tests/test_request_params/test_cookie/test_optional_list.py @@ -0,0 +1,3 @@ +# Currently, there is no way to pass multiple cookies with the same name. +# The only way to pass multiple values for cookie params is to serialize them using +# a comma as a delimiter, but this is not currently supported by Starlette. diff --git a/tests/test_request_params/test_cookie/test_optional_str.py b/tests/test_request_params/test_cookie/test_optional_str.py new file mode 100644 index 000000000..1eec45689 --- /dev/null +++ b/tests/test_request_params/test_cookie/test_optional_str.py @@ -0,0 +1,362 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import Cookie, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/optional-str") +async def read_optional_str(p: Annotated[Optional[str], Cookie()] = None): + return {"p": p} + + +class CookieModelOptionalStr(BaseModel): + p: Optional[str] = None + + +@app.get("/model-optional-str") +async def read_model_optional_str(p: Annotated[CookieModelOptionalStr, Cookie()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P", + }, + "name": "p", + "in": "cookie", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": {"title": "P", "type": "string"}, + "name": "p", + "in": "cookie", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str(path: str): + client = TestClient(app) + client.cookies.set("p", "hello") + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.get("/optional-alias") +async def read_optional_alias( + p: Annotated[Optional[str], Cookie(alias="p_alias")] = None, +): + return {"p": p} + + +class CookieModelOptionalAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias") + + +@app.get("/model-optional-alias") +async def read_model_optional_alias(p: Annotated[CookieModelOptionalAlias, Cookie()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Alias", + }, + "name": "p_alias", + "in": "cookie", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": {"title": "P Alias", "type": "string"}, + "name": "p_alias", + "in": "cookie", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_by_name(path: str): + client = TestClient(app) + client.cookies.set("p", "hello") + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-alias", + "/model-optional-alias", + ], +) +def test_optional_alias_by_alias(path: str): + client = TestClient(app) + client.cookies.set("p_alias", "hello") + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.get("/optional-validation-alias") +def read_optional_validation_alias( + p: Annotated[Optional[str], Cookie(validation_alias="p_val_alias")] = None, +): + return {"p": p} + + +class CookieModelOptionalValidationAlias(BaseModel): + p: Optional[str] = Field(None, validation_alias="p_val_alias") + + +@app.get("/model-optional-validation-alias") +def read_model_optional_validation_alias( + p: Annotated[CookieModelOptionalValidationAlias, Cookie()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_optional_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "cookie", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_optional_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_name(path: str): + client = TestClient(app) + client.cookies.set("p", "hello") + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + client.cookies.set("p_val_alias", "hello") + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/optional-alias-and-validation-alias") +def read_optional_alias_and_validation_alias( + p: Annotated[ + Optional[str], Cookie(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class CookieModelOptionalAliasAndValidationAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias") + + +@app.get("/model-optional-alias-and-validation-alias") +def read_model_optional_alias_and_validation_alias( + p: Annotated[CookieModelOptionalAliasAndValidationAlias, Cookie()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "cookie", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + client.cookies.set("p", "hello") + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + client.cookies.set("p_alias", "hello") + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + client.cookies.set("p_val_alias", "hello") + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_cookie/test_required_str.py b/tests/test_request_params/test_cookie/test_required_str.py new file mode 100644 index 000000000..6d0fa2ef2 --- /dev/null +++ b/tests/test_request_params/test_cookie/test_required_str.py @@ -0,0 +1,462 @@ +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import Cookie, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/required-str") +async def read_required_str(p: Annotated[str, Cookie()]): + return {"p": p} + + +class CookieModelRequiredStr(BaseModel): + p: str + + +@app.get("/model-required-str") +async def read_model_required_str(p: Annotated[CookieModelRequiredStr, Cookie()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P", "type": "string"}, + "name": "p", + "in": "cookie", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["cookie", "p"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["cookie", "p"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str(path: str): + client = TestClient(app) + client.cookies.set("p", "hello") + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.get("/required-alias") +async def read_required_alias(p: Annotated[str, Cookie(alias="p_alias")]): + return {"p": p} + + +class CookieModelRequiredAlias(BaseModel): + p: str = Field(alias="p_alias") + + +@app.get("/model-required-alias") +async def read_model_required_alias(p: Annotated[CookieModelRequiredAlias, Cookie()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P Alias", "type": "string"}, + "name": "p_alias", + "in": "cookie", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["cookie", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["cookie", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-alias", + "/model-required-alias", + ], +) +def test_required_alias_by_name(path: str): + client = TestClient(app) + client.cookies.set("p", "hello") + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["cookie", "p_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + {"p": "hello"}, + ), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["cookie", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-alias", + "/model-required-alias", + ], +) +def test_required_alias_by_alias(path: str): + client = TestClient(app) + client.cookies.set("p_alias", "hello") + response = client.get(path) + assert response.status_code == 200, response.text + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.get("/required-validation-alias") +def read_required_validation_alias( + p: Annotated[str, Cookie(validation_alias="p_val_alias")], +): + return {"p": p} + + +class CookieModelRequiredValidationAlias(BaseModel): + p: str = Field(validation_alias="p_val_alias") + + +@app.get("/model-required-validation-alias") +def read_model_required_validation_alias( + p: Annotated[CookieModelRequiredValidationAlias, Cookie()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-validation-alias", "/model-required-validation-alias"], +) +def test_required_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P Val Alias", "type": "string"}, + "name": "p_val_alias", + "in": "cookie", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "cookie", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_name(path: str): + client = TestClient(app) + client.cookies.set("p", "hello") + response = client.get(path) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["cookie", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + client.cookies.set("p_val_alias", "hello") + response = client.get(path) + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/required-alias-and-validation-alias") +def read_required_alias_and_validation_alias( + p: Annotated[str, Cookie(alias="p_alias", validation_alias="p_val_alias")], +): + return {"p": p} + + +class CookieModelRequiredAliasAndValidationAlias(BaseModel): + p: str = Field(alias="p_alias", validation_alias="p_val_alias") + + +@app.get("/model-required-alias-and-validation-alias") +def read_model_required_alias_and_validation_alias( + p: Annotated[CookieModelRequiredAliasAndValidationAlias, Cookie()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P Val Alias", "type": "string"}, + "name": "p_val_alias", + "in": "cookie", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "cookie", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + client.cookies.set("p", "hello") + response = client.get(path) + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "cookie", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf( + None, + {"p": "hello"}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + client.cookies.set("p_alias", "hello") + response = client.get(path) + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["cookie", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + {"p_alias": "hello"}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + client.cookies.set("p_val_alias", "hello") + response = client.get(path) + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_file/__init__.py b/tests/test_request_params/test_file/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_request_params/test_file/test_list.py b/tests/test_request_params/test_file/test_list.py new file mode 100644 index 000000000..94a33967f --- /dev/null +++ b/tests/test_request_params/test_file/test_list.py @@ -0,0 +1,557 @@ +from typing import List + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, UploadFile +from fastapi.testclient import TestClient +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/list-bytes", operation_id="list_bytes") +async def read_list_bytes(p: Annotated[List[bytes], File()]): + return {"file_size": [len(file) for file in p]} + + +@app.post("/list-uploadfile", operation_id="list_uploadfile") +async def read_list_uploadfile(p: Annotated[List[UploadFile], File()]): + return {"file_size": [file.size for file in p]} + + +@pytest.mark.parametrize( + "path", + [ + "/list-bytes", + "/list-uploadfile", + ], +) +def test_list_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p": ( + IsDict( + { + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + "title": "P", + }, + ) + | IsDict( + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + "title": "P", + }, + ) + ) + }, + "required": ["p"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/list-bytes", + "/list-uploadfile", + ], +) +def test_list_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p"], + "msg": "Field required", + "input": None, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/list-bytes", + "/list-uploadfile", + ], +) +def test_list(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello"), ("p", b"world")]) + assert response.status_code == 200 + assert response.json() == {"file_size": [5, 5]} + + +# ===================================================================================== +# Alias + + +@app.post("/list-bytes-alias", operation_id="list_bytes_alias") +async def read_list_bytes_alias(p: Annotated[List[bytes], File(alias="p_alias")]): + return {"file_size": [len(file) for file in p]} + + +@app.post("/list-uploadfile-alias", operation_id="list_uploadfile_alias") +async def read_list_uploadfile_alias( + p: Annotated[List[UploadFile], File(alias="p_alias")], +): + return {"file_size": [file.size for file in p]} + + +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias", + "/list-uploadfile-alias", + ], +) +def test_list_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_alias": ( + IsDict( + { + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + "title": "P Alias", + }, + ) + | IsDict( + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + "title": "P Alias", + }, + ) + ) + }, + "required": ["p_alias"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias", + "/list-uploadfile-alias", + ], +) +def test_list_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": None, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias", + "/list-uploadfile-alias", + ], +) +def test_list_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello"), ("p", b"world")]) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": None, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias", + "/list-uploadfile-alias", + ], +) +def test_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_alias", b"hello"), ("p_alias", b"world")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": [5, 5]} + + +# ===================================================================================== +# Validation alias + + +@app.post("/list-bytes-validation-alias", operation_id="list_bytes_validation_alias") +def read_list_bytes_validation_alias( + p: Annotated[List[bytes], File(validation_alias="p_val_alias")], +): + return {"file_size": [len(file) for file in p]} + + +@app.post( + "/list-uploadfile-validation-alias", + operation_id="list_uploadfile_validation_alias", +) +def read_list_uploadfile_validation_alias( + p: Annotated[List[UploadFile], File(validation_alias="p_val_alias")], +): + return {"file_size": [file.size for file in p]} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-validation-alias", + "/list-uploadfile-validation-alias", + ], +) +def test_list_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": ( + IsDict( + { + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + ) + | IsDict( + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + "title": "P Val Alias", + }, + ) + ) + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-validation-alias", + "/list-uploadfile-validation-alias", + ], +) +def test_list_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-validation-alias", + "/list-uploadfile-validation-alias", + ], +) +def test_list_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello"), ("p", b"world")]) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-validation-alias", + "/list-uploadfile-validation-alias", + ], +) +def test_list_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post( + path, files=[("p_val_alias", b"hello"), ("p_val_alias", b"world")] + ) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": [5, 5]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/list-bytes-alias-and-validation-alias", + operation_id="list_bytes_alias_and_validation_alias", +) +def read_list_bytes_alias_and_validation_alias( + p: Annotated[List[bytes], File(alias="p_alias", validation_alias="p_val_alias")], +): + return {"file_size": [len(file) for file in p]} + + +@app.post( + "/list-uploadfile-alias-and-validation-alias", + operation_id="list_uploadfile_alias_and_validation_alias", +) +def read_list_uploadfile_alias_and_validation_alias( + p: Annotated[ + List[UploadFile], File(alias="p_alias", validation_alias="p_val_alias") + ], +): + return {"file_size": [file.size for file in p]} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias-and-validation-alias", + "/list-uploadfile-alias-and-validation-alias", + ], +) +def test_list_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": ( + IsDict( + { + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + ) + | IsDict( + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + "title": "P Val Alias", + }, + ) + ) + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias-and-validation-alias", + "/list-uploadfile-alias-and-validation-alias", + ], +) +def test_list_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias-and-validation-alias", + "/list-uploadfile-alias-and-validation-alias", + ], +) +def test_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", "hello"), ("p", "world")]) + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias-and-validation-alias", + "/list-uploadfile-alias-and-validation-alias", + ], +) +def test_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_alias", b"hello"), ("p_alias", b"world")]) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/list-bytes-alias-and-validation-alias", + "/list-uploadfile-alias-and-validation-alias", + ], +) +def test_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post( + path, files=[("p_val_alias", b"hello"), ("p_val_alias", b"world")] + ) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": [5, 5]} diff --git a/tests/test_request_params/test_file/test_optional.py b/tests/test_request_params/test_file/test_optional.py new file mode 100644 index 000000000..2c1ca9530 --- /dev/null +++ b/tests/test_request_params/test_file/test_optional.py @@ -0,0 +1,408 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, UploadFile +from fastapi.testclient import TestClient +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/optional-bytes", operation_id="optional_bytes") +async def read_optional_bytes(p: Annotated[Optional[bytes], File()] = None): + return {"file_size": len(p) if p else None} + + +@app.post("/optional-uploadfile", operation_id="optional_uploadfile") +async def read_optional_uploadfile(p: Annotated[Optional[UploadFile], File()] = None): + return {"file_size": p.size if p else None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes", + "/optional-uploadfile", + ], +) +def test_optional_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p": ( + IsDict( + { + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + "title": "P", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "P", "type": "string", "format": "binary"} + ) + ), + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes", + "/optional-uploadfile", + ], +) +def test_optional_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes", + "/optional-uploadfile", + ], +) +def test_optional(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello")]) + assert response.status_code == 200 + assert response.json() == {"file_size": 5} + + +# ===================================================================================== +# Alias + + +@app.post("/optional-bytes-alias", operation_id="optional_bytes_alias") +async def read_optional_bytes_alias( + p: Annotated[Optional[bytes], File(alias="p_alias")] = None, +): + return {"file_size": len(p) if p else None} + + +@app.post("/optional-uploadfile-alias", operation_id="optional_uploadfile_alias") +async def read_optional_uploadfile_alias( + p: Annotated[Optional[UploadFile], File(alias="p_alias")] = None, +): + return {"file_size": p.size if p else None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias", + "/optional-uploadfile-alias", + ], +) +def test_optional_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_alias": ( + IsDict( + { + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + "title": "P Alias", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "P Alias", "type": "string", "format": "binary"} + ) + ), + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias", + "/optional-uploadfile-alias", + ], +) +def test_optional_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"file_size": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias", + "/optional-uploadfile-alias", + ], +) +def test_optional_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello")]) + assert response.status_code == 200 + assert response.json() == {"file_size": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias", + "/optional-uploadfile-alias", + ], +) +def test_optional_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_alias", b"hello")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": 5} + + +# ===================================================================================== +# Validation alias + + +@app.post( + "/optional-bytes-validation-alias", operation_id="optional_bytes_validation_alias" +) +def read_optional_bytes_validation_alias( + p: Annotated[Optional[bytes], File(validation_alias="p_val_alias")] = None, +): + return {"file_size": len(p) if p else None} + + +@app.post( + "/optional-uploadfile-validation-alias", + operation_id="optional_uploadfile_validation_alias", +) +def read_optional_uploadfile_validation_alias( + p: Annotated[Optional[UploadFile], File(validation_alias="p_val_alias")] = None, +): + return {"file_size": p.size if p else None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-validation-alias", + "/optional-uploadfile-validation-alias", + ], +) +def test_optional_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": ( + IsDict( + { + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + "title": "P Val Alias", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "P Val Alias", "type": "string", "format": "binary"} + ) + ), + }, + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-validation-alias", + "/optional-uploadfile-validation-alias", + ], +) +def test_optional_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-validation-alias", + "/optional-uploadfile-validation-alias", + ], +) +def test_optional_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-validation-alias", + "/optional-uploadfile-validation-alias", + ], +) +def test_optional_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_val_alias", b"hello")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": 5} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/optional-bytes-alias-and-validation-alias", + operation_id="optional_bytes_alias_and_validation_alias", +) +def read_optional_bytes_alias_and_validation_alias( + p: Annotated[ + Optional[bytes], File(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"file_size": len(p) if p else None} + + +@app.post( + "/optional-uploadfile-alias-and-validation-alias", + operation_id="optional_uploadfile_alias_and_validation_alias", +) +def read_optional_uploadfile_alias_and_validation_alias( + p: Annotated[ + Optional[UploadFile], File(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"file_size": p.size if p else None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias-and-validation-alias", + "/optional-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": ( + IsDict( + { + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + "title": "P Val Alias", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + {"title": "P Val Alias", "type": "string", "format": "binary"} + ) + ), + }, + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias-and-validation-alias", + "/optional-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias-and-validation-alias", + "/optional-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias-and-validation-alias", + "/optional-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_alias", b"hello")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-bytes-alias-and-validation-alias", + "/optional-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_val_alias", b"hello")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": 5} diff --git a/tests/test_request_params/test_file/test_optional_list.py b/tests/test_request_params/test_file/test_optional_list.py new file mode 100644 index 000000000..20e36501f --- /dev/null +++ b/tests/test_request_params/test_file/test_optional_list.py @@ -0,0 +1,434 @@ +from typing import List, Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, UploadFile +from fastapi.testclient import TestClient +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/optional-list-bytes") +async def read_optional_list_bytes(p: Annotated[Optional[List[bytes]], File()] = None): + return {"file_size": [len(file) for file in p] if p else None} + + +@app.post("/optional-list-uploadfile") +async def read_optional_list_uploadfile( + p: Annotated[Optional[List[UploadFile]], File()] = None, +): + return {"file_size": [file.size for file in p] if p else None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes", + "/optional-list-uploadfile", + ], +) +def test_optional_list_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p": ( + IsDict( + { + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + "title": "P", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "P", + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + ) + ), + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes", + "/optional-list-uploadfile", + ], +) +def test_optional_list_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes", + "/optional-list-uploadfile", + ], +) +def test_optional_list(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello"), ("p", b"world")]) + assert response.status_code == 200 + assert response.json() == {"file_size": [5, 5]} + + +# ===================================================================================== +# Alias + + +@app.post("/optional-list-bytes-alias") +async def read_optional_list_bytes_alias( + p: Annotated[Optional[List[bytes]], File(alias="p_alias")] = None, +): + return {"file_size": [len(file) for file in p] if p else None} + + +@app.post("/optional-list-uploadfile-alias") +async def read_optional_list_uploadfile_alias( + p: Annotated[Optional[List[UploadFile]], File(alias="p_alias")] = None, +): + return {"file_size": [file.size for file in p] if p else None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias", + "/optional-list-uploadfile-alias", + ], +) +def test_optional_list_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_alias": ( + IsDict( + { + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + "title": "P Alias", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "P Alias", + "type": "array", + "items": {"type": "string", "format": "binary"}, + } + ) + ), + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias", + "/optional-list-uploadfile-alias", + ], +) +def test_optional_list_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"file_size": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias", + "/optional-list-uploadfile-alias", + ], +) +def test_optional_list_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello"), ("p", b"world")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias", + "/optional-list-uploadfile-alias", + ], +) +def test_optional_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_alias", b"hello"), ("p_alias", b"world")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": [5, 5]} + + +# ===================================================================================== +# Validation alias + + +@app.post("/optional-list-bytes-validation-alias") +def read_optional_list_bytes_validation_alias( + p: Annotated[Optional[List[bytes]], File(validation_alias="p_val_alias")] = None, +): + return {"file_size": [len(file) for file in p] if p else None} + + +@app.post("/optional-list-uploadfile-validation-alias") +def read_optional_list_uploadfile_validation_alias( + p: Annotated[ + Optional[List[UploadFile]], File(validation_alias="p_val_alias") + ] = None, +): + return {"file_size": [file.size for file in p] if p else None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-validation-alias", + "/optional-list-uploadfile-validation-alias", + ], +) +def test_optional_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": ( + IsDict( + { + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + "title": "P Val Alias", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "P Val Alias", + "type": "array", + "items": {"type": "string", "format": "binary"}, + } + ) + ), + }, + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-validation-alias", + "/optional-list-uploadfile-validation-alias", + ], +) +def test_optional_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-validation-alias", + "/optional-list-uploadfile-validation-alias", + ], +) +def test_optional_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello"), ("p", b"world")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-validation-alias", + "/optional-list-uploadfile-validation-alias", + ], +) +def test_optional_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post( + path, files=[("p_val_alias", b"hello"), ("p_val_alias", b"world")] + ) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": [5, 5]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post("/optional-list-bytes-alias-and-validation-alias") +def read_optional_list_bytes_alias_and_validation_alias( + p: Annotated[ + Optional[List[bytes]], File(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"file_size": [len(file) for file in p] if p else None} + + +@app.post("/optional-list-uploadfile-alias-and-validation-alias") +def read_optional_list_uploadfile_alias_and_validation_alias( + p: Annotated[ + Optional[List[UploadFile]], + File(alias="p_alias", validation_alias="p_val_alias"), + ] = None, +): + return {"file_size": [file.size for file in p] if p else None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias-and-validation-alias", + "/optional-list-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": ( + IsDict( + { + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + "title": "P Val Alias", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "title": "P Val Alias", + "type": "array", + "items": {"type": "string", "format": "binary"}, + } + ) + ), + }, + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias-and-validation-alias", + "/optional-list-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias-and-validation-alias", + "/optional-list-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias-and-validation-alias", + "/optional-list-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_alias", b"hello"), ("p_alias", b"world")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-bytes-alias-and-validation-alias", + "/optional-list-uploadfile-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post( + path, files=[("p_val_alias", b"hello"), ("p_val_alias", b"world")] + ) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": [5, 5]} diff --git a/tests/test_request_params/test_file/test_required.py b/tests/test_request_params/test_file/test_required.py new file mode 100644 index 000000000..f041ac1cc --- /dev/null +++ b/tests/test_request_params/test_file/test_required.py @@ -0,0 +1,479 @@ +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, UploadFile +from fastapi.testclient import TestClient +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/required-bytes", operation_id="required_bytes") +async def read_required_bytes(p: Annotated[bytes, File()]): + return {"file_size": len(p)} + + +@app.post("/required-uploadfile", operation_id="required_uploadfile") +async def read_required_uploadfile(p: Annotated[UploadFile, File()]): + return {"file_size": p.size} + + +@pytest.mark.parametrize( + "path", + [ + "/required-bytes", + "/required-uploadfile", + ], +) +def test_required_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p": {"title": "P", "type": "string", "format": "binary"}, + }, + "required": ["p"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/required-bytes", + "/required-uploadfile", + ], +) +def test_required_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p"], + "msg": "Field required", + "input": None, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-bytes", + "/required-uploadfile", + ], +) +def test_required(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello")]) + assert response.status_code == 200 + assert response.json() == {"file_size": 5} + + +# ===================================================================================== +# Alias + + +@app.post("/required-bytes-alias", operation_id="required_bytes_alias") +async def read_required_bytes_alias(p: Annotated[bytes, File(alias="p_alias")]): + return {"file_size": len(p)} + + +@app.post("/required-uploadfile-alias", operation_id="required_uploadfile_alias") +async def read_required_uploadfile_alias( + p: Annotated[UploadFile, File(alias="p_alias")], +): + return {"file_size": p.size} + + +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias", + "/required-uploadfile-alias", + ], +) +def test_required_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_alias": {"title": "P Alias", "type": "string", "format": "binary"}, + }, + "required": ["p_alias"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias", + "/required-uploadfile-alias", + ], +) +def test_required_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": None, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias", + "/required-uploadfile-alias", + ], +) +def test_required_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello")]) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": None, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias", + "/required-uploadfile-alias", + ], +) +def test_required_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_alias", b"hello")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": 5} + + +# ===================================================================================== +# Validation alias + + +@app.post( + "/required-bytes-validation-alias", operation_id="required_bytes_validation_alias" +) +def read_required_bytes_validation_alias( + p: Annotated[bytes, File(validation_alias="p_val_alias")], +): + return {"file_size": len(p)} + + +@app.post( + "/required-uploadfile-validation-alias", + operation_id="required_uploadfile_validation_alias", +) +def read_required_uploadfile_validation_alias( + p: Annotated[UploadFile, File(validation_alias="p_val_alias")], +): + return {"file_size": p.size} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-validation-alias", + "/required-uploadfile-validation-alias", + ], +) +def test_required_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": { + "title": "P Val Alias", + "type": "string", + "format": "binary", + }, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-validation-alias", + "/required-uploadfile-validation-alias", + ], +) +def test_required_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-validation-alias", + "/required-uploadfile-validation-alias", + ], +) +def test_required_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files=[("p", b"hello")]) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-validation-alias", + "/required-uploadfile-validation-alias", + ], +) +def test_required_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_val_alias", b"hello")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": 5} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/required-bytes-alias-and-validation-alias", + operation_id="required_bytes_alias_and_validation_alias", +) +def read_required_bytes_alias_and_validation_alias( + p: Annotated[bytes, File(alias="p_alias", validation_alias="p_val_alias")], +): + return {"file_size": len(p)} + + +@app.post( + "/required-uploadfile-alias-and-validation-alias", + operation_id="required_uploadfile_alias_and_validation_alias", +) +def read_required_uploadfile_alias_and_validation_alias( + p: Annotated[UploadFile, File(alias="p_alias", validation_alias="p_val_alias")], +): + return {"file_size": p.size} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias-and-validation-alias", + "/required-uploadfile-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": { + "title": "P Val Alias", + "type": "string", + "format": "binary", + }, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias-and-validation-alias", + "/required-uploadfile-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias-and-validation-alias", + "/required-uploadfile-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, files={"p": "hello"}) + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias-and-validation-alias", + "/required-uploadfile-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_alias", b"hello")]) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-bytes-alias-and-validation-alias", + "/required-uploadfile-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, files=[("p_val_alias", b"hello")]) + assert response.status_code == 200, response.text + assert response.json() == {"file_size": 5} diff --git a/tests/test_request_params/test_file/utils.py b/tests/test_request_params/test_file/utils.py new file mode 100644 index 000000000..e33f64385 --- /dev/null +++ b/tests/test_request_params/test_file/utils.py @@ -0,0 +1,7 @@ +from typing import Any, Dict + + +def get_body_model_name(openapi: Dict[str, Any], path: str) -> str: + body = openapi["paths"][path]["post"]["requestBody"] + body_schema = body["content"]["multipart/form-data"]["schema"] + return body_schema.get("$ref", "").split("/")[-1] diff --git a/tests/test_request_params/test_form/__init__.py b/tests/test_request_params/test_form/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_request_params/test_form/test_list.py b/tests/test_request_params/test_form/test_list.py new file mode 100644 index 000000000..69a1b6a38 --- /dev/null +++ b/tests/test_request_params/test_form/test_list.py @@ -0,0 +1,486 @@ +from typing import List + +import pytest +from dirty_equals import IsDict, IsOneOf, IsPartialDict +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/required-list-str", operation_id="required_list_str") +async def read_required_list_str(p: Annotated[List[str], Form()]): + return {"p": p} + + +class FormModelRequiredListStr(BaseModel): + p: List[str] + + +@app.post("/model-required-list-str", operation_id="model_required_list_str") +def read_model_required_list_str(p: Annotated[FormModelRequiredListStr, Form()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p": { + "items": {"type": "string"}, + "title": "P", + "type": "array", + }, + }, + "required": ["p"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + { + "detail": [ + { + "loc": ["body", "p"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str(path: str): + client = TestClient(app) + response = client.post(path, data={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias + + +@app.post("/required-list-alias", operation_id="required_list_alias") +async def read_required_list_alias(p: Annotated[List[str], Form(alias="p_alias")]): + return {"p": p} + + +class FormModelRequiredListAlias(BaseModel): + p: List[str] = Field(alias="p_alias") + + +@app.post("/model-required-list-alias", operation_id="model_required_list_alias") +async def read_model_required_list_alias( + p: Annotated[FormModelRequiredListAlias, Form()], +): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias", + "/model-required-list-alias", + ], +) +def test_required_list_str_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_alias": { + "items": {"type": "string"}, + "title": "P Alias", + "type": "array", + }, + }, + "required": ["p_alias"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + ["/required-list-alias", "/model-required-list-alias"], +) +def test_required_list_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias", + "/model-required-list-alias", + ], +) +def test_required_list_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": ["hello", "world"]}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": ["hello", "world"]}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-list-alias", "/model-required-list-alias"], +) +def test_required_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Validation alias + + +@app.post( + "/required-list-validation-alias", operation_id="required_list_validation_alias" +) +def read_required_list_validation_alias( + p: Annotated[List[str], Form(validation_alias="p_val_alias")], +): + return {"p": p} + + +class FormModelRequiredListValidationAlias(BaseModel): + p: List[str] = Field(validation_alias="p_val_alias") + + +@app.post( + "/model-required-list-validation-alias", + operation_id="model_required_list_validation_alias", +) +async def read_model_required_list_validation_alias( + p: Annotated[FormModelRequiredListValidationAlias, Form()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-list-validation-alias", "/model-required-list-validation-alias"], +) +def test_required_list_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": { + "items": {"type": "string"}, + "title": "P Val Alias", + "type": "array", + }, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-validation-alias", + "/model-required-list-validation-alias", + ], +) +def test_required_list_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-validation-alias", + "/model-required-list-validation-alias", + ], +) +def test_required_list_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": ["hello", "world"]}) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, IsPartialDict({"p": ["hello", "world"]})), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-list-validation-alias", "/model-required-list-validation-alias"], +) +def test_required_list_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_val_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/required-list-alias-and-validation-alias", + operation_id="required_list_alias_and_validation_alias", +) +def read_required_list_alias_and_validation_alias( + p: Annotated[List[str], Form(alias="p_alias", validation_alias="p_val_alias")], +): + return {"p": p} + + +class FormModelRequiredListAliasAndValidationAlias(BaseModel): + p: List[str] = Field(alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-required-list-alias-and-validation-alias", + operation_id="model_required_list_alias_and_validation_alias", +) +def read_model_required_list_alias_and_validation_alias( + p: Annotated[FormModelRequiredListAliasAndValidationAlias, Form()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": { + "items": {"type": "string"}, + "title": "P Val Alias", + "type": "array", + }, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": ["hello", "world"]}) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf( + None, + {"p": ["hello", "world"]}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_alias": ["hello", "world"]}) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p_alias": ["hello", "world"]}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_val_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} diff --git a/tests/test_request_params/test_form/test_optional_list.py b/tests/test_request_params/test_form/test_optional_list.py new file mode 100644 index 000000000..1f779a7ed --- /dev/null +++ b/tests/test_request_params/test_form/test_optional_list.py @@ -0,0 +1,429 @@ +from typing import List, Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/optional-list-str", operation_id="optional_list_str") +async def read_optional_list_str( + p: Annotated[Optional[List[str]], Form()] = None, +): + return {"p": p} + + +class FormModelOptionalListStr(BaseModel): + p: Optional[List[str]] = None + + +@app.post("/model-optional-list-str", operation_id="model_optional_list_str") +async def read_model_optional_list_str(p: Annotated[FormModelOptionalListStr, Form()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p": {"items": {"type": "string"}, "type": "array", "title": "P"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str(path: str): + client = TestClient(app) + response = client.post(path, data={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias + + +@app.post("/optional-list-alias", operation_id="optional_list_alias") +async def read_optional_list_alias( + p: Annotated[Optional[List[str]], Form(alias="p_alias")] = None, +): + return {"p": p} + + +class FormModelOptionalListAlias(BaseModel): + p: Optional[List[str]] = Field(None, alias="p_alias") + + +@app.post("/model-optional-list-alias", operation_id="model_optional_list_alias") +async def read_model_optional_list_alias( + p: Annotated[FormModelOptionalListAlias, Form()], +): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias", + "/model-optional-list-alias", + ], +) +def test_optional_list_str_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_alias": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_alias": { + "items": {"type": "string"}, + "type": "array", + "title": "P Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_alias": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Validation alias + + +@app.post( + "/optional-list-validation-alias", operation_id="optional_list_validation_alias" +) +def read_optional_list_validation_alias( + p: Annotated[Optional[List[str]], Form(validation_alias="p_val_alias")] = None, +): + return {"p": p} + + +class FormModelOptionalListValidationAlias(BaseModel): + p: Optional[List[str]] = Field(None, validation_alias="p_val_alias") + + +@app.post( + "/model-optional-list-validation-alias", + operation_id="model_optional_list_validation_alias", +) +def read_model_optional_list_validation_alias( + p: Annotated[FormModelOptionalListValidationAlias, Form()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_val_alias": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_val_alias": { + "items": {"type": "string"}, + "type": "array", + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-validation-alias", + "/model-optional-list-validation-alias", + ], +) +def test_optional_list_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_val_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/optional-list-alias-and-validation-alias", + operation_id="optional_list_alias_and_validation_alias", +) +def read_optional_list_alias_and_validation_alias( + p: Annotated[ + Optional[List[str]], Form(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class FormModelOptionalListAliasAndValidationAlias(BaseModel): + p: Optional[List[str]] = Field( + None, alias="p_alias", validation_alias="p_val_alias" + ) + + +@app.post( + "/model-optional-list-alias-and-validation-alias", + operation_id="model_optional_list_alias_and_validation_alias", +) +def read_model_optional_list_alias_and_validation_alias( + p: Annotated[FormModelOptionalListAliasAndValidationAlias, Form()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_val_alias": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_val_alias": { + "items": {"type": "string"}, + "type": "array", + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_alias": ["hello", "world"]}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_val_alias": ["hello", "world"]}) + assert response.status_code == 200, response.text + assert response.json() == { + "p": [ + "hello", + "world", + ] + } diff --git a/tests/test_request_params/test_form/test_optional_str.py b/tests/test_request_params/test_form/test_optional_str.py new file mode 100644 index 000000000..969865945 --- /dev/null +++ b/tests/test_request_params/test_form/test_optional_str.py @@ -0,0 +1,394 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/optional-str", operation_id="optional_str") +async def read_optional_str(p: Annotated[Optional[str], Form()] = None): + return {"p": p} + + +class FormModelOptionalStr(BaseModel): + p: Optional[str] = None + + +@app.post("/model-optional-str", operation_id="model_optional_str") +async def read_model_optional_str(p: Annotated[FormModelOptionalStr, Form()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p": {"type": "string", "title": "P"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str(path: str): + client = TestClient(app) + response = client.post(path, data={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.post("/optional-alias", operation_id="optional_alias") +async def read_optional_alias( + p: Annotated[Optional[str], Form(alias="p_alias")] = None, +): + return {"p": p} + + +class FormModelOptionalAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias") + + +@app.post("/model-optional-alias", operation_id="model_optional_alias") +async def read_model_optional_alias(p: Annotated[FormModelOptionalAlias, Form()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-alias", + "/model-optional-alias", + ], +) +def test_optional_str_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_alias": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_alias": {"type": "string", "title": "P Alias"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.post("/optional-validation-alias", operation_id="optional_validation_alias") +def read_optional_validation_alias( + p: Annotated[Optional[str], Form(validation_alias="p_val_alias")] = None, +): + return {"p": p} + + +class FormModelOptionalValidationAlias(BaseModel): + p: Optional[str] = Field(None, validation_alias="p_val_alias") + + +@app.post( + "/model-optional-validation-alias", operation_id="model_optional_validation_alias" +) +def read_model_optional_validation_alias( + p: Annotated[FormModelOptionalValidationAlias, Form()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_optional_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_val_alias": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_val_alias": {"type": "string", "title": "P Val Alias"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_optional_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_val_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/optional-alias-and-validation-alias", + operation_id="optional_alias_and_validation_alias", +) +def read_optional_alias_and_validation_alias( + p: Annotated[ + Optional[str], Form(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class FormModelOptionalAliasAndValidationAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-optional-alias-and-validation-alias", + operation_id="model_optional_alias_and_validation_alias", +) +def read_model_optional_alias_and_validation_alias( + p: Annotated[FormModelOptionalAliasAndValidationAlias, Form()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == IsDict( + { + "properties": { + "p_val_alias": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + }, + "title": body_model_name, + "type": "object", + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "properties": { + "p_val_alias": {"type": "string", "title": "P Val Alias"}, + }, + "title": body_model_name, + "type": "object", + } + ) + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_val_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_form/test_required_str.py b/tests/test_request_params/test_form/test_required_str.py new file mode 100644 index 000000000..c901e7b44 --- /dev/null +++ b/tests/test_request_params/test_form/test_required_str.py @@ -0,0 +1,464 @@ +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +from .utils import get_body_model_name + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.post("/required-str", operation_id="required_str") +async def read_required_str(p: Annotated[str, Form()]): + return {"p": p} + + +class FormModelRequiredStr(BaseModel): + p: str + + +@app.post("/model-required-str", operation_id="model_required_str") +async def read_model_required_str(p: Annotated[FormModelRequiredStr, Form()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p": {"title": "P", "type": "string"}, + }, + "required": ["p"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str(path: str): + client = TestClient(app) + response = client.post(path, data={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.post("/required-alias", operation_id="required_alias") +async def read_required_alias(p: Annotated[str, Form(alias="p_alias")]): + return {"p": p} + + +class FormModelRequiredAlias(BaseModel): + p: str = Field(alias="p_alias") + + +@app.post("/model-required-alias", operation_id="model_required_alias") +async def read_model_required_alias(p: Annotated[FormModelRequiredAlias, Form()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + [ + "/required-alias", + "/model-required-alias", + ], +) +def test_required_str_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_alias": {"title": "P Alias", "type": "string"}, + }, + "required": ["p_alias"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": "hello"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["body", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_alias": "hello"}) + assert response.status_code == 200, response.text + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.post("/required-validation-alias", operation_id="required_validation_alias") +def read_required_validation_alias( + p: Annotated[str, Form(validation_alias="p_val_alias")], +): + return {"p": p} + + +class FormModelRequiredValidationAlias(BaseModel): + p: str = Field(validation_alias="p_val_alias") + + +@app.post( + "/model-required-validation-alias", operation_id="model_required_validation_alias" +) +def read_model_required_validation_alias( + p: Annotated[FormModelRequiredValidationAlias, Form()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-validation-alias", "/model-required-validation-alias"], +) +def test_required_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": {"title": "P Val Alias", "type": "string"}, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": "hello"}) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_val_alias": "hello"}) + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.post( + "/required-alias-and-validation-alias", + operation_id="required_alias_and_validation_alias", +) +def read_required_alias_and_validation_alias( + p: Annotated[str, Form(alias="p_alias", validation_alias="p_val_alias")], +): + return {"p": p} + + +class FormModelRequiredAliasAndValidationAlias(BaseModel): + p: str = Field(alias="p_alias", validation_alias="p_val_alias") + + +@app.post( + "/model-required-alias-and-validation-alias", + operation_id="model_required_alias_and_validation_alias", +) +def read_model_required_alias_and_validation_alias( + p: Annotated[FormModelRequiredAliasAndValidationAlias, Form()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "p_val_alias": {"title": "P Val Alias", "type": "string"}, + }, + "required": ["p_val_alias"], + "title": body_model_name, + "type": "object", + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.post(path, data={"p": "hello"}) + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_alias": "hello"}) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p_alias": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.post(path, data={"p_val_alias": "hello"}) + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_form/utils.py b/tests/test_request_params/test_form/utils.py new file mode 100644 index 000000000..d200650df --- /dev/null +++ b/tests/test_request_params/test_form/utils.py @@ -0,0 +1,7 @@ +from typing import Any, Dict + + +def get_body_model_name(openapi: Dict[str, Any], path: str) -> str: + body = openapi["paths"][path]["post"]["requestBody"] + body_schema = body["content"]["application/x-www-form-urlencoded"]["schema"] + return body_schema.get("$ref", "").split("/")[-1] diff --git a/tests/test_request_params/test_header/__init__.py b/tests/test_request_params/test_header/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_request_params/test_header/test_list.py b/tests/test_request_params/test_header/test_list.py new file mode 100644 index 000000000..4a5f4bb64 --- /dev/null +++ b/tests/test_request_params/test_header/test_list.py @@ -0,0 +1,468 @@ +from typing import List + +import pytest +from dirty_equals import AnyThing, IsDict, IsOneOf, IsPartialDict +from fastapi import FastAPI, Header +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/required-list-str") +async def read_required_list_str(p: Annotated[List[str], Header()]): + return {"p": p} + + +class HeaderModelRequiredListStr(BaseModel): + p: List[str] + + +@app.get("/model-required-list-str") +def read_model_required_list_str(p: Annotated[HeaderModelRequiredListStr, Header()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "P", + "type": "array", + "items": {"type": "string"}, + }, + "name": "p", + "in": "header", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "p"], + "msg": "Field required", + "input": AnyThing, + } + ] + } + ) | IsDict( + { + "detail": [ + { + "loc": ["header", "p"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p", "hello"), ("p", "world")]) + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias + + +@app.get("/required-list-alias") +async def read_required_list_alias(p: Annotated[List[str], Header(alias="p_alias")]): + return {"p": p} + + +class HeaderModelRequiredListAlias(BaseModel): + p: List[str] = Field(alias="p_alias") + + +@app.get("/model-required-list-alias") +async def read_model_required_list_alias( + p: Annotated[HeaderModelRequiredListAlias, Header()], +): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-list-alias", "/model-required-list-alias"], +) +def test_required_list_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "P Alias", + "type": "array", + "items": {"type": "string"}, + }, + "name": "p_alias", + "in": "header", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-list-alias", "/model-required-list-alias"], +) +def test_required_list_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_alias"], + "msg": "Field required", + "input": AnyThing, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias", + "/model-required-list-alias", + ], +) +def test_required_list_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p", "hello"), ("p", "world")]) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, IsPartialDict({"p": ["hello", "world"]})), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias", + "/model-required-list-alias", + ], +) +def test_required_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p_alias", "hello"), ("p_alias", "world")]) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Validation alias + + +@app.get("/required-list-validation-alias") +def read_required_list_validation_alias( + p: Annotated[List[str], Header(validation_alias="p_val_alias")], +): + return {"p": p} + + +class HeaderModelRequiredListValidationAlias(BaseModel): + p: List[str] = Field(validation_alias="p_val_alias") + + +@app.get("/model-required-list-validation-alias") +async def read_model_required_list_validation_alias( + p: Annotated[HeaderModelRequiredListValidationAlias, Header()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-list-validation-alias", "/model-required-list-validation-alias"], +) +def test_required_list_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "P Val Alias", + "type": "array", + "items": {"type": "string"}, + }, + "name": "p_val_alias", + "in": "header", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-validation-alias", + "/model-required-list-validation-alias", + ], +) +def test_required_list_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "header", + "p_val_alias", + ], + "msg": "Field required", + "input": AnyThing, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-validation-alias", + "/model-required-list-validation-alias", + ], +) +def test_required_list_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p", "hello"), ("p", "world")]) + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, IsPartialDict({"p": ["hello", "world"]})), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-list-validation-alias", "/model-required-list-validation-alias"], +) +def test_required_list_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get( + path, headers=[("p_val_alias", "hello"), ("p_val_alias", "world")] + ) + assert response.status_code == 200, response.text + + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/required-list-alias-and-validation-alias") +def read_required_list_alias_and_validation_alias( + p: Annotated[List[str], Header(alias="p_alias", validation_alias="p_val_alias")], +): + return {"p": p} + + +class HeaderModelRequiredListAliasAndValidationAlias(BaseModel): + p: List[str] = Field(alias="p_alias", validation_alias="p_val_alias") + + +@app.get("/model-required-list-alias-and-validation-alias") +def read_model_required_list_alias_and_validation_alias( + p: Annotated[HeaderModelRequiredListAliasAndValidationAlias, Header()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "P Val Alias", + "type": "array", + "items": {"type": "string"}, + }, + "name": "p_val_alias", + "in": "header", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "header", + "p_val_alias", + ], + "msg": "Field required", + "input": AnyThing, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p", "hello"), ("p", "world")]) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "header", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf( + None, + IsPartialDict({"p": ["hello", "world"]}), + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p_alias", "hello"), ("p_alias", "world")]) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + IsPartialDict({"p_alias": ["hello", "world"]}), + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get( + path, headers=[("p_val_alias", "hello"), ("p_val_alias", "world")] + ) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} diff --git a/tests/test_request_params/test_header/test_optional_list.py b/tests/test_request_params/test_header/test_optional_list.py new file mode 100644 index 000000000..e81025c02 --- /dev/null +++ b/tests/test_request_params/test_header/test_optional_list.py @@ -0,0 +1,384 @@ +from typing import List, Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, Header +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/optional-list-str") +async def read_optional_list_str( + p: Annotated[Optional[List[str]], Header()] = None, +): + return {"p": p} + + +class HeaderModelOptionalListStr(BaseModel): + p: Optional[List[str]] = None + + +@app.get("/model-optional-list-str") +async def read_model_optional_list_str( + p: Annotated[HeaderModelOptionalListStr, Header()], +): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P", + }, + "name": "p", + "in": "header", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": {"items": {"type": "string"}, "type": "array", "title": "P"}, + "name": "p", + "in": "header", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p", "hello"), ("p", "world")]) + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias + + +@app.get("/optional-list-alias") +async def read_optional_list_alias( + p: Annotated[Optional[List[str]], Header(alias="p_alias")] = None, +): + return {"p": p} + + +class HeaderModelOptionalListAlias(BaseModel): + p: Optional[List[str]] = Field(None, alias="p_alias") + + +@app.get("/model-optional-list-alias") +async def read_model_optional_list_alias( + p: Annotated[HeaderModelOptionalListAlias, Header()], +): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Alias", + }, + "name": "p_alias", + "in": "header", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": { + "items": {"type": "string"}, + "type": "array", + "title": "P Alias", + }, + "name": "p_alias", + "in": "header", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p", "hello"), ("p", "world")]) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias", + "/model-optional-list-alias", + ], +) +def test_optional_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p_alias", "hello"), ("p_alias", "world")]) + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Validation alias + + +@app.get("/optional-list-validation-alias") +def read_optional_list_validation_alias( + p: Annotated[Optional[List[str]], Header(validation_alias="p_val_alias")] = None, +): + return {"p": p} + + +class HeaderModelOptionalListValidationAlias(BaseModel): + p: Optional[List[str]] = Field(None, validation_alias="p_val_alias") + + +@app.get("/model-optional-list-validation-alias") +def read_model_optional_list_validation_alias( + p: Annotated[HeaderModelOptionalListValidationAlias, Header()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "header", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-validation-alias", + "/model-optional-list-validation-alias", + ], +) +def test_optional_list_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p", "hello"), ("p", "world")]) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get( + path, headers=[("p_val_alias", "hello"), ("p_val_alias", "world")] + ) + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/optional-list-alias-and-validation-alias") +def read_optional_list_alias_and_validation_alias( + p: Annotated[ + Optional[List[str]], Header(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class HeaderModelOptionalListAliasAndValidationAlias(BaseModel): + p: Optional[List[str]] = Field( + None, alias="p_alias", validation_alias="p_val_alias" + ) + + +@app.get("/model-optional-list-alias-and-validation-alias") +def read_model_optional_list_alias_and_validation_alias( + p: Annotated[HeaderModelOptionalListAliasAndValidationAlias, Header()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "header", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p", "hello"), ("p", "world")]) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(path, headers=[("p_alias", "hello"), ("p_alias", "world")]) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get( + path, headers=[("p_val_alias", "hello"), ("p_val_alias", "world")] + ) + assert response.status_code == 200, response.text + assert response.json() == { + "p": [ + "hello", + "world", + ] + } diff --git a/tests/test_request_params/test_header/test_optional_str.py b/tests/test_request_params/test_header/test_optional_str.py new file mode 100644 index 000000000..5ae9f2670 --- /dev/null +++ b/tests/test_request_params/test_header/test_optional_str.py @@ -0,0 +1,354 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, Header +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/optional-str") +async def read_optional_str(p: Annotated[Optional[str], Header()] = None): + return {"p": p} + + +class HeaderModelOptionalStr(BaseModel): + p: Optional[str] = None + + +@app.get("/model-optional-str") +async def read_model_optional_str(p: Annotated[HeaderModelOptionalStr, Header()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P", + }, + "name": "p", + "in": "header", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": {"title": "P", "type": "string"}, + "name": "p", + "in": "header", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str(path: str): + client = TestClient(app) + response = client.get(path, headers={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.get("/optional-alias") +async def read_optional_alias( + p: Annotated[Optional[str], Header(alias="p_alias")] = None, +): + return {"p": p} + + +class HeaderModelOptionalAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias") + + +@app.get("/model-optional-alias") +async def read_model_optional_alias(p: Annotated[HeaderModelOptionalAlias, Header()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Alias", + }, + "name": "p_alias", + "in": "header", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": {"title": "P Alias", "type": "string"}, + "name": "p_alias", + "in": "header", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-alias", + "/model-optional-alias", + ], +) +def test_optional_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(path, headers={"p_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.get("/optional-validation-alias") +def read_optional_validation_alias( + p: Annotated[Optional[str], Header(validation_alias="p_val_alias")] = None, +): + return {"p": p} + + +class HeaderModelOptionalValidationAlias(BaseModel): + p: Optional[str] = Field(None, validation_alias="p_val_alias") + + +@app.get("/model-optional-validation-alias") +def read_model_optional_validation_alias( + p: Annotated[HeaderModelOptionalValidationAlias, Header()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_optional_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "header", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_optional_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(path, headers={"p_val_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/optional-alias-and-validation-alias") +def read_optional_alias_and_validation_alias( + p: Annotated[ + Optional[str], Header(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class HeaderModelOptionalAliasAndValidationAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias") + + +@app.get("/model-optional-alias-and-validation-alias") +def read_model_optional_alias_and_validation_alias( + p: Annotated[HeaderModelOptionalAliasAndValidationAlias, Header()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "header", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(path, headers={"p_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(path, headers={"p_val_alias": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_header/test_required_str.py b/tests/test_request_params/test_header/test_required_str.py new file mode 100644 index 000000000..d57c66919 --- /dev/null +++ b/tests/test_request_params/test_header/test_required_str.py @@ -0,0 +1,451 @@ +import pytest +from dirty_equals import AnyThing, IsDict, IsOneOf, IsPartialDict +from fastapi import FastAPI, Header +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/required-str") +async def read_required_str(p: Annotated[str, Header()]): + return {"p": p} + + +class HeaderModelRequiredStr(BaseModel): + p: str + + +@app.get("/model-required-str") +async def read_model_required_str(p: Annotated[HeaderModelRequiredStr, Header()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P", "type": "string"}, + "name": "p", + "in": "header", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "p"], + "msg": "Field required", + "input": AnyThing, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "p"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str(path: str): + client = TestClient(app) + response = client.get(path, headers={"p": "hello"}) + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.get("/required-alias") +async def read_required_alias(p: Annotated[str, Header(alias="p_alias")]): + return {"p": p} + + +class HeaderModelRequiredAlias(BaseModel): + p: str = Field(alias="p_alias") + + +@app.get("/model-required-alias") +async def read_model_required_alias(p: Annotated[HeaderModelRequiredAlias, Header()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P Alias", "type": "string"}, + "name": "p_alias", + "in": "header", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_alias"], + "msg": "Field required", + "input": AnyThing, + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-alias", + "/model-required-alias", + ], +) +def test_required_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers={"p": "hello"}) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, IsPartialDict({"p": "hello"})), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["header", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-alias", + "/model-required-alias", + ], +) +def test_required_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(path, headers={"p_alias": "hello"}) + assert response.status_code == 200, response.text + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.get("/required-validation-alias") +def read_required_validation_alias( + p: Annotated[str, Header(validation_alias="p_val_alias")], +): + return {"p": p} + + +class HeaderModelRequiredValidationAlias(BaseModel): + p: str = Field(validation_alias="p_val_alias") + + +@app.get("/model-required-validation-alias") +def read_model_required_validation_alias( + p: Annotated[HeaderModelRequiredValidationAlias, Header()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-validation-alias", "/model-required-validation-alias"], +) +def test_required_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P Val Alias", "type": "string"}, + "name": "p_val_alias", + "in": "header", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "header", + "p_val_alias", + ], + "msg": "Field required", + "input": AnyThing, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers={"p": "hello"}) + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, IsPartialDict({"p": "hello"})), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(path, headers={"p_val_alias": "hello"}) + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/required-alias-and-validation-alias") +def read_required_alias_and_validation_alias( + p: Annotated[str, Header(alias="p_alias", validation_alias="p_val_alias")], +): + return {"p": p} + + +class HeaderModelRequiredAliasAndValidationAlias(BaseModel): + p: str = Field(alias="p_alias", validation_alias="p_val_alias") + + +@app.get("/model-required-alias-and-validation-alias") +def read_model_required_alias_and_validation_alias( + p: Annotated[HeaderModelRequiredAliasAndValidationAlias, Header()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P Val Alias", "type": "string"}, + "name": "p_val_alias", + "in": "header", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "header", + "p_val_alias", + ], + "msg": "Field required", + "input": AnyThing, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(path, headers={"p": "hello"}) + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "header", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf( + None, + IsPartialDict({"p": "hello"}), + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(path, headers={"p_alias": "hello"}) + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + IsPartialDict({"p_alias": "hello"}), + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(path, headers={"p_val_alias": "hello"}) + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_path/__init__.py b/tests/test_request_params/test_path/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_request_params/test_path/test_list.py b/tests/test_request_params/test_path/test_list.py new file mode 100644 index 000000000..bba055d9a --- /dev/null +++ b/tests/test_request_params/test_path/test_list.py @@ -0,0 +1 @@ +# FastAPI doesn't currently support non-scalar Path parameters diff --git a/tests/test_request_params/test_path/test_optional_list.py b/tests/test_request_params/test_path/test_optional_list.py new file mode 100644 index 000000000..0719430ac --- /dev/null +++ b/tests/test_request_params/test_path/test_optional_list.py @@ -0,0 +1 @@ +# Optional Path parameters are not supported diff --git a/tests/test_request_params/test_path/test_optional_str.py b/tests/test_request_params/test_path/test_optional_str.py new file mode 100644 index 000000000..0719430ac --- /dev/null +++ b/tests/test_request_params/test_path/test_optional_str.py @@ -0,0 +1 @@ +# Optional Path parameters are not supported diff --git a/tests/test_request_params/test_path/test_required_str.py b/tests/test_request_params/test_path/test_required_str.py new file mode 100644 index 000000000..641719967 --- /dev/null +++ b/tests/test_request_params/test_path/test_required_str.py @@ -0,0 +1,90 @@ +import pytest +from fastapi import FastAPI, Path +from fastapi.testclient import TestClient +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + + +@app.get("/required-str/{p}") +async def read_required_str(p: Annotated[str, Path()]): + return {"p": p} + + +@app.get("/required-alias/{p_alias}") +async def read_required_alias(p: Annotated[str, Path(alias="p_alias")]): + return {"p": p} + + +@app.get("/required-validation-alias/{p_val_alias}") +def read_required_validation_alias( + p: Annotated[str, Path(validation_alias="p_val_alias")], +): + return {"p": p} + + +@app.get("/required-alias-and-validation-alias/{p_val_alias}") +def read_required_alias_and_validation_alias( + p: Annotated[str, Path(alias="p_alias", validation_alias="p_val_alias")], +): + return {"p": p} + + +@pytest.mark.parametrize( + ("path", "expected_name", "expected_title"), + [ + pytest.param("/required-str/{p}", "p", "P", id="required-str"), + pytest.param( + "/required-alias/{p_alias}", "p_alias", "P Alias", id="required-alias" + ), + pytest.param( + "/required-validation-alias/{p_val_alias}", + "p_val_alias", + "P Val Alias", + id="required-validation-alias", + marks=needs_pydanticv2, + ), + pytest.param( + "/required-alias-and-validation-alias/{p_val_alias}", + "p_val_alias", + "P Val Alias", + id="required-alias-and-validation-alias", + marks=needs_pydanticv2, + ), + ], +) +def test_schema(path: str, expected_name: str, expected_title: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": expected_title, "type": "string"}, + "name": expected_name, + "in": "path", + } + ] + + +@pytest.mark.parametrize( + "path", + [ + pytest.param("/required-str", id="required-str"), + pytest.param("/required-alias", id="required-alias"), + pytest.param( + "/required-validation-alias", + id="required-validation-alias", + marks=needs_pydanticv2, + ), + pytest.param( + "/required-alias-and-validation-alias", + id="required-alias-and-validation-alias", + marks=needs_pydanticv2, + ), + ], +) +def test_success(path: str): + client = TestClient(app) + response = client.get(f"{path}/hello") + assert response.status_code == 200, response.text + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_query/__init__.py b/tests/test_request_params/test_query/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_request_params/test_query/test_list.py b/tests/test_request_params/test_query/test_list.py new file mode 100644 index 000000000..71cbca51f --- /dev/null +++ b/tests/test_request_params/test_query/test_list.py @@ -0,0 +1,469 @@ +from typing import List + +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import FastAPI, Query +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/required-list-str") +async def read_required_list_str(p: Annotated[List[str], Query()]): + return {"p": p} + + +class QueryModelRequiredListStr(BaseModel): + p: List[str] + + +@app.get("/model-required-list-str") +def read_model_required_list_str(p: Annotated[QueryModelRequiredListStr, Query()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "P", + "type": "array", + "items": {"type": "string"}, + }, + "name": "p", + "in": "query", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "p"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + { + "detail": [ + { + "loc": ["query", "p"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-list-str", "/model-required-list-str"], +) +def test_required_list_str(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello&p=world") + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias + + +@app.get("/required-list-alias") +async def read_required_list_alias(p: Annotated[List[str], Query(alias="p_alias")]): + return {"p": p} + + +class QueryModelRequiredListAlias(BaseModel): + p: List[str] = Field(alias="p_alias") + + +@app.get("/model-required-list-alias") +async def read_model_required_list_alias( + p: Annotated[QueryModelRequiredListAlias, Query()], +): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-list-alias", "/model-required-list-alias"], +) +def test_required_list_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "P Alias", + "type": "array", + "items": {"type": "string"}, + }, + "name": "p_alias", + "in": "query", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-list-alias", "/model-required-list-alias"], +) +def test_required_list_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias", + "/model-required-list-alias", + ], +) +def test_required_list_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello&p=world") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": ["hello", "world"]}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias", + "/model-required-list-alias", + ], +) +def test_required_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_alias=hello&p_alias=world") + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Validation alias + + +@app.get("/required-list-validation-alias") +def read_required_list_validation_alias( + p: Annotated[List[str], Query(validation_alias="p_val_alias")], +): + return {"p": p} + + +class QueryModelRequiredListValidationAlias(BaseModel): + p: List[str] = Field(validation_alias="p_val_alias") + + +@app.get("/model-required-list-validation-alias") +async def read_model_required_list_validation_alias( + p: Annotated[QueryModelRequiredListValidationAlias, Query()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-list-validation-alias", "/model-required-list-validation-alias"], +) +def test_required_list_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "P Val Alias", + "type": "array", + "items": {"type": "string"}, + }, + "name": "p_val_alias", + "in": "query", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-validation-alias", + "/model-required-list-validation-alias", + ], +) +def test_required_list_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "query", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-validation-alias", + "/model-required-list-validation-alias", + ], +) +def test_required_list_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello&p=world") + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": ["hello", "world"]}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-list-validation-alias", "/model-required-list-validation-alias"], +) +def test_required_list_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_val_alias=hello&p_val_alias=world") + assert response.status_code == 200, response.text + + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/required-list-alias-and-validation-alias") +def read_required_list_alias_and_validation_alias( + p: Annotated[List[str], Query(alias="p_alias", validation_alias="p_val_alias")], +): + return {"p": p} + + +class QueryModelRequiredListAliasAndValidationAlias(BaseModel): + p: List[str] = Field(alias="p_alias", validation_alias="p_val_alias") + + +@app.get("/model-required-list-alias-and-validation-alias") +def read_model_required_list_alias_and_validation_alias( + p: Annotated[QueryModelRequiredListAliasAndValidationAlias, Query()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "P Val Alias", + "type": "array", + "items": {"type": "string"}, + }, + "name": "p_val_alias", + "in": "query", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "query", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello&p=world") + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "query", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf( + None, + { + "p": [ + "hello", + "world", + ] + }, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_alias=hello&p_alias=world") + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + {"p_alias": ["hello", "world"]}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-list-alias-and-validation-alias", + "/model-required-list-alias-and-validation-alias", + ], +) +def test_required_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_val_alias=hello&p_val_alias=world") + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} diff --git a/tests/test_request_params/test_query/test_optional_list.py b/tests/test_request_params/test_query/test_optional_list.py new file mode 100644 index 000000000..560921336 --- /dev/null +++ b/tests/test_request_params/test_query/test_optional_list.py @@ -0,0 +1,380 @@ +from typing import List, Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, Query +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/optional-list-str") +async def read_optional_list_str( + p: Annotated[Optional[List[str]], Query()] = None, +): + return {"p": p} + + +class QueryModelOptionalListStr(BaseModel): + p: Optional[List[str]] = None + + +@app.get("/model-optional-list-str") +async def read_model_optional_list_str( + p: Annotated[QueryModelOptionalListStr, Query()], +): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P", + }, + "name": "p", + "in": "query", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": {"items": {"type": "string"}, "type": "array", "title": "P"}, + "name": "p", + "in": "query", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200, response.text + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-str", "/model-optional-list-str"], +) +def test_optional_list_str(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello&p=world") + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias + + +@app.get("/optional-list-alias") +async def read_optional_list_alias( + p: Annotated[Optional[List[str]], Query(alias="p_alias")] = None, +): + return {"p": p} + + +class QueryModelOptionalListAlias(BaseModel): + p: Optional[List[str]] = Field(None, alias="p_alias") + + +@app.get("/model-optional-list-alias") +async def read_model_optional_list_alias( + p: Annotated[QueryModelOptionalListAlias, Query()], +): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Alias", + }, + "name": "p_alias", + "in": "query", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": { + "items": {"type": "string"}, + "type": "array", + "title": "P Alias", + }, + "name": "p_alias", + "in": "query", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-list-alias", "/model-optional-list-alias"], +) +def test_optional_list_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello&p=world") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias", + "/model-optional-list-alias", + ], +) +def test_optional_list_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_alias=hello&p_alias=world") + assert response.status_code == 200 + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Validation alias + + +@app.get("/optional-list-validation-alias") +def read_optional_list_validation_alias( + p: Annotated[Optional[List[str]], Query(validation_alias="p_val_alias")] = None, +): + return {"p": p} + + +class QueryModelOptionalListValidationAlias(BaseModel): + p: Optional[List[str]] = Field(None, validation_alias="p_val_alias") + + +@app.get("/model-optional-list-validation-alias") +def read_model_optional_list_validation_alias( + p: Annotated[QueryModelOptionalListValidationAlias, Query()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "query", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-validation-alias", + "/model-optional-list-validation-alias", + ], +) +def test_optional_list_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello&p=world") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-list-validation-alias", "/model-optional-list-validation-alias"], +) +def test_optional_list_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_val_alias=hello&p_val_alias=world") + assert response.status_code == 200, response.text + assert response.json() == {"p": ["hello", "world"]} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/optional-list-alias-and-validation-alias") +def read_optional_list_alias_and_validation_alias( + p: Annotated[ + Optional[List[str]], Query(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class QueryModelOptionalListAliasAndValidationAlias(BaseModel): + p: Optional[List[str]] = Field( + None, alias="p_alias", validation_alias="p_val_alias" + ) + + +@app.get("/model-optional-list-alias-and-validation-alias") +def read_model_optional_list_alias_and_validation_alias( + p: Annotated[QueryModelOptionalListAliasAndValidationAlias, Query()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "string"}, "type": "array"}, + {"type": "null"}, + ], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "query", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello&p=world") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_alias=hello&p_alias=world") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-list-alias-and-validation-alias", + "/model-optional-list-alias-and-validation-alias", + ], +) +def test_optional_list_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_val_alias=hello&p_val_alias=world") + assert response.status_code == 200, response.text + assert response.json() == { + "p": [ + "hello", + "world", + ] + } diff --git a/tests/test_request_params/test_query/test_optional_str.py b/tests/test_request_params/test_query/test_optional_str.py new file mode 100644 index 000000000..25e4ea42e --- /dev/null +++ b/tests/test_request_params/test_query/test_optional_str.py @@ -0,0 +1,354 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, Query +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/optional-str") +async def read_optional_str(p: Optional[str] = None): + return {"p": p} + + +class QueryModelOptionalStr(BaseModel): + p: Optional[str] = None + + +@app.get("/model-optional-str") +async def read_model_optional_str(p: Annotated[QueryModelOptionalStr, Query()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P", + }, + "name": "p", + "in": "query", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": {"title": "P", "type": "string"}, + "name": "p", + "in": "query", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-str", "/model-optional-str"], +) +def test_optional_str(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello") + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.get("/optional-alias") +async def read_optional_alias( + p: Annotated[Optional[str], Query(alias="p_alias")] = None, +): + return {"p": p} + + +class QueryModelOptionalAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias") + + +@app.get("/model-optional-alias") +async def read_model_optional_alias(p: Annotated[QueryModelOptionalAlias, Query()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + IsDict( + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Alias", + }, + "name": "p_alias", + "in": "query", + } + ) + | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "required": False, + "schema": {"title": "P Alias", "type": "string"}, + "name": "p_alias", + "in": "query", + } + ) + ] + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + ["/optional-alias", "/model-optional-alias"], +) +def test_optional_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@pytest.mark.parametrize( + "path", + [ + "/optional-alias", + "/model-optional-alias", + ], +) +def test_optional_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_alias=hello") + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.get("/optional-validation-alias") +def read_optional_validation_alias( + p: Annotated[Optional[str], Query(validation_alias="p_val_alias")] = None, +): + return {"p": p} + + +class QueryModelOptionalValidationAlias(BaseModel): + p: Optional[str] = Field(None, validation_alias="p_val_alias") + + +@app.get("/model-optional-validation-alias") +def read_model_optional_validation_alias( + p: Annotated[QueryModelOptionalValidationAlias, Query()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_optional_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "query", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/optional-validation-alias", "/model-optional-validation-alias"], +) +def test_optional_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-validation-alias", + "/model-optional-validation-alias", + ], +) +def test_optional_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_val_alias=hello") + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/optional-alias-and-validation-alias") +def read_optional_alias_and_validation_alias( + p: Annotated[ + Optional[str], Query(alias="p_alias", validation_alias="p_val_alias") + ] = None, +): + return {"p": p} + + +class QueryModelOptionalAliasAndValidationAlias(BaseModel): + p: Optional[str] = Field(None, alias="p_alias", validation_alias="p_val_alias") + + +@app.get("/model-optional-alias-and-validation-alias") +def read_model_optional_alias_and_validation_alias( + p: Annotated[QueryModelOptionalAliasAndValidationAlias, Query()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "P Val Alias", + }, + "name": "p_val_alias", + "in": "query", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_alias=hello") + assert response.status_code == 200 + assert response.json() == {"p": None} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/optional-alias-and-validation-alias", + "/model-optional-alias-and-validation-alias", + ], +) +def test_optional_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_val_alias=hello") + assert response.status_code == 200 + assert response.json() == {"p": "hello"} diff --git a/tests/test_request_params/test_query/test_required_str.py b/tests/test_request_params/test_query/test_required_str.py new file mode 100644 index 000000000..aa0731e2c --- /dev/null +++ b/tests/test_request_params/test_query/test_required_str.py @@ -0,0 +1,454 @@ +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import FastAPI, Query +from fastapi.testclient import TestClient +from pydantic import BaseModel, Field +from typing_extensions import Annotated + +from tests.utils import needs_pydanticv2 + +app = FastAPI() + +# ===================================================================================== +# Without aliases + + +@app.get("/required-str") +async def read_required_str(p: str): + return {"p": p} + + +class QueryModelRequiredStr(BaseModel): + p: str + + +@app.get("/model-required-str") +async def read_model_required_str(p: Annotated[QueryModelRequiredStr, Query()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P", "type": "string"}, + "name": "p", + "in": "query", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "p"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "p"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + ["/required-str", "/model-required-str"], +) +def test_required_str(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello") + assert response.status_code == 200 + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias + + +@app.get("/required-alias") +async def read_required_alias(p: Annotated[str, Query(alias="p_alias")]): + return {"p": p} + + +class QueryModelRequiredAlias(BaseModel): + p: str = Field(alias="p_alias") + + +@app.get("/model-required-alias") +async def read_model_required_alias(p: Annotated[QueryModelRequiredAlias, Query()]): + return {"p": p.p} + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_str_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P Alias", "type": "string"}, + "name": "p_alias", + "in": "query", + } + ] + + +@pytest.mark.parametrize( + "path", + ["/required-alias", "/model-required-alias"], +) +def test_required_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_alias"], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-alias", + "/model-required-alias", + ], +) +def test_required_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello") + assert response.status_code == 422 + assert response.json() == IsDict( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + {"p": "hello"}, + ), + } + ] + } + ) | IsDict( + # TODO: remove when deprecating Pydantic v1 + { + "detail": [ + { + "loc": ["query", "p_alias"], + "msg": "field required", + "type": "value_error.missing", + } + ] + } + ) + + +@pytest.mark.parametrize( + "path", + [ + "/required-alias", + "/model-required-alias", + ], +) +def test_required_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_alias=hello") + assert response.status_code == 200, response.text + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Validation alias + + +@app.get("/required-validation-alias") +def read_required_validation_alias( + p: Annotated[str, Query(validation_alias="p_val_alias")], +): + return {"p": p} + + +class QueryModelRequiredValidationAlias(BaseModel): + p: str = Field(validation_alias="p_val_alias") + + +@app.get("/model-required-validation-alias") +def read_model_required_validation_alias( + p: Annotated[QueryModelRequiredValidationAlias, Query()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + ["/required-validation-alias", "/model-required-validation-alias"], +) +def test_required_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P Val Alias", "type": "string"}, + "name": "p_val_alias", + "in": "query", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "query", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello") + assert response.status_code == 422, response.text + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-validation-alias", + "/model-required-validation-alias", + ], +) +def test_required_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_val_alias=hello") + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} + + +# ===================================================================================== +# Alias and validation alias + + +@app.get("/required-alias-and-validation-alias") +def read_required_alias_and_validation_alias( + p: Annotated[str, Query(alias="p_alias", validation_alias="p_val_alias")], +): + return {"p": p} + + +class QueryModelRequiredAliasAndValidationAlias(BaseModel): + p: str = Field(alias="p_alias", validation_alias="p_val_alias") + + +@app.get("/model-required-alias-and-validation-alias") +def read_model_required_alias_and_validation_alias( + p: Annotated[QueryModelRequiredAliasAndValidationAlias, Query()], +): + return {"p": p.p} + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": {"title": "P Val Alias", "type": "string"}, + "name": "p_val_alias", + "in": "query", + } + ] + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_missing(path: str): + client = TestClient(app) + response = client.get(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "query", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_name(path: str): + client = TestClient(app) + response = client.get(f"{path}?p=hello") + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": [ + "query", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf( + None, + {"p": "hello"}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_alias=hello") + assert response.status_code == 422 + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + {"p_alias": "hello"}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + "/required-alias-and-validation-alias", + "/model-required-alias-and-validation-alias", + ], +) +def test_required_alias_and_validation_alias_by_validation_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_val_alias=hello") + assert response.status_code == 200, response.text + + assert response.json() == {"p": "hello"} diff --git a/tests/test_union_body_discriminator_annotated.py b/tests/test_union_body_discriminator_annotated.py new file mode 100644 index 000000000..14145e6f6 --- /dev/null +++ b/tests/test_union_body_discriminator_annotated.py @@ -0,0 +1,207 @@ +# Ref: https://github.com/fastapi/fastapi/discussions/14495 + +from typing import Union + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from inline_snapshot import snapshot +from pydantic import BaseModel +from typing_extensions import Annotated + +from .utils import needs_pydanticv2 + + +@pytest.fixture(name="client") +def client_fixture() -> TestClient: + from fastapi import Body + from pydantic import Discriminator, Tag + + class Cat(BaseModel): + pet_type: str = "cat" + meows: int + + class Dog(BaseModel): + pet_type: str = "dog" + barks: float + + def get_pet_type(v): + assert isinstance(v, dict) + return v.get("pet_type", "") + + Pet = Annotated[ + Union[Annotated[Cat, Tag("cat")], Annotated[Dog, Tag("dog")]], + Discriminator(get_pet_type), + ] + + app = FastAPI() + + @app.post("/pet/assignment") + async def create_pet_assignment(pet: Pet = Body()): + return pet + + @app.post("/pet/annotated") + async def create_pet_annotated(pet: Annotated[Pet, Body()]): + return pet + + client = TestClient(app) + return client + + +@needs_pydanticv2 +def test_union_body_discriminator_assignment(client: TestClient) -> None: + response = client.post("/pet/assignment", json={"pet_type": "cat", "meows": 5}) + assert response.status_code == 200, response.text + assert response.json() == {"pet_type": "cat", "meows": 5} + + +@needs_pydanticv2 +def test_union_body_discriminator_annotated(client: TestClient) -> None: + response = client.post("/pet/annotated", json={"pet_type": "dog", "barks": 3.5}) + assert response.status_code == 200, response.text + assert response.json() == {"pet_type": "dog", "barks": 3.5} + + +@needs_pydanticv2 +def test_openapi_schema(client: TestClient) -> None: + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == snapshot( + { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/pet/assignment": { + "post": { + "summary": "Create Pet Assignment", + "operationId": "create_pet_assignment_pet_assignment_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "anyOf": [ + {"$ref": "#/components/schemas/Cat"}, + {"$ref": "#/components/schemas/Dog"}, + ], + "title": "Pet", + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + } + }, + "/pet/annotated": { + "post": { + "summary": "Create Pet Annotated", + "operationId": "create_pet_annotated_pet_annotated_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + {"$ref": "#/components/schemas/Cat"}, + {"$ref": "#/components/schemas/Dog"}, + ], + "title": "Pet", + } + } + }, + "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": { + "Cat": { + "properties": { + "pet_type": { + "type": "string", + "title": "Pet Type", + "default": "cat", + }, + "meows": {"type": "integer", "title": "Meows"}, + }, + "type": "object", + "required": ["meows"], + "title": "Cat", + }, + "Dog": { + "properties": { + "pet_type": { + "type": "string", + "title": "Pet Type", + "default": "dog", + }, + "barks": {"type": "number", "title": "Barks"}, + }, + "type": "object", + "required": ["barks"], + "title": "Dog", + }, + "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", + }, + } + }, + } + )