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/de/docs/_llm-test.md b/docs/de/docs/_llm-test.md index 72846ef06..3a95f42e8 100644 --- a/docs/de/docs/_llm-test.md +++ b/docs/de/docs/_llm-test.md @@ -443,7 +443,7 @@ Für einige sprachspezifische Anweisungen, siehe z. B. den Abschnitt `### Headin * die Workload * das Deployment -* bereitstellen +* deployen * das SDK * das Software Development Kit diff --git a/docs/de/docs/advanced/additional-responses.md b/docs/de/docs/advanced/additional-responses.md index 218dd6c4f..29a0a1477 100644 --- a/docs/de/docs/advanced/additional-responses.md +++ b/docs/de/docs/advanced/additional-responses.md @@ -175,7 +175,7 @@ Sie können denselben `responses`-Parameter verwenden, um verschiedene Medientyp Sie können beispielsweise einen zusätzlichen Medientyp `image/png` hinzufügen und damit deklarieren, dass Ihre *Pfadoperation* ein JSON-Objekt (mit dem Medientyp `application/json`) oder ein PNG-Bild zurückgeben kann: -{* ../../docs_src/additional_responses/tutorial002.py hl[19:24,28] *} +{* ../../docs_src/additional_responses/tutorial002_py310.py hl[17:22,26] *} /// note | Hinweis @@ -237,7 +237,7 @@ Mit dieser Technik können Sie einige vordefinierte Responses in Ihren *Pfadoper Zum Beispiel: -{* ../../docs_src/additional_responses/tutorial004.py hl[13:17,26] *} +{* ../../docs_src/additional_responses/tutorial004_py310.py hl[11:15,24] *} ## Weitere Informationen zu OpenAPI-Responses { #more-information-about-openapi-responses } diff --git a/docs/de/docs/advanced/advanced-dependencies.md b/docs/de/docs/advanced/advanced-dependencies.md index 2254dcf53..e60df2883 100644 --- a/docs/de/docs/advanced/advanced-dependencies.md +++ b/docs/de/docs/advanced/advanced-dependencies.md @@ -144,7 +144,7 @@ Dies wurde in Version 0.110.0 geändert, um unbehandelten Speicherverbrauch durc ### Hintergrundtasks und Abhängigkeiten mit `yield`, Technische Details { #background-tasks-and-dependencies-with-yield-technical-details } -Vor FastAPI 0.106.0 war das Werfen von Exceptions nach `yield` nicht möglich, der Exit-Code in Abhängigkeiten mit `yield` wurde ausgeführt, nachdem die Response gesendet wurde, sodass [Exceptionhandler](../handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank} bereits ausgeführt worden wären. +Vor FastAPI 0.106.0 war das Werfen von Exceptions nach `yield` nicht möglich, der Exit-Code in Abhängigkeiten mit `yield` wurde ausgeführt, nachdem die Response gesendet wurde, sodass [Exceptionhandler](../tutorial/handling-errors.md#install-custom-exception-handlers){.internal-link target=_blank} bereits ausgeführt worden wären. Dies war so designt, hauptsächlich um die Verwendung derselben von Abhängigkeiten „geyieldeten“ Objekte in Hintergrundtasks zu ermöglichen, da der Exit-Code erst ausgeführt wurde, nachdem die Hintergrundtasks abgeschlossen waren. diff --git a/docs/de/docs/advanced/behind-a-proxy.md b/docs/de/docs/advanced/behind-a-proxy.md index 036916cbe..183d0beee 100644 --- a/docs/de/docs/advanced/behind-a-proxy.md +++ b/docs/de/docs/advanced/behind-a-proxy.md @@ -64,7 +64,7 @@ Wenn Sie mehr über HTTPS erfahren möchten, lesen Sie den Leitfaden [Über HTTP /// -### Wie Proxy-Forwarded-Header funktionieren +### Wie Proxy-Forwarded-Header funktionieren { #how-proxy-forwarded-headers-work } Hier ist eine visuelle Darstellung, wie der **Proxy** weitergeleitete Header zwischen dem Client und dem **Anwendungsserver** hinzufügt: @@ -228,7 +228,7 @@ Die Übergabe des `root_path` an `FastAPI` wäre das Äquivalent zur Übergabe d Beachten Sie, dass der Server (Uvicorn) diesen `root_path` für nichts anderes verwendet als für die Weitergabe an die Anwendung. -Aber wenn Sie mit Ihrem Browser auf http://127.0.0.1:8000/app gehen, sehen Sie die normale Response: +Aber wenn Sie mit Ihrem Browser auf http://127.0.0.1:8000/app gehen, sehen Sie die normale Response: ```JSON { @@ -443,6 +443,14 @@ Die Dokumentationsoberfläche interagiert mit dem von Ihnen ausgewählten Server /// +/// note | Technische Details + +Die Eigenschaft `servers` in der OpenAPI-Spezifikation ist optional. + +Wenn Sie den Parameter `servers` nicht angeben und `root_path` den Wert `/` hat, wird die Eigenschaft `servers` im generierten OpenAPI-Schema standardmäßig vollständig weggelassen, was dem Äquivalent eines einzelnen Servers mit einem `url`-Wert von `/` entspricht. + +/// + ### Den automatischen Server von `root_path` deaktivieren { #disable-automatic-server-from-root-path } Wenn Sie nicht möchten, dass **FastAPI** einen automatischen Server inkludiert, welcher `root_path` verwendet, können Sie den Parameter `root_path_in_servers=False` verwenden: diff --git a/docs/de/docs/advanced/dataclasses.md b/docs/de/docs/advanced/dataclasses.md index 12ea8e9ec..e2d59c776 100644 --- a/docs/de/docs/advanced/dataclasses.md +++ b/docs/de/docs/advanced/dataclasses.md @@ -4,7 +4,7 @@ FastAPI basiert auf **Pydantic**, und ich habe Ihnen gezeigt, wie Sie Pydantic-M Aber FastAPI unterstützt auf die gleiche Weise auch die Verwendung von `dataclasses`: -{* ../../docs_src/dataclasses/tutorial001.py hl[1,7:12,19:20] *} +{* ../../docs_src/dataclasses/tutorial001_py310.py hl[1,6:11,18:19] *} Das ist dank **Pydantic** ebenfalls möglich, da es `dataclasses` intern unterstützt. @@ -32,7 +32,7 @@ Wenn Sie jedoch eine Menge Datenklassen herumliegen haben, ist dies ein guter Tr Sie können `dataclasses` auch im Parameter `response_model` verwenden: -{* ../../docs_src/dataclasses/tutorial002.py hl[1,7:13,19] *} +{* ../../docs_src/dataclasses/tutorial002_py310.py hl[1,6:12,18] *} Die Datenklasse wird automatisch in eine Pydantic-Datenklasse konvertiert. @@ -48,7 +48,7 @@ In einigen Fällen müssen Sie möglicherweise immer noch Pydantics Version von In diesem Fall können Sie einfach die Standard-`dataclasses` durch `pydantic.dataclasses` ersetzen, was einen direkten Ersatz darstellt: -{* ../../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. Wir importieren `field` weiterhin von Standard-`dataclasses`. diff --git a/docs/de/docs/advanced/openapi-callbacks.md b/docs/de/docs/advanced/openapi-callbacks.md index afc48bbb8..fd68ab8dc 100644 --- a/docs/de/docs/advanced/openapi-callbacks.md +++ b/docs/de/docs/advanced/openapi-callbacks.md @@ -31,7 +31,7 @@ Sie verfügt über eine *Pfadoperation*, die einen `Invoice`-Body empfängt, und Dieser Teil ist ziemlich normal, der größte Teil des Codes ist Ihnen wahrscheinlich bereits bekannt: -{* ../../docs_src/openapi_callbacks/tutorial001.py hl[9:13,36:53] *} +{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[7:11,34:51] *} /// tip | Tipp @@ -90,7 +90,7 @@ Wenn Sie diese Sichtweise (des *externen Entwicklers*) vorübergehend übernehme Erstellen Sie zunächst einen neuen `APIRouter`, der einen oder mehrere Callbacks enthält. -{* ../../docs_src/openapi_callbacks/tutorial001.py hl[3,25] *} +{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[1,23] *} ### Die Callback-*Pfadoperation* erstellen { #create-the-callback-path-operation } @@ -101,7 +101,7 @@ Sie sollte wie eine normale FastAPI-*Pfadoperation* aussehen: * Sie sollte wahrscheinlich eine Deklaration des Bodys enthalten, die sie erhalten soll, z. B. `body: InvoiceEvent`. * Und sie könnte auch eine Deklaration der Response enthalten, die zurückgegeben werden soll, z. B. `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] *} Es gibt zwei Hauptunterschiede zu einer normalen *Pfadoperation*: @@ -169,7 +169,7 @@ An diesem Punkt haben Sie die benötigte(n) *Callback-Pfadoperation(en)* (diejen Verwenden Sie nun den Parameter `callbacks` im *Pfadoperation-Dekorator Ihrer API*, um das Attribut `.routes` (das ist eigentlich nur eine `list`e von Routen/*Pfadoperationen*) dieses Callback-Routers zu übergeben: -{* ../../docs_src/openapi_callbacks/tutorial001.py hl[35] *} +{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[33] *} /// tip | Tipp diff --git a/docs/de/docs/advanced/path-operation-advanced-configuration.md b/docs/de/docs/advanced/path-operation-advanced-configuration.md index f5ec7c49e..bad768feb 100644 --- a/docs/de/docs/advanced/path-operation-advanced-configuration.md +++ b/docs/de/docs/advanced/path-operation-advanced-configuration.md @@ -50,7 +50,7 @@ Das Hinzufügen eines `\f` (ein maskiertes „Form Feed“-Zeichen) führt dazu, Sie wird nicht in der Dokumentation angezeigt, aber andere Tools (z. B. Sphinx) können den Rest verwenden. -{* ../../docs_src/path_operation_advanced_configuration/tutorial004.py hl[19:29] *} +{* ../../docs_src/path_operation_advanced_configuration/tutorial004_py310.py hl[17:27] *} ## Zusätzliche Responses { #additional-responses } @@ -155,13 +155,13 @@ In der folgenden Anwendung verwenden wir beispielsweise weder die integrierte Fu //// 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 @@ Und dann parsen wir in unserem Code diesen YAML-Inhalt direkt und verwenden dann //// 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/de/docs/advanced/response-directly.md b/docs/de/docs/advanced/response-directly.md index d99517373..06ec2c32e 100644 --- a/docs/de/docs/advanced/response-directly.md +++ b/docs/de/docs/advanced/response-directly.md @@ -34,7 +34,7 @@ Sie können beispielsweise kein Pydantic-Modell in eine `JSONResponse` einfügen In diesen Fällen können Sie den `jsonable_encoder` verwenden, um Ihre Daten zu konvertieren, bevor Sie sie an eine Response übergeben: -{* ../../docs_src/response_directly/tutorial001.py hl[6:7,21:22] *} +{* ../../docs_src/response_directly/tutorial001_py310.py hl[5:6,20:21] *} /// note | Technische Details diff --git a/docs/de/docs/advanced/settings.md b/docs/de/docs/advanced/settings.md index ccd7f373d..03263a28b 100644 --- a/docs/de/docs/advanced/settings.md +++ b/docs/de/docs/advanced/settings.md @@ -148,7 +148,7 @@ Dies könnte besonders beim Testen nützlich sein, da es sehr einfach ist, eine Ausgehend vom vorherigen Beispiel könnte Ihre Datei `config.py` so aussehen: -{* ../../docs_src/settings/app02/config.py hl[10] *} +{* ../../docs_src/settings/app02_an_py39/config.py hl[10] *} Beachten Sie, dass wir jetzt keine Standardinstanz `settings = Settings()` erstellen. @@ -174,7 +174,7 @@ Und dann können wir das von der *Pfadoperation-Funktion* als Abhängigkeit einf Dann wäre es sehr einfach, beim Testen ein anderes Einstellungsobjekt bereitzustellen, indem man eine Abhängigkeitsüberschreibung für `get_settings` erstellt: -{* ../../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] *} Bei der Abhängigkeitsüberschreibung legen wir einen neuen Wert für `admin_email` fest, wenn wir das neue `Settings`-Objekt erstellen, und geben dann dieses neue Objekt zurück. @@ -217,7 +217,7 @@ Und dann aktualisieren Sie Ihre `config.py` mit: //// tab | Pydantic v2 -{* ../../docs_src/settings/app03_an/config.py hl[9] *} +{* ../../docs_src/settings/app03_an_py39/config.py hl[9] *} /// tip | Tipp @@ -229,7 +229,7 @@ Das Attribut `model_config` wird nur für die Pydantic-Konfiguration verwendet. //// 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 | Tipp diff --git a/docs/de/docs/deployment/cloud.md b/docs/de/docs/deployment/cloud.md index ca1ba3b3b..ad3ff76db 100644 --- a/docs/de/docs/deployment/cloud.md +++ b/docs/de/docs/deployment/cloud.md @@ -1,16 +1,24 @@ -# FastAPI bei Cloudanbietern bereitstellen { #deploy-fastapi-on-cloud-providers } +# FastAPI bei Cloudanbietern deployen { #deploy-fastapi-on-cloud-providers } Sie können praktisch **jeden Cloudanbieter** verwenden, um Ihre FastAPI-Anwendung bereitzustellen. -In den meisten Fällen bieten die großen Cloudanbieter Anleitungen zum Bereitstellen von FastAPI an. +In den meisten Fällen bieten die großen Cloudanbieter Anleitungen zum Deployment von FastAPI an. + +## FastAPI Cloud { #fastapi-cloud } + +**FastAPI Cloud** wurde vom selben Autor und Team hinter **FastAPI** entwickelt. + +Es vereinfacht den Prozess des **Erstellens**, **Deployens** und **Zugreifens** auf eine API mit minimalem Aufwand. + +Es bringt die gleiche **Developer-Experience** beim Erstellen von Apps mit FastAPI auch zum **Deployment** in der Cloud. 🎉 + +FastAPI Cloud ist der Hauptsponsor und Finanzierungsgeber für die *FastAPI and friends* Open-Source-Projekte. ✨ ## Cloudanbieter – Sponsoren { #cloud-providers-sponsors } -Einige Cloudanbieter ✨ [**sponsern FastAPI**](../help-fastapi.md#sponsor-the-author){.internal-link target=_blank} ✨, dies stellt die kontinuierliche und gesunde **Entwicklung** von FastAPI und seinem **Ökosystem** sicher. +Einige andere Cloudanbieter ✨ [**sponsern FastAPI**](../help-fastapi.md#sponsor-the-author){.internal-link target=_blank} ✨ ebenfalls. 🙇 -Und es zeigt ihr wahres Engagement für FastAPI und seine **Community** (Sie), da sie Ihnen nicht nur einen **guten Service** bieten möchten, sondern auch sicherstellen möchten, dass Sie ein **gutes und gesundes Framework**, FastAPI, haben. 🙇 - -Vielleicht möchten Sie deren Dienste ausprobieren und deren Anleitungen folgen: +Sie könnten diese ebenfalls in Betracht ziehen, deren Anleitungen folgen und ihre Dienste ausprobieren: * Render * Railway diff --git a/docs/de/docs/deployment/concepts.md b/docs/de/docs/deployment/concepts.md index ef0f458a7..dde922805 100644 --- a/docs/de/docs/deployment/concepts.md +++ b/docs/de/docs/deployment/concepts.md @@ -1,6 +1,6 @@ # Deployment-Konzepte { #deployments-concepts } -Bei dem Deployment – der Bereitstellung – einer **FastAPI**-Anwendung, oder eigentlich jeder Art von Web-API, gibt es mehrere Konzepte, die Sie wahrscheinlich interessieren, und mithilfe der Sie die **am besten geeignete** Methode zur **Bereitstellung Ihrer Anwendung** finden können. +Bei dem Deployment – der Bereitstellung – einer **FastAPI**-Anwendung, oder eigentlich jeder Art von Web-API, gibt es mehrere Konzepte, die Sie wahrscheinlich interessieren, und mithilfe der Sie die **am besten geeignete** Methode zum **Deployment Ihrer Anwendung** finden können. Einige wichtige Konzepte sind: @@ -15,11 +15,11 @@ Wir werden sehen, wie diese sich auf das **Deployment** auswirken. Letztendlich besteht das ultimative Ziel darin, **Ihre API-Clients** auf **sichere** Weise zu versorgen, um **Unterbrechungen** zu vermeiden und die **Rechenressourcen** (z. B. entfernte Server/virtuelle Maschinen) so effizient wie möglich zu nutzen. 🚀 -Ich erzähle Ihnen hier etwas mehr über diese **Konzepte**, was Ihnen hoffentlich die **Intuition** gibt, die Sie benötigen, um zu entscheiden, wie Sie Ihre API in sehr unterschiedlichen Umgebungen bereitstellen, möglicherweise sogar in **zukünftigen**, die jetzt noch nicht existieren. +Ich erzähle Ihnen hier etwas mehr über diese **Konzepte**, was Ihnen hoffentlich die **Intuition** gibt, die Sie benötigen, um zu entscheiden, wie Sie Ihre API in sehr unterschiedlichen Umgebungen deployen, möglicherweise sogar in **zukünftigen**, die jetzt noch nicht existieren. -Durch die Berücksichtigung dieser Konzepte können Sie die beste Variante der Bereitstellung **Ihrer eigenen APIs** **evaluieren und konzipieren**. +Durch die Berücksichtigung dieser Konzepte können Sie die beste Variante des Deployments **Ihrer eigenen APIs** **evaluieren und konzipieren**. -In den nächsten Kapiteln werde ich Ihnen mehr **konkrete Rezepte** für die Bereitstellung von FastAPI-Anwendungen geben. +In den nächsten Kapiteln werde ich Ihnen mehr **konkrete Rezepte** für das Deployment von FastAPI-Anwendungen geben. Aber schauen wir uns zunächst einmal diese grundlegenden **konzeptionellen Ideen** an. Diese Konzepte gelten auch für jede andere Art von Web-API. 💡 @@ -271,7 +271,7 @@ In diesem Fall müssen Sie sich darüber keine Sorgen machen. 🤷 ### Beispiele für Strategien für Vorab-Schritte { #examples-of-previous-steps-strategies } -Es hängt **stark** davon ab, wie Sie **Ihr System bereitstellen**, und hängt wahrscheinlich mit der Art und Weise zusammen, wie Sie Programme starten, Neustarts durchführen, usw. +Es hängt **stark** davon ab, wie Sie **Ihr System deployen**, und hängt wahrscheinlich mit der Art und Weise zusammen, wie Sie Programme starten, Neustarts durchführen, usw. Hier sind einige mögliche Ideen: @@ -307,7 +307,7 @@ Sie können einfache Tools wie `htop` verwenden, um die in Ihrem Server verwende ## Zusammenfassung { #recap } -Sie haben hier einige der wichtigsten Konzepte gelesen, die Sie wahrscheinlich berücksichtigen müssen, wenn Sie entscheiden, wie Sie Ihre Anwendung bereitstellen: +Sie haben hier einige der wichtigsten Konzepte gelesen, die Sie wahrscheinlich berücksichtigen müssen, wenn Sie entscheiden, wie Sie Ihre Anwendung deployen: * Sicherheit – HTTPS * Beim Hochfahren ausführen diff --git a/docs/de/docs/deployment/docker.md b/docs/de/docs/deployment/docker.md index 52ac99913..d4b74635d 100644 --- a/docs/de/docs/deployment/docker.md +++ b/docs/de/docs/deployment/docker.md @@ -1,6 +1,6 @@ # FastAPI in Containern – Docker { #fastapi-in-containers-docker } -Beim Deployment von FastAPI-Anwendungen besteht ein gängiger Ansatz darin, ein **Linux-Containerimage** zu erstellen. Normalerweise erfolgt dies mit **Docker**. Sie können dieses Containerimage dann auf eine von mehreren möglichen Arten bereitstellen. +Beim Deployment von FastAPI-Anwendungen besteht ein gängiger Ansatz darin, ein **Linux-Containerimage** zu erstellen. Normalerweise erfolgt dies mit **Docker**. Sie können dieses Containerimage dann auf eine von mehreren möglichen Arten deployen. Die Verwendung von Linux-Containern bietet mehrere Vorteile, darunter **Sicherheit**, **Replizierbarkeit**, **Einfachheit** und andere. @@ -40,7 +40,7 @@ Linux-Container werden mit demselben Linux-Kernel des Hosts (Maschine, virtuelle Auf diese Weise verbrauchen Container **wenig Ressourcen**, eine Menge vergleichbar mit der direkten Ausführung der Prozesse (eine virtuelle Maschine würde viel mehr verbrauchen). -Container verfügen außerdem über ihre eigenen **isoliert** laufenden Prozesse (üblicherweise nur einen Prozess), über ihr eigenes Dateisystem und ihr eigenes Netzwerk, was die Bereitstellung, Sicherheit, Entwicklung usw. vereinfacht. +Container verfügen außerdem über ihre eigenen **isoliert** laufenden Prozesse (üblicherweise nur einen Prozess), über ihr eigenes Dateisystem und ihr eigenes Netzwerk, was Deployment, Sicherheit, Entwicklung usw. vereinfacht. ## Was ist ein Containerimage { #what-is-a-container-image } @@ -598,7 +598,7 @@ Zum Beispiel: * Mit einem **Kubernetes**-Cluster * Mit einem Docker Swarm Mode-Cluster * Mit einem anderen Tool wie Nomad -* Mit einem Cloud-Dienst, der Ihr Containerimage nimmt und es bereitstellt +* Mit einem Cloud-Dienst, der Ihr Containerimage nimmt und es deployt ## Docker-Image mit `uv` { #docker-image-with-uv } diff --git a/docs/de/docs/deployment/fastapicloud.md b/docs/de/docs/deployment/fastapicloud.md new file mode 100644 index 000000000..18c3bb8a4 --- /dev/null +++ b/docs/de/docs/deployment/fastapicloud.md @@ -0,0 +1,65 @@ +# FastAPI Cloud { #fastapi-cloud } + +Sie können Ihre FastAPI-App in der FastAPI Cloud mit **einem einzigen Befehl** deployen – tragen Sie sich in die Warteliste ein, falls noch nicht geschehen. 🚀 + +## Anmelden { #login } + +Stellen Sie sicher, dass Sie bereits ein **FastAPI-Cloud-Konto** haben (wir haben Sie von der Warteliste eingeladen 😉). + +Melden Sie sich dann an: + +
+ +```console +$ fastapi login + +You are logged in to FastAPI Cloud 🚀 +``` + +
+ +## Deployen { #deploy } + +Stellen Sie Ihre App jetzt mit **einem einzigen Befehl** bereit: + +
+ +```console +$ fastapi deploy + +Deploying to FastAPI Cloud... + +✅ Deployment successful! + +🐔 Ready the chicken! Your app is ready at https://myapp.fastapicloud.dev +``` + +
+ +Das war’s! Jetzt können Sie Ihre App unter dieser URL aufrufen. ✨ + +## Über FastAPI Cloud { #about-fastapi-cloud } + +**FastAPI Cloud** wird vom gleichen Autor und Team hinter **FastAPI** entwickelt. + +Es vereinfacht den Prozess des **Erstellens**, **Deployens** und **Nutzens** einer API mit minimalem Aufwand. + +Es bringt die gleiche **Developer-Experience** beim Erstellen von Apps mit FastAPI auch zum **Deployment** in der Cloud. 🎉 + +Es kümmert sich außerdem um das meiste, was beim Deployen einer App nötig ist, zum Beispiel: + +* HTTPS +* Replikation, mit Autoscaling basierend auf Requests +* usw. + +FastAPI Cloud ist Hauptsponsor und Finanzierer der Open-Source-Projekte *FastAPI and friends*. ✨ + +## Bei anderen Cloudanbietern deployen { #deploy-to-other-cloud-providers } + +FastAPI ist Open Source und basiert auf Standards. Sie können FastAPI-Apps bei jedem Cloudanbieter Ihrer Wahl deployen. + +Folgen Sie den Anleitungen Ihres Cloudanbieters, um dort FastAPI-Apps zu deployen. 🤓 + +## Auf den eigenen Server deployen { #deploy-your-own-server } + +Ich werde Ihnen später in diesem **Deployment-Leitfaden** auch alle Details zeigen, sodass Sie verstehen, was passiert, was geschehen muss und wie Sie FastAPI-Apps selbst deployen können, auch auf Ihre eigenen Server. 🤓 diff --git a/docs/de/docs/deployment/index.md b/docs/de/docs/deployment/index.md index 65c76edce..cb3e53746 100644 --- a/docs/de/docs/deployment/index.md +++ b/docs/de/docs/deployment/index.md @@ -14,7 +14,9 @@ Das steht im Gegensatz zu den **Entwicklungsphasen**, in denen Sie ständig den Es gibt mehrere Möglichkeiten, dies zu tun, abhängig von Ihrem spezifischen Anwendungsfall und den von Ihnen verwendeten Tools. -Sie könnten mithilfe einer Kombination von Tools selbst **einen Server bereitstellen**, Sie könnten einen **Cloud-Dienst** nutzen, der einen Teil der Arbeit für Sie erledigt, oder andere mögliche Optionen. +Sie könnten mithilfe einer Kombination von Tools selbst **einen Server deployen**, Sie könnten einen **Cloud-Dienst** nutzen, der einen Teil der Arbeit für Sie erledigt, oder andere mögliche Optionen. + +Zum Beispiel haben wir, das Team hinter FastAPI, **FastAPI Cloud** entwickelt, um das Deployment von FastAPI-Apps in der Cloud so reibungslos wie möglich zu gestalten, mit derselben Developer-Experience wie beim Arbeiten mit FastAPI. Ich zeige Ihnen einige der wichtigsten Konzepte, die Sie beim Deployment einer **FastAPI**-Anwendung wahrscheinlich berücksichtigen sollten (obwohl das meiste davon auch für jede andere Art von Webanwendung gilt). diff --git a/docs/de/docs/deployment/server-workers.md b/docs/de/docs/deployment/server-workers.md index 169ed822b..7b68f1b1a 100644 --- a/docs/de/docs/deployment/server-workers.md +++ b/docs/de/docs/deployment/server-workers.md @@ -11,7 +11,7 @@ Schauen wir uns die Deployment-Konzepte von früher noch einmal an: Bis zu diesem Punkt, in allen Tutorials in der Dokumentation, haben Sie wahrscheinlich ein **Serverprogramm** ausgeführt, zum Beispiel mit dem `fastapi`-Befehl, der Uvicorn startet, und einen **einzelnen Prozess** ausführt. -Wenn Sie Anwendungen bereitstellen, möchten Sie wahrscheinlich eine gewisse **Replikation von Prozessen**, um **mehrere Kerne** zu nutzen und mehr Requests bearbeiten zu können. +Wenn Sie Anwendungen deployen, möchten Sie wahrscheinlich eine gewisse **Replikation von Prozessen**, um **mehrere Kerne** zu nutzen und mehr Requests bearbeiten zu können. Wie Sie im vorherigen Kapitel über [Deployment-Konzepte](concepts.md){.internal-link target=_blank} gesehen haben, gibt es mehrere Strategien, die Sie anwenden können. diff --git a/docs/de/docs/fastapi-cli.md b/docs/de/docs/fastapi-cli.md index ab9c8373e..86a797a9e 100644 --- a/docs/de/docs/fastapi-cli.md +++ b/docs/de/docs/fastapi-cli.md @@ -66,7 +66,7 @@ Das Ausführen von `fastapi run` startet FastAPI standardmäßig im Produktionsm Standardmäßig ist **Autoreload** deaktiviert. Es horcht auch auf der IP-Adresse `0.0.0.0`, was alle verfügbaren IP-Adressen bedeutet, so wird es öffentlich zugänglich für jeden, der mit der Maschine kommunizieren kann. So würden Sie es normalerweise in der Produktion ausführen, beispielsweise in einem Container. -In den meisten Fällen würden (und sollten) Sie einen „Terminierungsproxy“ haben, der HTTPS für Sie verwaltet. Dies hängt davon ab, wie Sie Ihre Anwendung bereitstellen. Ihr Anbieter könnte dies für Sie erledigen, oder Sie müssen es selbst einrichten. +In den meisten Fällen würden (und sollten) Sie einen „Terminierungsproxy“ haben, der HTTPS für Sie verwaltet. Dies hängt davon ab, wie Sie Ihre Anwendung deployen. Ihr Anbieter könnte dies für Sie erledigen, oder Sie müssen es selbst einrichten. /// tip | Tipp diff --git a/docs/de/docs/how-to/authentication-error-status-code.md b/docs/de/docs/how-to/authentication-error-status-code.md new file mode 100644 index 000000000..c743b54d9 --- /dev/null +++ b/docs/de/docs/how-to/authentication-error-status-code.md @@ -0,0 +1,17 @@ +# Alte 403-Authentifizierungsfehler-Statuscodes verwenden { #use-old-403-authentication-error-status-codes } + +Vor FastAPI-Version `0.122.0` verwendeten die integrierten Sicherheits-Utilities den HTTP-Statuscode `403 Forbidden`, wenn sie dem Client nach einer fehlgeschlagenen Authentifizierung einen Fehler zurückgaben. + +Ab FastAPI-Version `0.122.0` verwenden sie den passenderen HTTP-Statuscode `401 Unauthorized` und geben in der Response einen sinnvollen `WWW-Authenticate`-Header zurück, gemäß den HTTP-Spezifikationen, RFC 7235, RFC 9110. + +Aber falls Ihre Clients aus irgendeinem Grund vom alten Verhalten abhängen, können Sie darauf zurückgreifen, indem Sie in Ihren Sicherheitsklassen die Methode `make_not_authenticated_error` überschreiben. + +Sie können beispielsweise eine Unterklasse von `HTTPBearer` erstellen, die einen Fehler `403 Forbidden` zurückgibt, statt des Default-`401 Unauthorized`-Fehlers: + +{* ../../docs_src/authentication_error_status_code/tutorial001_an_py39.py hl[9:13] *} + +/// tip | Tipp + +Beachten Sie, dass die Funktion die Exception-Instanz zurückgibt; sie wirft sie nicht. Das Werfen erfolgt im restlichen internen Code. + +/// diff --git a/docs/de/docs/how-to/configure-swagger-ui.md b/docs/de/docs/how-to/configure-swagger-ui.md index 351cb996c..3616f03ac 100644 --- a/docs/de/docs/how-to/configure-swagger-ui.md +++ b/docs/de/docs/how-to/configure-swagger-ui.md @@ -40,7 +40,7 @@ FastAPI enthält einige Defaultkonfigurationsparameter, die für die meisten Anw Es umfasst die folgenden Defaultkonfigurationen: -{* ../../fastapi/openapi/docs.py ln[8:23] hl[17:23] *} +{* ../../fastapi/openapi/docs.py ln[9:24] hl[18:24] *} Sie können jede davon überschreiben, indem Sie im Argument `swagger_ui_parameters` einen anderen Wert festlegen. diff --git a/docs/de/docs/how-to/custom-request-and-route.md b/docs/de/docs/how-to/custom-request-and-route.md index 246717c04..017de2096 100644 --- a/docs/de/docs/how-to/custom-request-and-route.md +++ b/docs/de/docs/how-to/custom-request-and-route.md @@ -42,7 +42,7 @@ Wenn der Header kein `gzip` enthält, wird nicht versucht, den Body zu dekomprim Auf diese Weise kann dieselbe Routenklasse gzip-komprimierte oder unkomprimierte Requests verarbeiten. -{* ../../docs_src/custom_request_and_route/tutorial001.py hl[8:15] *} +{* ../../docs_src/custom_request_and_route/tutorial001_an_py310.py hl[9:16] *} ### Eine benutzerdefinierte `GzipRoute`-Klasse erstellen { #create-a-custom-gziproute-class } @@ -54,7 +54,7 @@ Diese Methode gibt eine Funktion zurück. Und diese Funktion empfängt einen OpenAPI (früher bekannt als Swagger) und JSON Schema. -* Schätzung basierend auf Tests in einem internen Entwicklungsteam, das Produktionsanwendungen erstellt. +* Schätzung basierend auf Tests, die von einem internen Entwicklungsteam durchgeführt wurden, das Produktionsanwendungen erstellt. ## Sponsoren { #sponsors } -{% if sponsors %} +### Keystone-Sponsor + +{% for sponsor in sponsors.keystone -%} + +{% endfor -%} + +### Gold- und Silber-Sponsoren + {% for sponsor in sponsors.gold -%} {% endfor -%} {%- for sponsor in sponsors.silver -%} {% endfor %} -{% endif %} @@ -444,6 +450,58 @@ Für ein vollständigeres Beispiel, mit weiteren Funktionen, siehe das FastAPI Cloud deployen, treten Sie der Warteliste bei, falls noch nicht geschehen. 🚀 + +Wenn Sie bereits ein **FastAPI Cloud**-Konto haben (wir haben Sie von der Warteliste eingeladen 😉), können Sie Ihre Anwendung mit einem einzigen Befehl deployen. + +Stellen Sie vor dem Deployen sicher, dass Sie eingeloggt sind: + +
+ +```console +$ fastapi login + +You are logged in to FastAPI Cloud 🚀 +``` + +
+ +Stellen Sie dann Ihre App bereit: + +
+ +```console +$ fastapi deploy + +Deploying to FastAPI Cloud... + +✅ Deployment successful! + +🐔 Ready the chicken! Your app is ready at https://myapp.fastapicloud.dev +``` + +
+ +Das war’s! Jetzt können Sie unter dieser URL auf Ihre App zugreifen. ✨ + +#### Über FastAPI Cloud { #about-fastapi-cloud } + +**FastAPI Cloud** wird vom selben Autor und Team hinter **FastAPI** entwickelt. + +Es vereinfacht den Prozess des **Erstellens**, **Deployens** und **Zugreifens** auf eine API mit minimalem Aufwand. + +Es bringt die gleiche **Developer-Experience** beim Erstellen von Apps mit FastAPI auch zum **Deployment** in der Cloud. 🎉 + +FastAPI Cloud ist der Hauptsponsor und Finanzierer der „FastAPI and friends“ Open-Source-Projekte. ✨ + +#### Bei anderen Cloudanbietern deployen { #deploy-to-other-cloud-providers } + +FastAPI ist Open Source und basiert auf Standards. Sie können FastAPI-Apps bei jedem Cloudanbieter Ihrer Wahl deployen. + +Folgen Sie den Anleitungen Ihres Cloudanbieters, um FastAPI-Apps dort bereitzustellen. 🤓 + ## Performanz { #performance } Unabhängige TechEmpower-Benchmarks zeigen **FastAPI**-Anwendungen, die unter Uvicorn laufen, als eines der schnellsten verfügbaren Python-Frameworks, nur hinter Starlette und Uvicorn selbst (intern von FastAPI verwendet). (*) diff --git a/docs/de/docs/project-generation.md b/docs/de/docs/project-generation.md index e6da4949c..290f605b3 100644 --- a/docs/de/docs/project-generation.md +++ b/docs/de/docs/project-generation.md @@ -9,20 +9,20 @@ GitHub-Repository: ../../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+ nicht annotiert - -/// tip | Tipp - -Bevorzugen Sie die `Annotated`-Version, falls möglich. - -/// - -```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 | Tipp @@ -181,9 +149,7 @@ Wir wissen, dass alle *Pfadoperationen* in diesem Modul folgendes haben: Anstatt also alles zu jeder *Pfadoperation* hinzuzufügen, können wir es dem `APIRouter` hinzufügen. -```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"] *} Da der Pfad jeder *Pfadoperation* mit `/` beginnen muss, wie in: @@ -242,9 +208,7 @@ Und wir müssen die Abhängigkeitsfunktion aus dem Modul `app.dependencies` impo Daher verwenden wir einen relativen Import mit `..` für die Abhängigkeiten: -```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"] *} #### Wie relative Importe funktionieren { #how-relative-imports-work } @@ -315,9 +279,7 @@ Wir fügen weder das Präfix `/items` noch `tags=["items"]` zu jeder *Pfadoperat Aber wir können immer noch _mehr_ `tags` hinzufügen, die auf eine bestimmte *Pfadoperation* angewendet werden, sowie einige zusätzliche `responses`, die speziell für diese *Pfadoperation* gelten: -```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 | Tipp @@ -343,17 +305,13 @@ Sie importieren und erstellen wie gewohnt eine `FastAPI`-Klasse. Und wir können sogar [globale Abhängigkeiten](dependencies/global-dependencies.md){.internal-link target=_blank} deklarieren, die mit den Abhängigkeiten für jeden `APIRouter` kombiniert werden: -```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"] *} ### Den `APIRouter` importieren { #import-the-apirouter } Jetzt importieren wir die anderen Submodule, die `APIRouter` haben: -```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"] *} Da es sich bei den Dateien `app/routers/users.py` und `app/routers/items.py` um Submodule handelt, die Teil desselben Python-Packages `app` sind, können wir einen einzelnen Punkt `.` verwenden, um sie mit „relativen Imports“ zu importieren. @@ -416,17 +374,13 @@ würde der `router` von `users` den von `items` überschreiben und wir könnten Um also beide in derselben Datei verwenden zu können, importieren wir die Submodule direkt: -```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"] *} ### Die `APIRouter` für `users` und `items` inkludieren { #include-the-apirouters-for-users-and-items } Inkludieren wir nun die `router` aus diesen Submodulen `users` und `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 | Info @@ -466,17 +420,13 @@ Sie enthält einen `APIRouter` mit einigen administrativen *Pfadoperationen*, di In diesem Beispiel wird es ganz einfach sein. Nehmen wir jedoch an, dass wir, da sie mit anderen Projekten in der Organisation geteilt wird, sie nicht ändern und kein `prefix`, `dependencies`, `tags`, usw. direkt zum `APIRouter` hinzufügen können: -```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"] *} Aber wir möchten immer noch ein benutzerdefiniertes `prefix` festlegen, wenn wir den `APIRouter` einbinden, sodass alle seine *Pfadoperationen* mit `/admin` beginnen, wir möchten es mit den `dependencies` sichern, die wir bereits für dieses Projekt haben, und wir möchten `tags` und `responses` hinzufügen. Wir können das alles deklarieren, ohne den ursprünglichen `APIRouter` ändern zu müssen, indem wir diese Parameter an `app.include_router()` übergeben: -```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"] *} Auf diese Weise bleibt der ursprüngliche `APIRouter` unverändert, sodass wir dieselbe `app/internal/admin.py`-Datei weiterhin mit anderen Projekten in der Organisation teilen können. @@ -497,9 +447,7 @@ Wir können *Pfadoperationen* auch direkt zur `FastAPI`-App hinzufügen. Hier machen wir es ... nur um zu zeigen, dass wir es können 🤷: -```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"] *} und es wird korrekt funktionieren, zusammen mit allen anderen *Pfadoperationen*, die mit `app.include_router()` hinzugefügt wurden. diff --git a/docs/de/docs/tutorial/cookie-param-models.md b/docs/de/docs/tutorial/cookie-param-models.md index 2baf3d70d..25718bd33 100644 --- a/docs/de/docs/tutorial/cookie-param-models.md +++ b/docs/de/docs/tutorial/cookie-param-models.md @@ -50,7 +50,7 @@ Ihre API hat jetzt die Macht, ihre eigene Response**. diff --git a/docs/de/docs/tutorial/first-steps.md b/docs/de/docs/tutorial/first-steps.md index 7ec98c53b..71912d035 100644 --- a/docs/de/docs/tutorial/first-steps.md +++ b/docs/de/docs/tutorial/first-steps.md @@ -143,6 +143,42 @@ Es gibt dutzende Alternativen, die alle auf OpenAPI basieren. Sie können jede d Ebenfalls können Sie es verwenden, um automatisch Code für Clients zu generieren, die mit Ihrer API kommunizieren. Zum Beispiel für Frontend-, Mobile- oder IoT-Anwendungen. +### Ihre App deployen (optional) { #deploy-your-app-optional } + +Sie können optional Ihre FastAPI-App in der FastAPI Cloud deployen, treten Sie der Warteliste bei, falls Sie es noch nicht getan haben. 🚀 + +Wenn Sie bereits ein **FastAPI Cloud**-Konto haben (wir haben Sie von der Warteliste eingeladen 😉), können Sie Ihre Anwendung mit einem Befehl deployen. + +Vor dem Deployen, stellen Sie sicher, dass Sie eingeloggt sind: + +
+ +```console +$ fastapi login + +You are logged in to FastAPI Cloud 🚀 +``` + +
+ +Dann stellen Sie Ihre App bereit: + +
+ +```console +$ fastapi deploy + +Deploying to FastAPI Cloud... + +✅ Deployment successful! + +🐔 Ready the chicken! Your app is ready at https://myapp.fastapicloud.dev +``` + +
+ +Das war's! Jetzt können Sie Ihre App unter dieser URL aufrufen. ✨ + ## Zusammenfassung, Schritt für Schritt { #recap-step-by-step } ### Schritt 1: `FastAPI` importieren { #step-1-import-fastapi } @@ -314,6 +350,26 @@ Sie können auch Pydantic-Modelle zurückgeben (dazu später mehr). Es gibt viele andere Objekte und Modelle, die automatisch zu JSON konvertiert werden (einschließlich ORMs, usw.). Versuchen Sie, Ihre Lieblingsobjekte zu verwenden. Es ist sehr wahrscheinlich, dass sie bereits unterstützt werden. +### Schritt 6: Deployen { #step-6-deploy-it } + +Stellen Sie Ihre App in der **FastAPI Cloud** mit einem Befehl bereit: `fastapi deploy`. 🎉 + +#### Über FastAPI Cloud { #about-fastapi-cloud } + +**FastAPI Cloud** wird vom selben Autor und Team hinter **FastAPI** entwickelt. + +Es vereinfacht den Prozess des Erstellens, Deployens und des Zugriffs auf eine API mit minimalem Aufwand. + +Es bringt die gleiche **Developer-Experience** beim Erstellen von Apps mit FastAPI auch zum **Deployment** in der Cloud. 🎉 + +FastAPI Cloud ist der Hauptsponsor und Finanzierer der „FastAPI and friends“ Open-Source-Projekte. ✨ + +#### Zu anderen Cloudanbietern deployen { #deploy-to-other-cloud-providers } + +FastAPI ist Open Source und basiert auf Standards. Sie können FastAPI-Apps bei jedem Cloudanbieter Ihrer Wahl deployen. + +Folgen Sie den Anleitungen Ihres Cloudanbieters, um dort FastAPI-Apps bereitzustellen. 🤓 + ## Zusammenfassung { #recap } * Importieren Sie `FastAPI`. @@ -321,3 +377,4 @@ Es gibt viele andere Objekte und Modelle, die automatisch zu JSON konvertiert we * Schreiben Sie einen **Pfadoperation-Dekorator** unter Verwendung von Dekoratoren wie `@app.get("/")`. * Definieren Sie eine **Pfadoperation-Funktion**, zum Beispiel `def root(): ...`. * Starten Sie den Entwicklungsserver mit dem Befehl `fastapi dev`. +* Optional: Ihre App mit `fastapi deploy` deployen. diff --git a/docs/de/docs/tutorial/handling-errors.md b/docs/de/docs/tutorial/handling-errors.md index 58e4607c5..a39c3db37 100644 --- a/docs/de/docs/tutorial/handling-errors.md +++ b/docs/de/docs/tutorial/handling-errors.md @@ -127,7 +127,7 @@ Um diesen zu überschreiben, importieren Sie den `RequestValidationError` und ve Der Exceptionhandler erhält einen `Request` und die Exception. -{* ../../docs_src/handling_errors/tutorial004.py hl[2,14:16] *} +{* ../../docs_src/handling_errors/tutorial004.py hl[2,14:19] *} Wenn Sie nun zu `/items/foo` gehen, erhalten Sie anstelle des standardmäßigen JSON-Fehlers mit: @@ -149,36 +149,17 @@ Wenn Sie nun zu `/items/foo` gehen, erhalten Sie anstelle des standardmäßigen eine Textversion mit: ``` -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` vs. `ValidationError` { #requestvalidationerror-vs-validationerror } - -/// warning | Achtung - -Dies sind technische Details, die Sie überspringen können, wenn sie für Sie jetzt nicht wichtig sind. - -/// - -`RequestValidationError` ist eine Unterklasse von Pydantics `ValidationError`. - -**FastAPI** verwendet diesen so, dass, wenn Sie ein Pydantic-Modell in `response_model` verwenden und Ihre Daten einen Fehler haben, Sie den Fehler in Ihrem Log sehen. - -Aber der Client/Benutzer wird ihn nicht sehen. Stattdessen erhält der Client einen „Internal Server Error“ mit einem HTTP-Statuscode `500`. - -Es sollte so sein, denn wenn Sie einen Pydantic `ValidationError` in Ihrer *Response* oder irgendwo anders in Ihrem Code haben (nicht im *Request* des Clients), ist es tatsächlich ein Fehler in Ihrem Code. - -Und während Sie den Fehler beheben, sollten Ihre Clients/Benutzer keinen Zugriff auf interne Informationen über den Fehler haben, da das eine Sicherheitslücke aufdecken könnte. - ### Überschreiben des `HTTPException`-Fehlerhandlers { #override-the-httpexception-error-handler } Auf die gleiche Weise können Sie den `HTTPException`-Handler überschreiben. Zum Beispiel könnten Sie eine Klartext-Response statt JSON für diese Fehler zurückgeben wollen: -{* ../../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 | Technische Details @@ -188,6 +169,14 @@ Sie könnten auch `from starlette.responses import PlainTextResponse` verwenden. /// +/// warning | Achtung + +Beachten Sie, dass der `RequestValidationError` Informationen über den Dateinamen und die Zeile enthält, in der der Validierungsfehler auftritt, sodass Sie ihn bei Bedarf mit den relevanten Informationen in Ihren Logs anzeigen können. + +Das bedeutet aber auch, dass, wenn Sie ihn einfach in einen String umwandeln und diese Informationen direkt zurückgeben, Sie möglicherweise ein paar Informationen über Ihr System preisgeben. Daher extrahiert und zeigt der Code hier jeden Fehler getrennt. + +/// + ### Verwenden des `RequestValidationError`-Bodys { #use-the-requestvalidationerror-body } Der `RequestValidationError` enthält den empfangenen `body` mit den ungültigen Daten. diff --git a/docs/de/docs/tutorial/sql-databases.md b/docs/de/docs/tutorial/sql-databases.md index cf9731aee..3af4ecdfc 100644 --- a/docs/de/docs/tutorial/sql-databases.md +++ b/docs/de/docs/tutorial/sql-databases.md @@ -65,7 +65,7 @@ Es gibt ein paar Unterschiede: * `Field(primary_key=True)` sagt SQLModel, dass die `id` der **Primärschlüssel** in der SQL-Datenbank ist (Sie können mehr über SQL-Primärschlüssel in der SQLModel-Dokumentation erfahren). - Durch das Festlegen des Typs als `int | None` wird SQLModel wissen, dass diese Spalte ein `INTEGER` in der SQL-Datenbank sein sollte und dass sie `NULLABLE` sein sollte. + **Hinweis:** Wir verwenden für das Primärschlüsselfeld `int | None`, damit wir im Python-Code *ein Objekt ohne `id` erstellen* können (`id=None`), in der Annahme, dass die Datenbank sie *beim Speichern generiert*. SQLModel versteht, dass die Datenbank die `id` bereitstellt, und *definiert die Spalte im Datenbankschema als ein Nicht-Null-`INTEGER`*. Siehe die SQLModel-Dokumentation zu Primärschlüsseln für Details. * `Field(index=True)` sagt SQLModel, dass es einen **SQL-Index** für diese Spalte erstellen soll, was schnelleres Suchen in der Datenbank ermöglicht, wenn Daten mittels dieser Spalte gefiltert werden. diff --git a/docs/de/docs/tutorial/testing.md b/docs/de/docs/tutorial/testing.md index 9c28a2a22..b18469998 100644 --- a/docs/de/docs/tutorial/testing.md +++ b/docs/de/docs/tutorial/testing.md @@ -122,63 +122,13 @@ Sie verfügt über eine `POST`-Operation, die mehrere Fehler zurückgeben könnt Beide *Pfadoperationen* erfordern einen `X-Token`-Header. -//// 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+ nicht annotiert - -/// tip | Tipp - -Bevorzugen Sie die `Annotated`-Version, falls möglich. - -/// - -```Python -{!> ../../docs_src/app_testing/app_b_py310/main.py!} -``` - -//// - -//// tab | Python 3.8+ nicht annotiert - -/// tip | Tipp - -Bevorzugen Sie die `Annotated`-Version, falls möglich. - -/// - -```Python -{!> ../../docs_src/app_testing/app_b/main.py!} -``` - -//// +{* ../../docs_src/app_testing/app_b_an_py310/main.py *} ### Erweiterte Testdatei { #extended-testing-file } Anschließend könnten Sie `test_main.py` mit den erweiterten Tests aktualisieren: -{* ../../docs_src/app_testing/app_b/test_main.py *} +{* ../../docs_src/app_testing/app_b_an_py310/test_main.py *} Wenn Sie möchten, dass der Client Informationen im Request übergibt und Sie nicht wissen, wie das geht, können Sie suchen (googeln), wie es mit `httpx` gemacht wird, oder sogar, wie es mit `requests` gemacht wird, da das Design von HTTPX auf dem Design von Requests basiert. diff --git a/docs/de/docs/virtual-environments.md b/docs/de/docs/virtual-environments.md index 497f1b44d..11da496c5 100644 --- a/docs/de/docs/virtual-environments.md +++ b/docs/de/docs/virtual-environments.md @@ -242,6 +242,26 @@ $ python -m pip install --upgrade pip +/// tip | Tipp + +Manchmal kann beim Versuch, `pip` zu aktualisieren, der Fehler **`No module named pip`** auftreten. + +Wenn das passiert, installieren und aktualisieren Sie `pip` mit dem folgenden Befehl: + +
+ +```console +$ python -m ensurepip --upgrade + +---> 100% +``` + +
+ +Dieser Befehl installiert `pip`, falls es noch nicht installiert ist, und stellt außerdem sicher, dass die installierte Version von `pip` mindestens so aktuell ist wie die in `ensurepip` verfügbare. + +/// + ## `.gitignore` hinzufügen { #add-gitignore } Wenn Sie **Git** verwenden (was Sie sollten), fügen Sie eine `.gitignore`-Datei hinzu, um alles in Ihrem `.venv` von Git auszuschließen. diff --git a/docs/de/llm-prompt.md b/docs/de/llm-prompt.md index df202d2ff..5df904ac7 100644 --- a/docs/de/llm-prompt.md +++ b/docs/de/llm-prompt.md @@ -255,6 +255,7 @@ Below is a list of English terms and their preferred German translations, separa * «the default value»: «der Defaultwert» * «the default value»: NOT «der Standardwert» * «the default declaration»: «die Default-Deklaration» +* «the deployment»: «das Deployment» * «the dict»: «das Dict» * «the dictionary»: «das Dictionary» * «the enumeration»: «die Enumeration» @@ -316,6 +317,7 @@ Below is a list of English terms and their preferred German translations, separa * «the worker process»: «der Workerprozess» * «the worker process»: NOT «der Arbeiterprozess» * «to commit»: «committen» +* «to deploy» (in the cloud): «deployen» * «to modify»: «ändern» * «to serve» (an application): «bereitstellen» * «to serve» (a response): «ausliefern» diff --git a/docs/en/data/external_links.yml b/docs/en/data/external_links.yml deleted file mode 100644 index 6e71ab9eb..000000000 --- a/docs/en/data/external_links.yml +++ /dev/null @@ -1,430 +0,0 @@ -Articles: - English: - - author: Apitally - author_link: https://apitally.io - link: https://apitally.io/blog/getting-started-with-logging-in-fastapi - title: Getting started with logging in FastAPI - - author: Balthazar Rouberol - author_link: https://balthazar-rouberol.com - link: https://blog.balthazar-rouberol.com/how-to-profile-a-fastapi-asynchronous-request - title: How to profile a FastAPI asynchronous request - - author: Stephen Siegert - Neon - link: https://neon.tech/blog/deploy-a-serverless-fastapi-app-with-neon-postgres-and-aws-app-runner-at-any-scale - title: Deploy a Serverless FastAPI App with Neon Postgres and AWS App Runner at any scale - - author: Kurtis Pykes - NVIDIA - link: https://developer.nvidia.com/blog/building-a-machine-learning-microservice-with-fastapi/ - title: Building a Machine Learning Microservice with FastAPI - - author: Ravgeet Dhillon - Twilio - link: https://www.twilio.com/en-us/blog/booking-appointments-twilio-notion-fastapi - title: Booking Appointments with Twilio, Notion, and FastAPI - - author: Abhinav Tripathi - Microsoft Blogs - link: https://devblogs.microsoft.com/cosmosdb/azure-cosmos-db-python-and-fastapi/ - title: Write a Python data layer with Azure Cosmos DB and FastAPI - - author: Donny Peeters - author_link: https://github.com/Donnype - link: https://bitestreams.com/blog/fastapi-sqlalchemy/ - title: 10 Tips for adding SQLAlchemy to FastAPI - - author: Jessica Temporal - author_link: https://jtemporal.com/socials - link: https://jtemporal.com/tips-on-migrating-from-flask-to-fastapi-and-vice-versa/ - title: Tips on migrating from Flask to FastAPI and vice-versa - - author: Ankit Anchlia - author_link: https://linkedin.com/in/aanchlia21 - link: https://hackernoon.com/explore-how-to-effectively-use-jwt-with-fastapi - title: Explore How to Effectively Use JWT With FastAPI - - author: Nicoló Lino - author_link: https://www.nlino.com - link: https://github.com/softwarebloat/python-tracing-demo - title: Instrument FastAPI with OpenTelemetry tracing and visualize traces in Grafana Tempo. - - author: Mikhail Rozhkov, Elena Samuylova - author_link: https://www.linkedin.com/in/mnrozhkov/ - link: https://www.evidentlyai.com/blog/fastapi-tutorial - title: ML serving and monitoring with FastAPI and Evidently - - author: Visual Studio Code Team - author_link: https://code.visualstudio.com/ - link: https://code.visualstudio.com/docs/python/tutorial-fastapi - title: FastAPI Tutorial in Visual Studio Code - - author: Apitally - author_link: https://apitally.io - link: https://blog.apitally.io/fastapi-application-monitoring-made-easy - title: FastAPI application monitoring made easy - - author: John Philip - author_link: https://medium.com/@amjohnphilip - link: https://python.plainenglish.io/building-a-restful-api-with-fastapi-secure-signup-and-login-functionality-included-45cdbcb36106 - title: "Building a RESTful API with FastAPI: Secure Signup and Login Functionality Included" - - author: Keshav Malik - author_link: https://theinfosecguy.xyz/ - link: https://blog.theinfosecguy.xyz/building-a-crud-api-with-fastapi-and-supabase-a-step-by-step-guide - title: Building a CRUD API with FastAPI and Supabase - - author: Adejumo Ridwan Suleiman - author_link: https://www.linkedin.com/in/adejumoridwan/ - link: https://medium.com/python-in-plain-english/build-an-sms-spam-classifier-serverless-database-with-faunadb-and-fastapi-23dbb275bc5b - title: Build an SMS Spam Classifier Serverless Database with FaunaDB and FastAPI - - author: Raf Rasenberg - author_link: https://rafrasenberg.com/about/ - link: https://rafrasenberg.com/fastapi-lambda/ - title: 'FastAPI lambda container: serverless simplified' - - author: Teresa N. Fontanella De Santis - author_link: https://dev.to/ - link: https://dev.to/teresafds/authorization-on-fastapi-with-casbin-41og - title: Authorization on FastAPI with Casbin - - author: New Relic - author_link: https://newrelic.com - link: https://newrelic.com/instant-observability/fastapi/e559ec64-f765-4470-a15f-1901fcebb468 - title: How to monitor FastAPI application performance using Python agent - - author: Jean-Baptiste Rocher - author_link: https://hashnode.com/@jibrocher - link: https://dev.indooroutdoor.io/series/fastapi-react-poll-app - title: Building the Poll App From Django Tutorial With FastAPI And React - - author: Silvan Melchior - author_link: https://github.com/silvanmelchior - link: https://blog.devgenius.io/seamless-fastapi-configuration-with-confz-90949c14ea12 - title: Seamless FastAPI Configuration with ConfZ - - author: Kaustubh Gupta - author_link: https://medium.com/@kaustubhgupta1828/ - link: https://levelup.gitconnected.com/5-advance-features-of-fastapi-you-should-try-7c0ac7eebb3e - title: 5 Advanced Features of FastAPI You Should Try - - author: Kaustubh Gupta - author_link: https://medium.com/@kaustubhgupta1828/ - link: https://www.analyticsvidhya.com/blog/2021/06/deploying-ml-models-as-api-using-fastapi-and-heroku/ - title: Deploying ML Models as API Using FastAPI and Heroku - - link: https://jarmos.netlify.app/posts/using-github-actions-to-deploy-a-fastapi-project-to-heroku/ - title: Using GitHub Actions to Deploy a FastAPI Project to Heroku - author_link: https://jarmos.netlify.app/ - author: Somraj Saha - - author: "@pystar" - author_link: https://pystar.substack.com/ - link: https://pystar.substack.com/p/how-to-create-a-fake-certificate - title: How to Create A Fake Certificate Authority And Generate TLS Certs for FastAPI - - author: Ben Gamble - author_link: https://uk.linkedin.com/in/bengamble7 - link: https://ably.com/blog/realtime-ticket-booking-solution-kafka-fastapi-ably - title: Building a realtime ticket booking solution with Kafka, FastAPI, and Ably - - author: Shahriyar(Shako) Rzayev - author_link: https://www.linkedin.com/in/shahriyar-rzayev/ - link: https://www.azepug.az/posts/fastapi/#building-simple-e-commerce-with-nuxtjs-and-fastapi-series - title: Building simple E-Commerce with NuxtJS and FastAPI - - author: Rodrigo Arenas - author_link: https://rodrigo-arenas.medium.com/ - link: https://medium.com/analytics-vidhya/serve-a-machine-learning-model-using-sklearn-fastapi-and-docker-85aabf96729b - title: "Serve a machine learning model using Sklearn, FastAPI and Docker" - - author: Yashasvi Singh - author_link: https://hashnode.com/@aUnicornDev - link: https://aunicorndev.hashnode.dev/series/supafast-api - title: "Building an API with FastAPI and Supabase and Deploying on Deta" - - author: Navule Pavan Kumar Rao - author_link: https://www.linkedin.com/in/navule/ - link: https://www.tutlinks.com/deploy-fastapi-on-ubuntu-gunicorn-caddy-2/ - title: Deploy FastAPI on Ubuntu and Serve using Caddy 2 Web Server - - author: Patrick Ladon - author_link: https://dev.to/factorlive - link: https://dev.to/factorlive/python-facebook-messenger-webhook-with-fastapi-on-glitch-4n90 - title: Python Facebook messenger webhook with FastAPI on Glitch - - author: Valon Januzaj - author_link: https://www.linkedin.com/in/valon-januzaj-b02692187/ - link: https://valonjanuzaj.medium.com/deploy-a-dockerized-fastapi-application-to-aws-cc757830ba1b - title: Deploy a dockerized FastAPI application to AWS - - author: Amit Chaudhary - author_link: https://x.com/amitness - link: https://amitness.com/2020/06/fastapi-vs-flask/ - title: FastAPI for Flask Users - - author: Louis Guitton - author_link: https://x.com/louis_guitton - link: https://guitton.co/posts/fastapi-monitoring/ - title: How to monitor your FastAPI service - - author: Precious Ndubueze - author_link: https://medium.com/@gabbyprecious2000 - link: https://medium.com/@gabbyprecious2000/creating-a-crud-app-with-fastapi-part-one-7c049292ad37 - title: Creating a CRUD App with FastAPI (Part one) - - author: Farhad Malik - author_link: https://medium.com/@farhadmalik - link: https://towardsdatascience.com/build-and-host-fast-data-science-applications-using-fastapi-823be8a1d6a0 - title: Build And Host Fast Data Science Applications Using FastAPI - - author: Navule Pavan Kumar Rao - author_link: https://www.linkedin.com/in/navule/ - link: https://www.tutlinks.com/deploy-fastapi-on-azure/ - title: Deploy FastAPI on Azure App Service - - author: Davide Fiocco - author_link: https://github.com/davidefiocco - link: https://davidefiocco.github.io/streamlit-fastapi-ml-serving/ - title: Machine learning model serving in Python using FastAPI and streamlit - - author: Netflix - author_link: https://netflixtechblog.com/ - link: https://netflixtechblog.com/introducing-dispatch-da4b8a2a8072 - title: Introducing Dispatch - - author: Stavros Korokithakis - author_link: https://x.com/Stavros - link: https://www.stavros.io/posts/fastapi-with-django/ - title: Using FastAPI with Django - - author: Twilio - author_link: https://www.twilio.com - link: https://www.twilio.com/blog/build-secure-twilio-webhook-python-fastapi - title: Build a Secure Twilio Webhook with Python and FastAPI - - author: Sebastián Ramírez (tiangolo) - author_link: https://x.com/tiangolo - link: https://dev.to/tiangolo/build-a-web-api-from-scratch-with-fastapi-the-workshop-2ehe - title: Build a web API from scratch with FastAPI - the workshop - - author: Paul Sec - author_link: https://x.com/PaulWebSec - link: https://paulsec.github.io/posts/fastapi_plus_zeit_serverless_fu/ - title: FastAPI + Zeit.co = 🚀 - - author: cuongld2 - author_link: https://dev.to/cuongld2 - link: https://dev.to/cuongld2/build-simple-api-service-with-python-fastapi-part-1-581o - title: Build simple API service with Python FastAPI — Part 1 - - author: Paurakh Sharma Humagain - author_link: https://x.com/PaurakhSharma - link: https://dev.to/paurakhsharma/microservice-in-python-using-fastapi-24cc - title: Microservice in Python using FastAPI - - author: Guillermo Cruz - author_link: https://wuilly.com/ - link: https://wuilly.com/2019/10/real-time-notifications-with-python-and-postgres/ - title: Real-time Notifications with Python and Postgres - - author: Navule Pavan Kumar Rao - author_link: https://www.linkedin.com/in/navule/ - link: https://www.tutlinks.com/create-and-deploy-fastapi-app-to-heroku/ - title: Create and Deploy FastAPI app to Heroku without using Docker - - author: Arthur Henrique - author_link: https://x.com/arthurheinrique - link: https://medium.com/@arthur393/another-boilerplate-to-fastapi-azure-pipeline-ci-pytest-3c8d9a4be0bb - title: 'Another Boilerplate to FastAPI: Azure Pipeline CI + Pytest' - - author: Shane Soh - author_link: https://medium.com/@shane.soh - link: https://medium.com/analytics-vidhya/deploy-machine-learning-models-with-keras-fastapi-redis-and-docker-4940df614ece - title: Deploy Machine Learning Models with Keras, FastAPI, Redis and Docker - - author: Mandy Gu - author_link: https://towardsdatascience.com/@mandygu - link: https://towardsdatascience.com/deploying-iris-classifications-with-fastapi-and-docker-7c9b83fdec3a - title: 'Towards Data Science: Deploying Iris Classifications with FastAPI and Docker' - - author: Michael Herman - author_link: https://testdriven.io/authors/herman - link: https://testdriven.io/blog/fastapi-crud/ - title: 'TestDriven.io: Developing and Testing an Asynchronous API with FastAPI and Pytest' - - author: Bernard Brenyah - author_link: https://medium.com/@bbrenyah - link: https://medium.com/python-data/how-to-deploy-tensorflow-2-0-models-as-an-api-service-with-fastapi-docker-128b177e81f3 - title: How To Deploy Tensorflow 2.0 Models As An API Service With FastAPI & Docker - - author: Dylan Anthony - author_link: https://dev.to/dbanty - link: https://dev.to/dbanty/why-i-m-leaving-flask-3ki6 - title: Why I'm Leaving Flask - - author: Mike Moritz - author_link: https://medium.com/@mike.p.moritz - link: https://medium.com/@mike.p.moritz/using-docker-compose-to-deploy-a-lightweight-python-rest-api-with-a-job-queue-37e6072a209b - title: Using Docker Compose to deploy a lightweight Python REST API with a job queue - - author: '@euri10' - author_link: https://gitlab.com/euri10 - link: https://gitlab.com/euri10/fastapi_cheatsheet - title: A FastAPI and Swagger UI visual cheatsheet - - author: Uber Engineering - author_link: https://eng.uber.com - link: https://eng.uber.com/ludwig-v0-2/ - title: 'Uber: Ludwig v0.2 Adds New Features and Other Improvements to its Deep Learning Toolbox [including a FastAPI server]' - - author: Maarten Grootendorst - author_link: https://www.linkedin.com/in/mgrootendorst/ - link: https://towardsdatascience.com/how-to-deploy-a-machine-learning-model-dc51200fe8cf - title: How to Deploy a Machine Learning Model - - author: Johannes Gontrum - author_link: https://x.com/gntrm - link: https://medium.com/@gntrm/jwt-authentication-with-fastapi-and-aws-cognito-1333f7f2729e - title: JWT Authentication with FastAPI and AWS Cognito - - author: Ankush Thakur - author_link: https://geekflare.com/author/ankush/ - link: https://geekflare.com/python-asynchronous-web-frameworks/ - title: Top 5 Asynchronous Web Frameworks for Python - - author: Nico Axtmann - author_link: https://www.linkedin.com/in/nico-axtmann - link: https://medium.com/@nico.axtmann95/deploying-a-scikit-learn-model-with-onnx-und-fastapi-1af398268915 - title: Deploying a scikit-learn model with ONNX and FastAPI - - author: Nils de Bruin - author_link: https://medium.com/@nilsdebruin - link: https://medium.com/data-rebels/fastapi-authentication-revisited-enabling-api-key-authentication-122dc5975680 - title: 'FastAPI authentication revisited: Enabling API key authentication' - - author: Nick Cortale - author_link: https://nickc1.github.io/ - link: https://nickc1.github.io/api,/scikit-learn/2019/01/10/scikit-fastapi.html - title: 'FastAPI and Scikit-Learn: Easily Deploy Models' - - author: Errieta Kostala - author_link: https://dev.to/errietta - link: https://dev.to/errietta/introduction-to-the-fastapi-python-framework-2n10 - title: Introduction to the fastapi python framework - - author: Nils de Bruin - author_link: https://medium.com/@nilsdebruin - link: https://medium.com/data-rebels/fastapi-how-to-add-basic-and-cookie-authentication-a45c85ef47d3 - title: FastAPI — How to add basic and cookie authentication - - author: Nils de Bruin - author_link: https://medium.com/@nilsdebruin - link: https://medium.com/data-rebels/fastapi-google-as-an-external-authentication-provider-3a527672cf33 - title: FastAPI — Google as an external authentication provider - - author: William Hayes - author_link: https://medium.com/@williamhayes - link: https://medium.com/@williamhayes/fastapi-starlette-debug-vs-prod-5f7561db3a59 - title: FastAPI/Starlette debug vs prod - - author: Mukul Mantosh - author_link: https://x.com/MantoshMukul - link: https://www.jetbrains.com/pycharm/guide/tutorials/fastapi-aws-kubernetes/ - title: Developing FastAPI Application using K8s & AWS - - author: KrishNa - author_link: https://medium.com/@krishnardt365 - link: https://medium.com/@krishnardt365/fastapi-docker-and-postgres-91943e71be92 - title: Fastapi, Docker(Docker compose) and Postgres - - author: Devon Ray - author_link: https://devonray.com - link: https://devonray.com/blog/deploying-a-fastapi-project-using-aws-lambda-aurora-cdk - title: Deployment using Docker, Lambda, Aurora, CDK & GH Actions - - author: Shubhendra Kushwaha - author_link: https://www.linkedin.com/in/theshubhendra/ - link: https://theshubhendra.medium.com/mastering-soft-delete-advanced-sqlalchemy-techniques-4678f4738947 - title: 'Mastering Soft Delete: Advanced SQLAlchemy Techniques' - - author: Shubhendra Kushwaha - author_link: https://www.linkedin.com/in/theshubhendra/ - link: https://theshubhendra.medium.com/role-based-row-filtering-advanced-sqlalchemy-techniques-733e6b1328f6 - title: 'Role based row filtering: Advanced SQLAlchemy Techniques' - German: - - author: Marcel Sander (actidoo) - author_link: https://www.actidoo.com - link: https://www.actidoo.com/de/blog/python-fastapi-domain-driven-design - title: Domain-driven Design mit Python und FastAPI - - author: Nico Axtmann - author_link: https://x.com/_nicoax - link: https://blog.codecentric.de/2019/08/inbetriebnahme-eines-scikit-learn-modells-mit-onnx-und-fastapi/ - title: Inbetriebnahme eines scikit-learn-Modells mit ONNX und FastAPI - - author: Felix Schürmeyer - author_link: https://hellocoding.de/autor/felix-schuermeyer/ - link: https://hellocoding.de/blog/coding-language/python/fastapi - title: REST-API Programmieren mittels Python und dem FastAPI Modul - Japanese: - - author: '@bee2' - author_link: https://qiita.com/bee2 - link: https://qiita.com/bee2/items/75d9c0d7ba20e7a4a0e9 - title: '[FastAPI] Python製のASGI Web フレームワーク FastAPIに入門する' - - author: '@bee2' - author_link: https://qiita.com/bee2 - link: https://qiita.com/bee2/items/0ad260ab9835a2087dae - title: PythonのWeb frameworkのパフォーマンス比較 (Django, Flask, responder, FastAPI, japronto) - - author: ライトコードメディア編集部 - author_link: https://rightcode.co.jp/author/jun - link: https://rightcode.co.jp/blog/information-technology/fastapi-tutorial-todo-apps-admin-page-improvement - title: '【第4回】FastAPIチュートリアル: toDoアプリを作ってみよう【管理者ページ改良編】' - - author: ライトコードメディア編集部 - author_link: https://rightcode.co.jp/author/jun - link: https://rightcode.co.jp/blog/information-technology/fastapi-tutorial-todo-apps-authentication-user-registration - title: '【第3回】FastAPIチュートリアル: toDoアプリを作ってみよう【認証・ユーザ登録編】' - - author: ライトコードメディア編集部 - author_link: https://rightcode.co.jp/author/jun - link: https://rightcode.co.jp/blog/information-technology/fastapi-tutorial-todo-apps-model-building - title: '【第2回】FastAPIチュートリアル: ToDoアプリを作ってみよう【モデル構築編】' - - author: ライトコードメディア編集部 - author_link: https://rightcode.co.jp/author/jun - link: https://rightcode.co.jp/blog/information-technology/fastapi-tutorial-todo-apps-environment - title: '【第1回】FastAPIチュートリアル: ToDoアプリを作ってみよう【環境構築編】' - - author: Hikaru Takahashi - author_link: https://qiita.com/hikarut - link: https://qiita.com/hikarut/items/b178af2e2440c67c6ac4 - title: フロントエンド開発者向けのDockerによるPython開発環境構築 - - author: '@angel_katayoku' - author_link: https://qiita.com/angel_katayoku - link: https://qiita.com/angel_katayoku/items/8a458a8952f50b73f420 - title: FastAPIでPOSTされたJSONのレスポンスbodyを受け取る - - author: '@angel_katayoku' - author_link: https://qiita.com/angel_katayoku - link: https://qiita.com/angel_katayoku/items/4fbc1a4e2b33fa2237d2 - title: FastAPIをMySQLと接続してDockerで管理してみる - - author: '@angel_katayoku' - author_link: https://qiita.com/angel_katayoku - link: https://qiita.com/angel_katayoku/items/0e1f5dbbe62efc612a78 - title: FastAPIでCORSを回避 - - author: '@ryoryomaru' - author_link: https://qiita.com/ryoryomaru - link: https://qiita.com/ryoryomaru/items/59958ed385b3571d50de - title: python製の最新APIフレームワーク FastAPI を触ってみた - - author: '@mtitg' - author_link: https://qiita.com/mtitg - link: https://qiita.com/mtitg/items/47770e9a562dd150631d - title: FastAPI|DB接続してCRUDするPython製APIサーバーを構築 - Portuguese: - - author: Eduardo Mendes - author_link: https://bolha.us/@dunossauro - link: https://fastapidozero.dunossauro.com/ - title: FastAPI do ZERO - - author: Jessica Temporal - author_link: https://jtemporal.com/socials - link: https://jtemporal.com/dicas-para-migrar-de-flask-para-fastapi-e-vice-versa/ - title: Dicas para migrar uma aplicação de Flask para FastAPI e vice-versa - Russian: - - author: Troy Köhler - author_link: https://www.linkedin.com/in/trkohler/ - link: https://trkohler.com/fast-api-introduction-to-framework - title: 'FastAPI: знакомимся с фреймворком' - - author: prostomarkeloff - author_link: https://github.com/prostomarkeloff - link: https://habr.com/ru/post/478620/ - title: Почему Вы должны попробовать FastAPI? - - author: Andrey Korchak - author_link: https://habr.com/ru/users/57uff3r/ - link: https://habr.com/ru/post/454440/ - title: 'Мелкая питонячая радость #2: Starlette - Солидная примочка – FastAPI' - Vietnamese: - - author: Nguyễn Nhân - author_link: https://fullstackstation.com/author/figonking/ - link: https://fullstackstation.com/fastapi-trien-khai-bang-docker/ - title: 'FASTAPI: TRIỂN KHAI BẰNG DOCKER' - Taiwanese: - - author: Leon - author_link: http://editor.leonh.space/ - link: https://editor.leonh.space/2022/tortoise/ - title: 'Tortoise ORM / FastAPI 整合快速筆記' - Spanish: - - author: Eduardo Zepeda - author_link: https://coffeebytes.dev/en/authors/eduardo-zepeda/ - link: https://coffeebytes.dev/es/python-fastapi-el-mejor-framework-de-python/ - title: 'Tutorial de FastAPI, ¿el mejor framework de Python?' -Podcasts: - English: - - author: Behind the Commit - author_link: https://www.youtube.com/@BehindtheCommit - link: https://youtu.be/iaDRYUQ0OMM - title: Why FastAPI Became Python’s Fastest‑Growing Framework – Chat with Sebastián Ramírez - - author: Real Python - author_link: https://realpython.com/ - link: https://realpython.com/podcasts/rpp/72/ - title: Starting With FastAPI and Examining Python's Import System - Episode 72 - - author: Python Bytes FM - author_link: https://pythonbytes.fm/ - link: https://www.pythonpodcast.com/fastapi-web-application-framework-episode-259/ - title: 'Do you dare to press "."? - Episode 247 - Dan #6: SQLModel - use the same models for SQL and FastAPI' - - author: Podcast.`__init__` - author_link: https://www.pythonpodcast.com/ - link: https://www.pythonpodcast.com/fastapi-web-application-framework-episode-259/ - title: Build The Next Generation Of Python Web Applications With FastAPI - Episode 259 - interview to Sebastían Ramírez (tiangolo) - - author: Python Bytes FM - author_link: https://pythonbytes.fm/ - link: https://pythonbytes.fm/episodes/show/123/time-to-right-the-py-wrongs?time_in_sec=855 - title: FastAPI on PythonBytes -Talks: - English: - - author: Sebastián Ramírez (tiangolo) - author_link: https://x.com/tiangolo - link: https://www.youtube.com/watch?v=mwvmfl8nN_U - title: 'Keynote: Behind the scenes of FastAPI and friends for developers and builders — Sebastián Ramírez' - - author: Jeny Sadadia - author_link: https://github.com/JenySadadia - link: https://www.youtube.com/watch?v=uZdTe8_Z6BQ - title: 'PyCon AU 2023: Testing asynchronous applications with FastAPI and pytest' - - author: Sebastián Ramírez (tiangolo) - author_link: https://x.com/tiangolo - link: https://www.youtube.com/watch?v=PnpTY1f4k2U - title: '[VIRTUAL] Py.Amsterdam''s flying Software Circus: Intro to FastAPI' - - author: Sebastián Ramírez (tiangolo) - author_link: https://x.com/tiangolo - link: https://www.youtube.com/watch?v=z9K5pwb0rt8 - title: 'PyConBY 2020: Serve ML models easily with FastAPI' - - author: Chris Withers - author_link: https://x.com/chriswithers13 - link: https://www.youtube.com/watch?v=3DLwPcrE5mA - title: 'PyCon UK 2019: FastAPI from the ground up' - Taiwanese: - - author: Blueswen - author_link: https://github.com/blueswen - link: https://www.youtube.com/watch?v=y3sumuoDq4w - title: 'PyCon TW 2024: 全方位強化 Python 服務可觀測性:以 FastAPI 和 Grafana Stack 為例' 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/advanced/additional-responses.md b/docs/en/docs/advanced/additional-responses.md index 799532c5b..cb3a40d13 100644 --- a/docs/en/docs/advanced/additional-responses.md +++ b/docs/en/docs/advanced/additional-responses.md @@ -175,7 +175,7 @@ You can use this same `responses` parameter to add different media types for the For example, you can add an additional media type of `image/png`, declaring that your *path operation* can return a JSON object (with media type `application/json`) or a PNG image: -{* ../../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 @@ You can use that technique to reuse some predefined responses in your *path oper For example: -{* ../../docs_src/additional_responses/tutorial004.py hl[13:17,26] *} +{* ../../docs_src/additional_responses/tutorial004_py310.py hl[11:15,24] *} ## More information about OpenAPI responses { #more-information-about-openapi-responses } diff --git a/docs/en/docs/advanced/behind-a-proxy.md b/docs/en/docs/advanced/behind-a-proxy.md index f692a28e8..f4dbd4560 100644 --- a/docs/en/docs/advanced/behind-a-proxy.md +++ b/docs/en/docs/advanced/behind-a-proxy.md @@ -443,6 +443,14 @@ The docs UI will interact with the server that you select. /// +/// note | Technical Details + +The `servers` property in the OpenAPI specification is optional. + +If you don't specify the `servers` parameter and `root_path` is equal to `/`, the `servers` property in the generated OpenAPI schema will be omitted entirely by default, which is the equivalent of a single server with a `url` value of `/`. + +/// + ### Disable automatic server from `root_path` { #disable-automatic-server-from-root-path } If you don't want **FastAPI** to include an automatic server using the `root_path`, you can use the parameter `root_path_in_servers=False`: diff --git a/docs/en/docs/advanced/dataclasses.md b/docs/en/docs/advanced/dataclasses.md index b7b9b65c5..574beb65f 100644 --- a/docs/en/docs/advanced/dataclasses.md +++ b/docs/en/docs/advanced/dataclasses.md @@ -4,7 +4,7 @@ FastAPI is built on top of **Pydantic**, and I have been showing you how to use But FastAPI also supports using `dataclasses` the same way: -{* ../../docs_src/dataclasses/tutorial001.py hl[1,7:12,19:20] *} +{* ../../docs_src/dataclasses/tutorial001_py310.py hl[1,6:11,18:19] *} This is still supported thanks to **Pydantic**, as it has internal support for `dataclasses`. @@ -32,7 +32,7 @@ But if you have a bunch of dataclasses laying around, this is a nice trick to us You can also use `dataclasses` in the `response_model` parameter: -{* ../../docs_src/dataclasses/tutorial002.py hl[1,7:13,19] *} +{* ../../docs_src/dataclasses/tutorial002_py310.py hl[1,6:12,18] *} The dataclass will be automatically converted to a Pydantic dataclass. @@ -48,7 +48,7 @@ In some cases, you might still have to use Pydantic's version of `dataclasses`. In that case, you can simply swap the standard `dataclasses` with `pydantic.dataclasses`, which is a 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. We still import `field` from standard `dataclasses`. diff --git a/docs/en/docs/advanced/openapi-callbacks.md b/docs/en/docs/advanced/openapi-callbacks.md index 059d893c2..5bd7c2cfd 100644 --- a/docs/en/docs/advanced/openapi-callbacks.md +++ b/docs/en/docs/advanced/openapi-callbacks.md @@ -31,7 +31,7 @@ It will have a *path operation* that will receive an `Invoice` body, and a query This part is pretty normal, most of the code is probably already familiar to you: -{* ../../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 @@ Temporarily adopting this point of view (of the *external developer*) can help y First create a new `APIRouter` that will contain one or more callbacks. -{* ../../docs_src/openapi_callbacks/tutorial001.py hl[3,25] *} +{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[1,23] *} ### Create the callback *path operation* { #create-the-callback-path-operation } @@ -101,7 +101,7 @@ It should look just like a normal FastAPI *path operation*: * It should probably have a declaration of the body it should receive, e.g. `body: InvoiceEvent`. * And it could also have a declaration of the response it should return, e.g. `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] *} There are 2 main differences from a normal *path operation*: @@ -169,7 +169,7 @@ At this point you have the *callback path operation(s)* needed (the one(s) that Now use the parameter `callbacks` in *your API's path operation decorator* to pass the attribute `.routes` (that's actually just a `list` of routes/*path operations*) from that callback router: -{* ../../docs_src/openapi_callbacks/tutorial001.py hl[35] *} +{* ../../docs_src/openapi_callbacks/tutorial001_py310.py hl[33] *} /// tip diff --git a/docs/en/docs/advanced/path-operation-advanced-configuration.md b/docs/en/docs/advanced/path-operation-advanced-configuration.md index b9961f9f3..5879bc5c7 100644 --- a/docs/en/docs/advanced/path-operation-advanced-configuration.md +++ b/docs/en/docs/advanced/path-operation-advanced-configuration.md @@ -50,7 +50,7 @@ Adding an `\f` (an escaped "form feed" character) causes **FastAPI** to truncate It won't show up in the documentation, but other tools (such as Sphinx) will be able to use the rest. -{* ../../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 { #additional-responses } @@ -155,13 +155,13 @@ For example, in this application we don't use FastAPI's integrated functionality //// 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 @@ And then in our code, we parse that YAML content directly, and then we are again //// 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/en/docs/advanced/response-directly.md b/docs/en/docs/advanced/response-directly.md index 3197e1bd4..156b4dac7 100644 --- a/docs/en/docs/advanced/response-directly.md +++ b/docs/en/docs/advanced/response-directly.md @@ -34,7 +34,7 @@ For example, you cannot put a Pydantic model in a `JSONResponse` without first c For those cases, you can use the `jsonable_encoder` to convert your data before passing it to a response: -{* ../../docs_src/response_directly/tutorial001.py hl[6:7,21:22] *} +{* ../../docs_src/response_directly/tutorial001_py310.py hl[5:6,20:21] *} /// note | Technical Details diff --git a/docs/en/docs/advanced/settings.md b/docs/en/docs/advanced/settings.md index a218c3d01..0220c52ce 100644 --- a/docs/en/docs/advanced/settings.md +++ b/docs/en/docs/advanced/settings.md @@ -148,7 +148,7 @@ This could be especially useful during testing, as it's very easy to override a Coming from the previous example, your `config.py` file could look like: -{* ../../docs_src/settings/app02/config.py hl[10] *} +{* ../../docs_src/settings/app02_an_py39/config.py hl[10] *} Notice that now we don't create a default instance `settings = Settings()`. @@ -174,7 +174,7 @@ And then we can require it from the *path operation function* as a dependency an Then it would be very easy to provide a different settings object during testing by creating a dependency override for `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] *} In the dependency override we set a new value for the `admin_email` when creating the new `Settings` object, and then we return that new object. @@ -217,7 +217,7 @@ And then update your `config.py` with: //// 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 @@ The `model_config` attribute is used just for Pydantic configuration. You can re //// 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/en/docs/external-links.md b/docs/en/docs/external-links.md index 3ed04e5c5..481cf1d7f 100644 --- a/docs/en/docs/external-links.md +++ b/docs/en/docs/external-links.md @@ -1,36 +1,22 @@ -# External Links and Articles +# External Links **FastAPI** has a great community constantly growing. There are many posts, articles, tools, and projects, related to **FastAPI**. -Here's an incomplete list of some of them. +You could easily use a search engine or video platform to find many resources related to FastAPI. -/// tip +/// info -If you have an article, project, tool, or anything related to **FastAPI** that is not yet listed here, create a Pull Request adding it. +Before, this page used to list links to external articles. + +But now that FastAPI is the backend framework with the most GitHub stars across languages, and the most starred and used framework in Python, it no longer makes sense to attempt to list all articles written about it. /// -{% for section_name, section_content in external_links.items() %} - -## {{ section_name }} - -{% for lang_name, lang_content in section_content.items() %} - -### {{ lang_name }} - -{% for item in lang_content %} - -* {{ item.title }} by {{ item.author }}. - -{% endfor %} -{% endfor %} -{% endfor %} - ## GitHub Repositories -Most starred GitHub repositories with the topic `fastapi`: +Most starred GitHub repositories with the topic `fastapi`: {% for repo in topic_repos %} diff --git a/docs/en/docs/how-to/configure-swagger-ui.md b/docs/en/docs/how-to/configure-swagger-ui.md index 2d7b99f8f..3dbfcffec 100644 --- a/docs/en/docs/how-to/configure-swagger-ui.md +++ b/docs/en/docs/how-to/configure-swagger-ui.md @@ -40,7 +40,7 @@ FastAPI includes some default configuration parameters appropriate for most of t It includes these default configurations: -{* ../../fastapi/openapi/docs.py ln[8:23] hl[17:23] *} +{* ../../fastapi/openapi/docs.py ln[9:24] hl[18:24] *} You can override any of them by setting a different value in the argument `swagger_ui_parameters`. diff --git a/docs/en/docs/how-to/custom-request-and-route.md b/docs/en/docs/how-to/custom-request-and-route.md index 884c8ed04..bfc60729f 100644 --- a/docs/en/docs/how-to/custom-request-and-route.md +++ b/docs/en/docs/how-to/custom-request-and-route.md @@ -42,7 +42,7 @@ If there's no `gzip` in the header, it will not try to decompress the body. That way, the same route class can handle gzip compressed or uncompressed requests. -{* ../../docs_src/custom_request_and_route/tutorial001.py hl[8:15] *} +{* ../../docs_src/custom_request_and_route/tutorial001_an_py310.py hl[9:16] *} ### Create a custom `GzipRoute` class { #create-a-custom-gziproute-class } @@ -54,7 +54,7 @@ This method returns a function. And that function is what will receive a request Here we use it to create a `GzipRequest` from the original request. -{* ../../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 | Technical Details @@ -92,18 +92,18 @@ We can also use this same approach to access the request body in an exception ha All we need to do is handle the request inside a `try`/`except` block: -{* ../../docs_src/custom_request_and_route/tutorial002.py hl[13,15] *} +{* ../../docs_src/custom_request_and_route/tutorial002_an_py310.py hl[14,16] *} If an exception occurs, the`Request` instance will still be in scope, so we can read and make use of the request body when handling the error: -{* ../../docs_src/custom_request_and_route/tutorial002.py hl[16:18] *} +{* ../../docs_src/custom_request_and_route/tutorial002_an_py310.py hl[17:19] *} ## Custom `APIRoute` class in a router { #custom-apiroute-class-in-a-router } You can also set the `route_class` parameter of an `APIRouter`: -{* ../../docs_src/custom_request_and_route/tutorial003.py hl[26] *} +{* ../../docs_src/custom_request_and_route/tutorial003_py310.py hl[26] *} In this example, the *path operations* under the `router` will use the custom `TimedRoute` class, and will have an extra `X-Response-Time` header in the response with the time it took to generate the response: -{* ../../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/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/project-generation.md b/docs/en/docs/project-generation.md index 44f058b90..610d23ccb 100644 --- a/docs/en/docs/project-generation.md +++ b/docs/en/docs/project-generation.md @@ -9,18 +9,18 @@ GitHub Repository: ../../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 - -Prefer to use the `Annotated` version if possible. - -/// - -```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 @@ -181,9 +149,7 @@ We know all the *path operations* in this module have the same: So, instead of adding all that to each *path operation*, we can add it to the `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"] *} As the path of each *path operation* has to start with `/`, like in: @@ -242,9 +208,7 @@ And we need to get the dependency function from the module `app.dependencies`, t So we use a relative import with `..` for the dependencies: -```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 { #how-relative-imports-work } @@ -315,9 +279,7 @@ We are not adding the prefix `/items` nor the `tags=["items"]` to each *path ope But we can still add _more_ `tags` that will be applied to a specific *path operation*, and also some extra `responses` specific to that *path operation*: -```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 @@ -343,17 +305,13 @@ You import and create a `FastAPI` class as normally. And we can even declare [global dependencies](dependencies/global-dependencies.md){.internal-link target=_blank} that will be combined with the dependencies for each `APIRouter`: -```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"] *} ### Import the `APIRouter` { #import-the-apirouter } Now we import the other submodules that have `APIRouter`s: -```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"] *} As the files `app/routers/users.py` and `app/routers/items.py` are submodules that are part of the same Python package `app`, we can use a single dot `.` to import them using "relative imports". @@ -416,17 +374,13 @@ the `router` from `users` would overwrite the one from `items` and we wouldn't b So, to be able to use both of them in the same file, we import the submodules directly: -```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"] *} ### Include the `APIRouter`s for `users` and `items` { #include-the-apirouters-for-users-and-items } Now, let's include the `router`s from the submodules `users` and `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 @@ -466,17 +420,13 @@ It contains an `APIRouter` with some admin *path operations* that your organizat For this example it will be super simple. But let's say that because it is shared with other projects in the organization, we cannot modify it and add a `prefix`, `dependencies`, `tags`, etc. directly to the `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"] *} But we still want to set a custom `prefix` when including the `APIRouter` so that all its *path operations* start with `/admin`, we want to secure it with the `dependencies` we already have for this project, and we want to include `tags` and `responses`. We can declare all that without having to modify the original `APIRouter` by passing those parameters to `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"] *} That way, the original `APIRouter` will stay unmodified, so we can still share that same `app/internal/admin.py` file with other projects in the organization. @@ -497,9 +447,7 @@ We can also add *path operations* directly to the `FastAPI` app. Here we do it... just to show that we can 🤷: -```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"] *} and it will work correctly, together with all the other *path operations* added with `app.include_router()`. diff --git a/docs/en/docs/tutorial/cookie-param-models.md b/docs/en/docs/tutorial/cookie-param-models.md index 96dc5cf3d..016a65d7f 100644 --- a/docs/en/docs/tutorial/cookie-param-models.md +++ b/docs/en/docs/tutorial/cookie-param-models.md @@ -50,7 +50,7 @@ Your API now has the power to control its own `ValidationError`. - -**FastAPI** uses it so that, if you use a Pydantic model in `response_model`, and your data has an error, you will see the error in your log. - -But the client/user will not see it. Instead, the client will receive an "Internal Server Error" with an HTTP status code `500`. - -It should be this way because if you have a Pydantic `ValidationError` in your *response* or anywhere in your code (not in the client's *request*), it's actually a bug in your code. - -And while you fix it, your clients/users shouldn't have access to internal information about the error, as that could expose a security vulnerability. - ### Override the `HTTPException` error handler { #override-the-httpexception-error-handler } The same way, you can override the `HTTPException` handler. For example, you could want to return a plain text response instead of JSON for these errors: -{* ../../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 | Technical Details @@ -188,6 +169,14 @@ You could also use `from starlette.responses import PlainTextResponse`. /// +/// warning + +Have in mind that the `RequestValidationError` contains the information of the file name and line where the validation error happens so that you can show it in your logs with the relevant information if you want to. + +But that means that if you just convert it to a string and return that information directly, you could be leaking a bit of information about your system, that's why here the code extracts and shows each error independently. + +/// + ### Use the `RequestValidationError` body { #use-the-requestvalidationerror-body } The `RequestValidationError` contains the `body` it received with invalid data. diff --git a/docs/en/docs/tutorial/testing.md b/docs/en/docs/tutorial/testing.md index 3dcf5dc4a..c6a0e5b7d 100644 --- a/docs/en/docs/tutorial/testing.md +++ b/docs/en/docs/tutorial/testing.md @@ -121,63 +121,13 @@ It has a `POST` operation that could return several errors. Both *path operations* require an `X-Token` header. -//// 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+ non-Annotated - -/// tip - -Prefer to use the `Annotated` version if possible. - -/// - -```Python -{!> ../../docs_src/app_testing/app_b_py310/main.py!} -``` - -//// - -//// tab | Python 3.8+ non-Annotated - -/// tip - -Prefer to use the `Annotated` version if possible. - -/// - -```Python -{!> ../../docs_src/app_testing/app_b/main.py!} -``` - -//// +{* ../../docs_src/app_testing/app_b_an_py310/main.py *} ### Extended testing file { #extended-testing-file } You could then update `test_main.py` with the extended tests: -{* ../../docs_src/app_testing/app_b/test_main.py *} +{* ../../docs_src/app_testing/app_b_an_py310/test_main.py *} Whenever you need the client to pass information in the request and you don't know how to, you can search (Google) how to do it in `httpx`, or even how to do it with `requests`, as HTTPX's design is based on Requests' design. diff --git a/docs/en/mkdocs.yml b/docs/en/mkdocs.yml index fd346a3d3..0e0adab9b 100644 --- a/docs/en/mkdocs.yml +++ b/docs/en/mkdocs.yml @@ -59,7 +59,6 @@ plugins: search: null macros: include_yaml: - - external_links: ../en/data/external_links.yml - github_sponsors: ../en/data/github_sponsors.yml - people: ../en/data/people.yml - contributors: ../en/data/contributors.yml diff --git a/docs/pt/docs/project-generation.md b/docs/pt/docs/project-generation.md index c2015dd2c..9c4f1f1ec 100644 --- a/docs/pt/docs/project-generation.md +++ b/docs/pt/docs/project-generation.md @@ -14,7 +14,7 @@ Repositório GitHub: bytes: + if not hasattr(self, "_body"): + body = await super().body() + if "gzip" in self.headers.getlist("Content-Encoding"): + body = gzip.decompress(body) + self._body = body + return self._body + + +class GzipRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + request = GzipRequest(request.scope, request.receive) + return await original_route_handler(request) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = GzipRoute + + +@app.post("/sum") +async def sum_numbers(numbers: Annotated[List[int], Body()]): + return {"sum": sum(numbers)} diff --git a/docs_src/custom_request_and_route/tutorial001_an_py310.py b/docs_src/custom_request_and_route/tutorial001_an_py310.py new file mode 100644 index 000000000..381bab6d8 --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial001_an_py310.py @@ -0,0 +1,36 @@ +import gzip +from collections.abc import Callable +from typing import Annotated + +from fastapi import Body, FastAPI, Request, Response +from fastapi.routing import APIRoute + + +class GzipRequest(Request): + async def body(self) -> bytes: + if not hasattr(self, "_body"): + body = await super().body() + if "gzip" in self.headers.getlist("Content-Encoding"): + body = gzip.decompress(body) + self._body = body + return self._body + + +class GzipRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + request = GzipRequest(request.scope, request.receive) + return await original_route_handler(request) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = GzipRoute + + +@app.post("/sum") +async def sum_numbers(numbers: Annotated[list[int], Body()]): + return {"sum": sum(numbers)} diff --git a/docs_src/custom_request_and_route/tutorial001_an_py39.py b/docs_src/custom_request_and_route/tutorial001_an_py39.py new file mode 100644 index 000000000..076727e64 --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial001_an_py39.py @@ -0,0 +1,35 @@ +import gzip +from typing import Annotated, Callable + +from fastapi import Body, FastAPI, Request, Response +from fastapi.routing import APIRoute + + +class GzipRequest(Request): + async def body(self) -> bytes: + if not hasattr(self, "_body"): + body = await super().body() + if "gzip" in self.headers.getlist("Content-Encoding"): + body = gzip.decompress(body) + self._body = body + return self._body + + +class GzipRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + request = GzipRequest(request.scope, request.receive) + return await original_route_handler(request) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = GzipRoute + + +@app.post("/sum") +async def sum_numbers(numbers: Annotated[list[int], Body()]): + return {"sum": sum(numbers)} diff --git a/docs_src/custom_request_and_route/tutorial001_py310.py b/docs_src/custom_request_and_route/tutorial001_py310.py new file mode 100644 index 000000000..c678088ce --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial001_py310.py @@ -0,0 +1,35 @@ +import gzip +from collections.abc import Callable + +from fastapi import Body, FastAPI, Request, Response +from fastapi.routing import APIRoute + + +class GzipRequest(Request): + async def body(self) -> bytes: + if not hasattr(self, "_body"): + body = await super().body() + if "gzip" in self.headers.getlist("Content-Encoding"): + body = gzip.decompress(body) + self._body = body + return self._body + + +class GzipRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + request = GzipRequest(request.scope, request.receive) + return await original_route_handler(request) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = GzipRoute + + +@app.post("/sum") +async def sum_numbers(numbers: list[int] = Body()): + return {"sum": sum(numbers)} diff --git a/docs_src/custom_request_and_route/tutorial001_py39.py b/docs_src/custom_request_and_route/tutorial001_py39.py new file mode 100644 index 000000000..54b20b942 --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial001_py39.py @@ -0,0 +1,35 @@ +import gzip +from typing import Callable + +from fastapi import Body, FastAPI, Request, Response +from fastapi.routing import APIRoute + + +class GzipRequest(Request): + async def body(self) -> bytes: + if not hasattr(self, "_body"): + body = await super().body() + if "gzip" in self.headers.getlist("Content-Encoding"): + body = gzip.decompress(body) + self._body = body + return self._body + + +class GzipRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + request = GzipRequest(request.scope, request.receive) + return await original_route_handler(request) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = GzipRoute + + +@app.post("/sum") +async def sum_numbers(numbers: list[int] = Body()): + return {"sum": sum(numbers)} diff --git a/docs_src/custom_request_and_route/tutorial002_an.py b/docs_src/custom_request_and_route/tutorial002_an.py new file mode 100644 index 000000000..127f7a9ce --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial002_an.py @@ -0,0 +1,30 @@ +from typing import Callable, List + +from fastapi import Body, FastAPI, HTTPException, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute +from typing_extensions import Annotated + + +class ValidationErrorLoggingRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + try: + return await original_route_handler(request) + except RequestValidationError as exc: + body = await request.body() + detail = {"errors": exc.errors(), "body": body.decode()} + raise HTTPException(status_code=422, detail=detail) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = ValidationErrorLoggingRoute + + +@app.post("/") +async def sum_numbers(numbers: Annotated[List[int], Body()]): + return sum(numbers) diff --git a/docs_src/custom_request_and_route/tutorial002_an_py310.py b/docs_src/custom_request_and_route/tutorial002_an_py310.py new file mode 100644 index 000000000..69b7de485 --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial002_an_py310.py @@ -0,0 +1,30 @@ +from collections.abc import Callable +from typing import Annotated + +from fastapi import Body, FastAPI, HTTPException, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute + + +class ValidationErrorLoggingRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + try: + return await original_route_handler(request) + except RequestValidationError as exc: + body = await request.body() + detail = {"errors": exc.errors(), "body": body.decode()} + raise HTTPException(status_code=422, detail=detail) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = ValidationErrorLoggingRoute + + +@app.post("/") +async def sum_numbers(numbers: Annotated[list[int], Body()]): + return sum(numbers) diff --git a/docs_src/custom_request_and_route/tutorial002_an_py39.py b/docs_src/custom_request_and_route/tutorial002_an_py39.py new file mode 100644 index 000000000..e7de09de4 --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial002_an_py39.py @@ -0,0 +1,29 @@ +from typing import Annotated, Callable + +from fastapi import Body, FastAPI, HTTPException, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute + + +class ValidationErrorLoggingRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + try: + return await original_route_handler(request) + except RequestValidationError as exc: + body = await request.body() + detail = {"errors": exc.errors(), "body": body.decode()} + raise HTTPException(status_code=422, detail=detail) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = ValidationErrorLoggingRoute + + +@app.post("/") +async def sum_numbers(numbers: Annotated[list[int], Body()]): + return sum(numbers) diff --git a/docs_src/custom_request_and_route/tutorial002_py310.py b/docs_src/custom_request_and_route/tutorial002_py310.py new file mode 100644 index 000000000..13a5ca542 --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial002_py310.py @@ -0,0 +1,29 @@ +from collections.abc import Callable + +from fastapi import Body, FastAPI, HTTPException, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute + + +class ValidationErrorLoggingRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + try: + return await original_route_handler(request) + except RequestValidationError as exc: + body = await request.body() + detail = {"errors": exc.errors(), "body": body.decode()} + raise HTTPException(status_code=422, detail=detail) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = ValidationErrorLoggingRoute + + +@app.post("/") +async def sum_numbers(numbers: list[int] = Body()): + return sum(numbers) diff --git a/docs_src/custom_request_and_route/tutorial002_py39.py b/docs_src/custom_request_and_route/tutorial002_py39.py new file mode 100644 index 000000000..c4e474828 --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial002_py39.py @@ -0,0 +1,29 @@ +from typing import Callable + +from fastapi import Body, FastAPI, HTTPException, Request, Response +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute + + +class ValidationErrorLoggingRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + try: + return await original_route_handler(request) + except RequestValidationError as exc: + body = await request.body() + detail = {"errors": exc.errors(), "body": body.decode()} + raise HTTPException(status_code=422, detail=detail) + + return custom_route_handler + + +app = FastAPI() +app.router.route_class = ValidationErrorLoggingRoute + + +@app.post("/") +async def sum_numbers(numbers: list[int] = Body()): + return sum(numbers) diff --git a/docs_src/custom_request_and_route/tutorial003_py310.py b/docs_src/custom_request_and_route/tutorial003_py310.py new file mode 100644 index 000000000..f4e60be61 --- /dev/null +++ b/docs_src/custom_request_and_route/tutorial003_py310.py @@ -0,0 +1,39 @@ +import time +from collections.abc import Callable + +from fastapi import APIRouter, FastAPI, Request, Response +from fastapi.routing import APIRoute + + +class TimedRoute(APIRoute): + def get_route_handler(self) -> Callable: + original_route_handler = super().get_route_handler() + + async def custom_route_handler(request: Request) -> Response: + before = time.time() + response: Response = await original_route_handler(request) + duration = time.time() - before + response.headers["X-Response-Time"] = str(duration) + print(f"route duration: {duration}") + print(f"route response: {response}") + print(f"route response headers: {response.headers}") + return response + + return custom_route_handler + + +app = FastAPI() +router = APIRouter(route_class=TimedRoute) + + +@app.get("/") +async def not_timed(): + return {"message": "Not timed"} + + +@router.get("/timed") +async def timed(): + return {"message": "It's the time of my life"} + + +app.include_router(router) diff --git a/docs_src/dataclasses/tutorial001_py310.py b/docs_src/dataclasses/tutorial001_py310.py new file mode 100644 index 000000000..ab709a7c8 --- /dev/null +++ b/docs_src/dataclasses/tutorial001_py310.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + +from fastapi import FastAPI + + +@dataclass +class Item: + name: str + price: float + description: str | None = None + tax: float | None = None + + +app = FastAPI() + + +@app.post("/items/") +async def create_item(item: Item): + return item diff --git a/docs_src/dataclasses/tutorial002_py310.py b/docs_src/dataclasses/tutorial002_py310.py new file mode 100644 index 000000000..e16249f1e --- /dev/null +++ b/docs_src/dataclasses/tutorial002_py310.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field + +from fastapi import FastAPI + + +@dataclass +class Item: + name: str + price: float + tags: list[str] = field(default_factory=list) + description: str | None = None + tax: float | None = None + + +app = FastAPI() + + +@app.get("/items/next", response_model=Item) +async def read_next_item(): + return { + "name": "Island In The Moon", + "price": 12.99, + "description": "A place to be playin' and havin' fun", + "tags": ["breater"], + } diff --git a/docs_src/dataclasses/tutorial002_py39.py b/docs_src/dataclasses/tutorial002_py39.py new file mode 100644 index 000000000..0c23765d8 --- /dev/null +++ b/docs_src/dataclasses/tutorial002_py39.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field +from typing import Union + +from fastapi import FastAPI + + +@dataclass +class Item: + name: str + price: float + tags: list[str] = field(default_factory=list) + description: Union[str, None] = None + tax: Union[float, None] = None + + +app = FastAPI() + + +@app.get("/items/next", response_model=Item) +async def read_next_item(): + return { + "name": "Island In The Moon", + "price": 12.99, + "description": "A place to be playin' and havin' fun", + "tags": ["breater"], + } diff --git a/docs_src/dataclasses/tutorial003_py310.py b/docs_src/dataclasses/tutorial003_py310.py new file mode 100644 index 000000000..9b9a3fd63 --- /dev/null +++ b/docs_src/dataclasses/tutorial003_py310.py @@ -0,0 +1,54 @@ +from dataclasses import field # (1) + +from fastapi import FastAPI +from pydantic.dataclasses import dataclass # (2) + + +@dataclass +class Item: + name: str + description: str | None = None + + +@dataclass +class Author: + name: str + items: list[Item] = field(default_factory=list) # (3) + + +app = FastAPI() + + +@app.post("/authors/{author_id}/items/", response_model=Author) # (4) +async def create_author_items(author_id: str, items: list[Item]): # (5) + return {"name": author_id, "items": items} # (6) + + +@app.get("/authors/", response_model=list[Author]) # (7) +def get_authors(): # (8) + return [ # (9) + { + "name": "Breaters", + "items": [ + { + "name": "Island In The Moon", + "description": "A place to be playin' and havin' fun", + }, + {"name": "Holy Buddies"}, + ], + }, + { + "name": "System of an Up", + "items": [ + { + "name": "Salt", + "description": "The kombucha mushroom people's favorite", + }, + {"name": "Pad Thai"}, + { + "name": "Lonely Night", + "description": "The mostests lonliest nightiest of allest", + }, + ], + }, + ] diff --git a/docs_src/dataclasses/tutorial003_py39.py b/docs_src/dataclasses/tutorial003_py39.py new file mode 100644 index 000000000..991708c00 --- /dev/null +++ b/docs_src/dataclasses/tutorial003_py39.py @@ -0,0 +1,55 @@ +from dataclasses import field # (1) +from typing import Union + +from fastapi import FastAPI +from pydantic.dataclasses import dataclass # (2) + + +@dataclass +class Item: + name: str + description: Union[str, None] = None + + +@dataclass +class Author: + name: str + items: list[Item] = field(default_factory=list) # (3) + + +app = FastAPI() + + +@app.post("/authors/{author_id}/items/", response_model=Author) # (4) +async def create_author_items(author_id: str, items: list[Item]): # (5) + return {"name": author_id, "items": items} # (6) + + +@app.get("/authors/", response_model=list[Author]) # (7) +def get_authors(): # (8) + return [ # (9) + { + "name": "Breaters", + "items": [ + { + "name": "Island In The Moon", + "description": "A place to be playin' and havin' fun", + }, + {"name": "Holy Buddies"}, + ], + }, + { + "name": "System of an Up", + "items": [ + { + "name": "Salt", + "description": "The kombucha mushroom people's favorite", + }, + {"name": "Pad Thai"}, + { + "name": "Lonely Night", + "description": "The mostests lonliest nightiest of allest", + }, + ], + }, + ] diff --git a/docs_src/handling_errors/tutorial004.py b/docs_src/handling_errors/tutorial004.py index 300a3834f..ae50807e9 100644 --- a/docs_src/handling_errors/tutorial004.py +++ b/docs_src/handling_errors/tutorial004.py @@ -12,8 +12,11 @@ async def http_exception_handler(request, exc): @app.exception_handler(RequestValidationError) -async def validation_exception_handler(request, exc): - return PlainTextResponse(str(exc), status_code=400) +async def validation_exception_handler(request, exc: RequestValidationError): + message = "Validation errors:" + for error in exc.errors(): + message += f"\nField: {error['loc']}, Error: {error['msg']}" + return PlainTextResponse(message, status_code=400) @app.get("/items/{item_id}") diff --git a/docs_src/openapi_callbacks/tutorial001_py310.py b/docs_src/openapi_callbacks/tutorial001_py310.py new file mode 100644 index 000000000..3efe0ee25 --- /dev/null +++ b/docs_src/openapi_callbacks/tutorial001_py310.py @@ -0,0 +1,51 @@ +from fastapi import APIRouter, FastAPI +from pydantic import BaseModel, HttpUrl + +app = FastAPI() + + +class Invoice(BaseModel): + id: str + title: str | None = None + customer: str + total: float + + +class InvoiceEvent(BaseModel): + description: str + paid: bool + + +class InvoiceEventReceived(BaseModel): + ok: bool + + +invoices_callback_router = APIRouter() + + +@invoices_callback_router.post( + "{$callback_url}/invoices/{$request.body.id}", response_model=InvoiceEventReceived +) +def invoice_notification(body: InvoiceEvent): + pass + + +@app.post("/invoices/", callbacks=invoices_callback_router.routes) +def create_invoice(invoice: Invoice, callback_url: HttpUrl | None = None): + """ + Create an invoice. + + This will (let's imagine) let the API user (some external developer) create an + invoice. + + And this path operation will: + + * Send the invoice to the client. + * Collect the money from the client. + * Send a notification back to the API user (the external developer), as a callback. + * At this point is that the API will somehow send a POST request to the + external API with the notification of the invoice event + (e.g. "payment successful"). + """ + # Send the invoice, collect the money, send the notification (the callback) + return {"msg": "Invoice received"} diff --git a/docs_src/path_operation_advanced_configuration/tutorial004_py310.py b/docs_src/path_operation_advanced_configuration/tutorial004_py310.py new file mode 100644 index 000000000..a815a564b --- /dev/null +++ b/docs_src/path_operation_advanced_configuration/tutorial004_py310.py @@ -0,0 +1,28 @@ +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + description: str | None = None + price: float + tax: float | None = None + tags: set[str] = set() + + +@app.post("/items/", response_model=Item, summary="Create an item") +async def create_item(item: Item): + """ + Create an item with all the information: + + - **name**: each item must have a name + - **description**: a long description + - **price**: required + - **tax**: if the item doesn't have tax, you can omit this + - **tags**: a set of unique tag strings for this item + \f + :param item: User input. + """ + return item diff --git a/docs_src/path_operation_advanced_configuration/tutorial004_py39.py b/docs_src/path_operation_advanced_configuration/tutorial004_py39.py new file mode 100644 index 000000000..d5fe6705c --- /dev/null +++ b/docs_src/path_operation_advanced_configuration/tutorial004_py39.py @@ -0,0 +1,30 @@ +from typing import Union + +from fastapi import FastAPI +from pydantic import BaseModel + +app = FastAPI() + + +class Item(BaseModel): + name: str + description: Union[str, None] = None + price: float + tax: Union[float, None] = None + tags: set[str] = set() + + +@app.post("/items/", response_model=Item, summary="Create an item") +async def create_item(item: Item): + """ + Create an item with all the information: + + - **name**: each item must have a name + - **description**: a long description + - **price**: required + - **tax**: if the item doesn't have tax, you can omit this + - **tags**: a set of unique tag strings for this item + \f + :param item: User input. + """ + return item diff --git a/docs_src/path_operation_advanced_configuration/tutorial007_pv1_py39.py b/docs_src/path_operation_advanced_configuration/tutorial007_pv1_py39.py new file mode 100644 index 000000000..831966553 --- /dev/null +++ b/docs_src/path_operation_advanced_configuration/tutorial007_pv1_py39.py @@ -0,0 +1,32 @@ +import yaml +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel, ValidationError + +app = FastAPI() + + +class Item(BaseModel): + name: str + tags: list[str] + + +@app.post( + "/items/", + openapi_extra={ + "requestBody": { + "content": {"application/x-yaml": {"schema": Item.schema()}}, + "required": True, + }, + }, +) +async def create_item(request: Request): + raw_body = await request.body() + try: + data = yaml.safe_load(raw_body) + except yaml.YAMLError: + raise HTTPException(status_code=422, detail="Invalid YAML") + try: + item = Item.parse_obj(data) + except ValidationError as e: + raise HTTPException(status_code=422, detail=e.errors()) + return item diff --git a/docs_src/path_operation_advanced_configuration/tutorial007_py39.py b/docs_src/path_operation_advanced_configuration/tutorial007_py39.py new file mode 100644 index 000000000..ff64ef792 --- /dev/null +++ b/docs_src/path_operation_advanced_configuration/tutorial007_py39.py @@ -0,0 +1,32 @@ +import yaml +from fastapi import FastAPI, HTTPException, Request +from pydantic import BaseModel, ValidationError + +app = FastAPI() + + +class Item(BaseModel): + name: str + tags: list[str] + + +@app.post( + "/items/", + openapi_extra={ + "requestBody": { + "content": {"application/x-yaml": {"schema": Item.model_json_schema()}}, + "required": True, + }, + }, +) +async def create_item(request: Request): + raw_body = await request.body() + try: + data = yaml.safe_load(raw_body) + except yaml.YAMLError: + raise HTTPException(status_code=422, detail="Invalid YAML") + try: + item = Item.model_validate(data) + except ValidationError as e: + raise HTTPException(status_code=422, detail=e.errors(include_url=False)) + return item diff --git a/docs_src/response_directly/tutorial001_py310.py b/docs_src/response_directly/tutorial001_py310.py new file mode 100644 index 000000000..81e094dc6 --- /dev/null +++ b/docs_src/response_directly/tutorial001_py310.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from pydantic import BaseModel + + +class Item(BaseModel): + title: str + timestamp: datetime + description: str | None = None + + +app = FastAPI() + + +@app.put("/items/{id}") +def update_item(id: str, item: Item): + json_compatible_item_data = jsonable_encoder(item) + return JSONResponse(content=json_compatible_item_data) diff --git a/docs_src/settings/app03/config.py b/docs_src/settings/app03/config.py index 942aea3e5..08f8f88c2 100644 --- a/docs_src/settings/app03/config.py +++ b/docs_src/settings/app03/config.py @@ -1,4 +1,4 @@ -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): @@ -6,5 +6,4 @@ class Settings(BaseSettings): admin_email: str items_per_user: int = 50 - class Config: - env_file = ".env" + model_config = SettingsConfigDict(env_file=".env") diff --git a/docs_src/settings/app03/config_pv1.py b/docs_src/settings/app03/config_pv1.py new file mode 100644 index 000000000..e1c3ee300 --- /dev/null +++ b/docs_src/settings/app03/config_pv1.py @@ -0,0 +1,10 @@ +from pydantic import BaseSettings + + +class Settings(BaseSettings): + app_name: str = "Awesome API" + admin_email: str + items_per_user: int = 50 + + class Config: + env_file = ".env" diff --git a/docs_src/settings/app03_an/main.py b/docs_src/settings/app03_an/main.py index 2f64b9cd1..62f347639 100644 --- a/docs_src/settings/app03_an/main.py +++ b/docs_src/settings/app03_an/main.py @@ -1,7 +1,7 @@ from functools import lru_cache -from typing import Annotated from fastapi import Depends, FastAPI +from typing_extensions import Annotated from . import config diff --git a/docs_src/settings/app03_an_py39/config.py b/docs_src/settings/app03_an_py39/config.py index 942aea3e5..08f8f88c2 100644 --- a/docs_src/settings/app03_an_py39/config.py +++ b/docs_src/settings/app03_an_py39/config.py @@ -1,4 +1,4 @@ -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): @@ -6,5 +6,4 @@ class Settings(BaseSettings): admin_email: str items_per_user: int = 50 - class Config: - env_file = ".env" + model_config = SettingsConfigDict(env_file=".env") diff --git a/docs_src/settings/app03_an_py39/config_pv1.py b/docs_src/settings/app03_an_py39/config_pv1.py new file mode 100644 index 000000000..e1c3ee300 --- /dev/null +++ b/docs_src/settings/app03_an_py39/config_pv1.py @@ -0,0 +1,10 @@ +from pydantic import BaseSettings + + +class Settings(BaseSettings): + app_name: str = "Awesome API" + admin_email: str + items_per_user: int = 50 + + class Config: + env_file = ".env" diff --git a/docs_src/settings/app03_an_py39/main.py b/docs_src/settings/app03_an_py39/main.py index 62f347639..2f64b9cd1 100644 --- a/docs_src/settings/app03_an_py39/main.py +++ b/docs_src/settings/app03_an_py39/main.py @@ -1,7 +1,7 @@ from functools import lru_cache +from typing import Annotated from fastapi import Depends, FastAPI -from typing_extensions import Annotated from . import config diff --git a/fastapi/__init__.py b/fastapi/__init__.py index a15326cc2..8de426ad4 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.123.3" +__version__ = "0.124.2" from starlette import status as status diff --git a/fastapi/_compat/v2.py b/fastapi/_compat/v2.py index fd689cd76..f0140883e 100644 --- a/fastapi/_compat/v2.py +++ b/fastapi/_compat/v2.py @@ -1,7 +1,7 @@ import re import warnings from copy import copy, deepcopy -from dataclasses import dataclass +from dataclasses import dataclass, is_dataclass from enum import Enum from typing import ( Any, @@ -17,8 +17,8 @@ from typing import ( from fastapi._compat import may_v1, shared from fastapi.openapi.constants import REF_TEMPLATE -from fastapi.types import IncEx, ModelNameMap -from pydantic import BaseModel, TypeAdapter, create_model +from fastapi.types import IncEx, ModelNameMap, UnionType +from pydantic import BaseModel, ConfigDict, TypeAdapter, create_model from pydantic import PydanticSchemaGenerationError as PydanticSchemaGenerationError from pydantic import PydanticUndefinedAnnotation as PydanticUndefinedAnnotation from pydantic import ValidationError as ValidationError @@ -64,6 +64,7 @@ class ModelField: field_info: FieldInfo name: str mode: Literal["validation", "serialization"] = "validation" + config: Union[ConfigDict, None] = None @property def alias(self) -> str: @@ -106,8 +107,14 @@ class ModelField: warnings.simplefilter( "ignore", category=UnsupportedFieldAttributeWarning ) + annotated_args = ( + self.field_info.annotation, + *self.field_info.metadata, + self.field_info, + ) self._type_adapter: TypeAdapter[Any] = TypeAdapter( - Annotated[self.field_info.annotation, self.field_info] + Annotated[annotated_args], + config=self.config, ) def get_default(self) -> Any: @@ -183,6 +190,13 @@ def _get_model_config(model: BaseModel) -> Any: return model.model_config +def _has_computed_fields(field: ModelField) -> bool: + computed_fields = field._type_adapter.core_schema.get("schema", {}).get( + "computed_fields", [] + ) + return len(computed_fields) > 0 + + def get_schema_from_model_field( *, field: ModelField, @@ -193,7 +207,9 @@ def get_schema_from_model_field( separate_input_output_schemas: bool = True, ) -> Dict[str, Any]: override_mode: Union[Literal["validation"], None] = ( - None if separate_input_output_schemas else "validation" + None + if (separate_input_output_schemas or _has_computed_fields(field)) + else "validation" ) field_alias = ( (field.validation_alias or field.alias) @@ -222,9 +238,6 @@ def get_definitions( Dict[str, Dict[str, Any]], ]: schema_generator = GenerateJsonSchema(ref_template=REF_TEMPLATE) - override_mode: Union[Literal["validation"], None] = ( - None if separate_input_output_schemas else "validation" - ) validation_fields = [field for field in fields if field.mode == "validation"] serialization_fields = [field for field in fields if field.mode == "serialization"] flat_validation_models = get_flat_models_from_fields( @@ -254,9 +267,16 @@ def get_definitions( unique_flat_model_fields = { f for f in flat_model_fields if f.type_ not in input_types } - inputs = [ - (field, override_mode or field.mode, field._type_adapter.core_schema) + ( + field, + ( + field.mode + if (separate_input_output_schemas or _has_computed_fields(field)) + else "validation" + ), + field._type_adapter.core_schema, + ) for field in list(fields) + list(unique_flat_model_fields) ] field_mapping, definitions = schema_generator.generate_definitions(inputs=inputs) @@ -389,7 +409,7 @@ def copy_field_info(*, field_info: FieldInfo, annotation: Any) -> FieldInfo: def serialize_sequence_value(*, field: ModelField, value: Any) -> Sequence[Any]: origin_type = get_origin(field.field_info.annotation) or field.field_info.annotation - if origin_type is Union: # Handle optional sequences + if origin_type is Union or origin_type is UnionType: # Handle optional sequences union_args = get_args(field.field_info.annotation) for union_arg in union_args: if union_arg is type(None): @@ -417,10 +437,21 @@ def create_body_model( def get_model_fields(model: Type[BaseModel]) -> List[ModelField]: - return [ - ModelField(field_info=field_info, name=name) - for name, field_info in model.model_fields.items() - ] + model_fields: List[ModelField] = [] + for name, field_info in model.model_fields.items(): + type_ = field_info.annotation + if lenient_issubclass(type_, (BaseModel, dict)) or is_dataclass(type_): + model_config = None + else: + model_config = model.model_config + model_fields.append( + ModelField( + field_info=field_info, + name=name, + config=model_config, + ) + ) + return model_fields # Duplicate of several schema functions from Pydantic v1 to make them compatible with diff --git a/fastapi/applications.py b/fastapi/applications.py index 0a47699ae..02193312b 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -301,7 +301,12 @@ class FastAPI(Starlette): browser tabs open). Or if you want to leave fixed the possible URLs. If the servers `list` is not provided, or is an empty `list`, the - default value would be a `dict` with a `url` value of `/`. + `servers` property in the generated OpenAPI will be: + + * a `dict` with a `url` value of the application's mounting point + (`root_path`) if it's different from `/`. + * otherwise, the `servers` property will be omitted from the OpenAPI + schema. Each item in the `list` is a `dict` containing: diff --git a/fastapi/dependencies/models.py b/fastapi/dependencies/models.py index fbb666a7d..6c4bf18b3 100644 --- a/fastapi/dependencies/models.py +++ b/fastapi/dependencies/models.py @@ -1,8 +1,8 @@ import inspect import sys from dataclasses import dataclass, field -from functools import cached_property -from typing import Any, Callable, List, Optional, Sequence, Union +from functools import cached_property, partial +from typing import Any, Callable, List, Optional, Union from fastapi._compat import ModelField from fastapi.security.base import SecurityBase @@ -15,10 +15,17 @@ else: # pragma: no cover from asyncio import iscoroutinefunction -@dataclass -class SecurityRequirement: - security_scheme: SecurityBase - scopes: Optional[Sequence[str]] = None +def _unwrapped_call(call: Optional[Callable[..., Any]]) -> Any: + if call is None: + return call # pragma: no cover + unwrapped = inspect.unwrap(_impartial(call)) + return unwrapped + + +def _impartial(func: Callable[..., Any]) -> Callable[..., Any]: + while isinstance(func, partial): + func = func.func + return func @dataclass @@ -29,7 +36,6 @@ class Dependant: cookie_params: List[ModelField] = field(default_factory=list) body_params: List[ModelField] = field(default_factory=list) dependencies: List["Dependant"] = field(default_factory=list) - security_requirements: List[SecurityRequirement] = field(default_factory=list) name: Optional[str] = None call: Optional[Callable[..., Any]] = None request_param_name: Optional[str] = None @@ -70,33 +76,113 @@ class Dependant: return True if self.security_scopes_param_name is not None: return True + if self._is_security_scheme: + return True for sub_dep in self.dependencies: if sub_dep._uses_scopes: return True return False + @cached_property + def _is_security_scheme(self) -> bool: + if self.call is None: + return False # pragma: no cover + unwrapped = _unwrapped_call(self.call) + return isinstance(unwrapped, SecurityBase) + + # Mainly to get the type of SecurityBase, but it's the same self.call + @cached_property + def _security_scheme(self) -> SecurityBase: + unwrapped = _unwrapped_call(self.call) + assert isinstance(unwrapped, SecurityBase) + return unwrapped + + @cached_property + def _security_dependencies(self) -> List["Dependant"]: + security_deps = [dep for dep in self.dependencies if dep._is_security_scheme] + return security_deps + @cached_property def is_gen_callable(self) -> bool: - if inspect.isgeneratorfunction(self.call): + if self.call is None: + return False # pragma: no cover + if inspect.isgeneratorfunction( + _impartial(self.call) + ) or inspect.isgeneratorfunction(_unwrapped_call(self.call)): return True - dunder_call = getattr(self.call, "__call__", None) # noqa: B004 - return inspect.isgeneratorfunction(dunder_call) + if inspect.isclass(_unwrapped_call(self.call)): + return False + dunder_call = getattr(_impartial(self.call), "__call__", None) # noqa: B004 + if dunder_call is None: + return False # pragma: no cover + if inspect.isgeneratorfunction( + _impartial(dunder_call) + ) or inspect.isgeneratorfunction(_unwrapped_call(dunder_call)): + return True + dunder_unwrapped_call = getattr(_unwrapped_call(self.call), "__call__", None) # noqa: B004 + if dunder_unwrapped_call is None: + return False # pragma: no cover + if inspect.isgeneratorfunction( + _impartial(dunder_unwrapped_call) + ) or inspect.isgeneratorfunction(_unwrapped_call(dunder_unwrapped_call)): + return True + return False @cached_property def is_async_gen_callable(self) -> bool: - if inspect.isasyncgenfunction(self.call): + if self.call is None: + return False # pragma: no cover + if inspect.isasyncgenfunction( + _impartial(self.call) + ) or inspect.isasyncgenfunction(_unwrapped_call(self.call)): return True - dunder_call = getattr(self.call, "__call__", None) # noqa: B004 - return inspect.isasyncgenfunction(dunder_call) + if inspect.isclass(_unwrapped_call(self.call)): + return False + dunder_call = getattr(_impartial(self.call), "__call__", None) # noqa: B004 + if dunder_call is None: + return False # pragma: no cover + if inspect.isasyncgenfunction( + _impartial(dunder_call) + ) or inspect.isasyncgenfunction(_unwrapped_call(dunder_call)): + return True + dunder_unwrapped_call = getattr(_unwrapped_call(self.call), "__call__", None) # noqa: B004 + if dunder_unwrapped_call is None: + return False # pragma: no cover + if inspect.isasyncgenfunction( + _impartial(dunder_unwrapped_call) + ) or inspect.isasyncgenfunction(_unwrapped_call(dunder_unwrapped_call)): + return True + return False @cached_property def is_coroutine_callable(self) -> bool: - if inspect.isroutine(self.call): - return iscoroutinefunction(self.call) - if inspect.isclass(self.call): + if self.call is None: + return False # pragma: no cover + if inspect.isroutine(_impartial(self.call)) and iscoroutinefunction( + _impartial(self.call) + ): + return True + if inspect.isroutine(_unwrapped_call(self.call)) and iscoroutinefunction( + _unwrapped_call(self.call) + ): + return True + if inspect.isclass(_unwrapped_call(self.call)): return False - dunder_call = getattr(self.call, "__call__", None) # noqa: B004 - return iscoroutinefunction(dunder_call) + dunder_call = getattr(_impartial(self.call), "__call__", None) # noqa: B004 + if dunder_call is None: + return False # pragma: no cover + if iscoroutinefunction(_impartial(dunder_call)) or iscoroutinefunction( + _unwrapped_call(dunder_call) + ): + return True + dunder_unwrapped_call = getattr(_unwrapped_call(self.call), "__call__", None) # noqa: B004 + if dunder_unwrapped_call is None: + return False # pragma: no cover + if iscoroutinefunction( + _impartial(dunder_unwrapped_call) + ) or iscoroutinefunction(_unwrapped_call(dunder_unwrapped_call)): + return True + return False @cached_property def computed_scope(self) -> Union[str, None]: diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 9b15a927e..cc7e55b4b 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -1,5 +1,6 @@ import dataclasses import inspect +import sys from contextlib import AsyncExitStack, contextmanager from copy import copy, deepcopy from dataclasses import dataclass @@ -54,10 +55,9 @@ from fastapi.concurrency import ( asynccontextmanager, contextmanager_in_threadpool, ) -from fastapi.dependencies.models import Dependant, SecurityRequirement +from fastapi.dependencies.models import Dependant from fastapi.exceptions import DependencyScopeError from fastapi.logger import logger -from fastapi.security.base import SecurityBase from fastapi.security.oauth2 import SecurityScopes from fastapi.types import DependencyCacheKey from fastapi.utils import create_model_field, get_path_param_names @@ -141,10 +141,14 @@ def get_flat_dependant( *, skip_repeats: bool = False, visited: Optional[List[DependencyCacheKey]] = None, + parent_oauth_scopes: Optional[List[str]] = None, ) -> Dependant: if visited is None: visited = [] visited.append(dependant.cache_key) + use_parent_oauth_scopes = (parent_oauth_scopes or []) + ( + dependant.oauth_scopes or [] + ) flat_dependant = Dependant( path_params=dependant.path_params.copy(), @@ -152,22 +156,37 @@ def get_flat_dependant( header_params=dependant.header_params.copy(), cookie_params=dependant.cookie_params.copy(), body_params=dependant.body_params.copy(), - security_requirements=dependant.security_requirements.copy(), + name=dependant.name, + call=dependant.call, + request_param_name=dependant.request_param_name, + websocket_param_name=dependant.websocket_param_name, + http_connection_param_name=dependant.http_connection_param_name, + response_param_name=dependant.response_param_name, + background_tasks_param_name=dependant.background_tasks_param_name, + security_scopes_param_name=dependant.security_scopes_param_name, + own_oauth_scopes=dependant.own_oauth_scopes, + parent_oauth_scopes=use_parent_oauth_scopes, use_cache=dependant.use_cache, path=dependant.path, + scope=dependant.scope, ) for sub_dependant in dependant.dependencies: if skip_repeats and sub_dependant.cache_key in visited: continue flat_sub = get_flat_dependant( - sub_dependant, skip_repeats=skip_repeats, visited=visited + sub_dependant, + skip_repeats=skip_repeats, + visited=visited, + parent_oauth_scopes=flat_dependant.oauth_scopes, ) + flat_dependant.dependencies.append(flat_sub) flat_dependant.path_params.extend(flat_sub.path_params) flat_dependant.query_params.extend(flat_sub.query_params) flat_dependant.header_params.extend(flat_sub.header_params) flat_dependant.cookie_params.extend(flat_sub.cookie_params) flat_dependant.body_params.extend(flat_sub.body_params) - flat_dependant.security_requirements.extend(flat_sub.security_requirements) + flat_dependant.dependencies.extend(flat_sub.dependencies) + return flat_dependant @@ -190,9 +209,23 @@ def get_flat_params(dependant: Dependant) -> List[ModelField]: return path_params + query_params + header_params + cookie_params +def _get_signature(call: Callable[..., Any]) -> inspect.Signature: + if sys.version_info >= (3, 10): + try: + signature = inspect.signature(call, eval_str=True) + except NameError: + # Handle type annotations with if TYPE_CHECKING, not used by FastAPI + # e.g. dependency return types + signature = inspect.signature(call) + else: + signature = inspect.signature(call) + return signature + + def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: - signature = inspect.signature(call) - globalns = getattr(call, "__globals__", {}) + signature = _get_signature(call) + unwrapped = inspect.unwrap(call) + globalns = getattr(unwrapped, "__globals__", {}) typed_params = [ inspect.Parameter( name=param.name, @@ -216,13 +249,14 @@ def get_typed_annotation(annotation: Any, globalns: Dict[str, Any]) -> Any: def get_typed_return_annotation(call: Callable[..., Any]) -> Any: - signature = inspect.signature(call) + signature = _get_signature(call) + unwrapped = inspect.unwrap(call) annotation = signature.return_annotation if annotation is inspect.Signature.empty: return None - globalns = getattr(call, "__globals__", {}) + globalns = getattr(unwrapped, "__globals__", {}) return get_typed_annotation(annotation, globalns) @@ -249,11 +283,6 @@ def get_dependant( path_param_names = get_path_param_names(path) endpoint_signature = get_typed_signature(call) signature_params = endpoint_signature.parameters - if isinstance(call, SecurityBase): - security_requirement = SecurityRequirement( - security_scheme=call, scopes=current_scopes - ) - dependant.security_requirements.append(security_requirement) for param_name, param in signature_params.items(): is_path_param = param_name in path_param_names param_details = analyze_param( @@ -546,10 +575,10 @@ async def _solve_generator( *, dependant: Dependant, stack: AsyncExitStack, sub_values: Dict[str, Any] ) -> Any: assert dependant.call - if dependant.is_gen_callable: - cm = contextmanager_in_threadpool(contextmanager(dependant.call)(**sub_values)) - elif dependant.is_async_gen_callable: + if dependant.is_async_gen_callable: cm = asynccontextmanager(dependant.call)(**sub_values) + elif dependant.is_gen_callable: + cm = contextmanager_in_threadpool(contextmanager(dependant.call)(**sub_values)) return await stack.enter_async_context(cm) diff --git a/fastapi/exceptions.py b/fastapi/exceptions.py index 0620428be..a46e82350 100644 --- a/fastapi/exceptions.py +++ b/fastapi/exceptions.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Optional, Sequence, Type, Union +from typing import Any, Dict, Optional, Sequence, Type, TypedDict, Union from annotated_doc import Doc from pydantic import BaseModel, create_model @@ -7,6 +7,13 @@ from starlette.exceptions import WebSocketException as StarletteWebSocketExcepti from typing_extensions import Annotated +class EndpointContext(TypedDict, total=False): + function: str + path: str + file: str + line: int + + class HTTPException(StarletteHTTPException): """ An HTTP exception you can raise in your own code to show errors to the client. @@ -155,30 +162,72 @@ class DependencyScopeError(FastAPIError): class ValidationException(Exception): - def __init__(self, errors: Sequence[Any]) -> None: + def __init__( + self, + errors: Sequence[Any], + *, + endpoint_ctx: Optional[EndpointContext] = None, + ) -> None: self._errors = errors + self.endpoint_ctx = endpoint_ctx + + ctx = endpoint_ctx or {} + self.endpoint_function = ctx.get("function") + self.endpoint_path = ctx.get("path") + self.endpoint_file = ctx.get("file") + self.endpoint_line = ctx.get("line") def errors(self) -> Sequence[Any]: return self._errors + def _format_endpoint_context(self) -> str: + if not (self.endpoint_file and self.endpoint_line and self.endpoint_function): + if self.endpoint_path: + return f"\n Endpoint: {self.endpoint_path}" + return "" + + context = f'\n File "{self.endpoint_file}", line {self.endpoint_line}, in {self.endpoint_function}' + if self.endpoint_path: + context += f"\n {self.endpoint_path}" + return context + + def __str__(self) -> str: + message = f"{len(self._errors)} validation error{'s' if len(self._errors) != 1 else ''}:\n" + for err in self._errors: + message += f" {err}\n" + message += self._format_endpoint_context() + return message.rstrip() + class RequestValidationError(ValidationException): - def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None: - super().__init__(errors) + def __init__( + self, + errors: Sequence[Any], + *, + body: Any = None, + endpoint_ctx: Optional[EndpointContext] = None, + ) -> None: + super().__init__(errors, endpoint_ctx=endpoint_ctx) self.body = body class WebSocketRequestValidationError(ValidationException): - pass + def __init__( + self, + errors: Sequence[Any], + *, + endpoint_ctx: Optional[EndpointContext] = None, + ) -> None: + super().__init__(errors, endpoint_ctx=endpoint_ctx) class ResponseValidationError(ValidationException): - def __init__(self, errors: Sequence[Any], *, body: Any = None) -> None: - super().__init__(errors) + def __init__( + self, + errors: Sequence[Any], + *, + body: Any = None, + endpoint_ctx: Optional[EndpointContext] = None, + ) -> None: + super().__init__(errors, endpoint_ctx=endpoint_ctx) self.body = body - - def __str__(self) -> str: - message = f"{len(self._errors)} validation errors:\n" - for err in self._errors: - message += f" {err}\n" - return message diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py index b401af97d..9fe2044f2 100644 --- a/fastapi/openapi/utils.py +++ b/fastapi/openapi/utils.py @@ -80,16 +80,25 @@ def get_openapi_security_definitions( flat_dependant: Dependant, ) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: security_definitions = {} - operation_security = [] - for security_requirement in flat_dependant.security_requirements: + # Use a dict to merge scopes for same security scheme + operation_security_dict: Dict[str, List[str]] = {} + for security_dependency in flat_dependant._security_dependencies: security_definition = jsonable_encoder( - security_requirement.security_scheme.model, + security_dependency._security_scheme.model, by_alias=True, exclude_none=True, ) - security_name = security_requirement.security_scheme.scheme_name + security_name = security_dependency._security_scheme.scheme_name security_definitions[security_name] = security_definition - operation_security.append({security_name: security_requirement.scopes}) + # Merge scopes for the same security scheme + if security_name not in operation_security_dict: + operation_security_dict[security_name] = [] + for scope in security_dependency.oauth_scopes or []: + if scope not in operation_security_dict[security_name]: + operation_security_dict[security_name].append(scope) + operation_security = [ + {name: scopes} for name, scopes in operation_security_dict.items() + ] return security_definitions, operation_security diff --git a/fastapi/routing.py b/fastapi/routing.py index a8e12eb60..9be2b44bc 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -3,7 +3,6 @@ import email.message import functools import inspect import json -import sys from contextlib import AsyncExitStack, asynccontextmanager from enum import Enum, IntEnum from typing import ( @@ -47,6 +46,7 @@ from fastapi.dependencies.utils import ( ) from fastapi.encoders import jsonable_encoder from fastapi.exceptions import ( + EndpointContext, FastAPIError, RequestValidationError, ResponseValidationError, @@ -79,11 +79,6 @@ from starlette.types import AppType, ASGIApp, Lifespan, Receive, Scope, Send from starlette.websockets import WebSocket from typing_extensions import Annotated, deprecated -if sys.version_info >= (3, 13): # pragma: no cover - from inspect import iscoroutinefunction -else: # pragma: no cover - from asyncio import iscoroutinefunction - # Copy of starlette.routing.request_response modified to include the # dependencies' AsyncExitStack @@ -218,6 +213,33 @@ def _merge_lifespan_context( return merged_lifespan # type: ignore[return-value] +# Cache for endpoint context to avoid re-extracting on every request +_endpoint_context_cache: Dict[int, EndpointContext] = {} + + +def _extract_endpoint_context(func: Any) -> EndpointContext: + """Extract endpoint context with caching to avoid repeated file I/O.""" + func_id = id(func) + + if func_id in _endpoint_context_cache: + return _endpoint_context_cache[func_id] + + try: + ctx: EndpointContext = {} + + if (source_file := inspect.getsourcefile(func)) is not None: + ctx["file"] = source_file + if (line_number := inspect.getsourcelines(func)[1]) is not None: + ctx["line"] = line_number + if (func_name := getattr(func, "__name__", None)) is not None: + ctx["function"] = func_name + except Exception: + ctx = EndpointContext() + + _endpoint_context_cache[func_id] = ctx + return ctx + + async def serialize_response( *, field: Optional[ModelField] = None, @@ -229,6 +251,7 @@ async def serialize_response( exclude_defaults: bool = False, exclude_none: bool = False, is_coroutine: bool = True, + endpoint_ctx: Optional[EndpointContext] = None, ) -> Any: if field: errors = [] @@ -251,8 +274,11 @@ async def serialize_response( elif errors_: errors.append(errors_) if errors: + ctx = endpoint_ctx or EndpointContext() raise ResponseValidationError( - errors=_normalize_errors(errors), body=response_content + errors=_normalize_errors(errors), + body=response_content, + endpoint_ctx=ctx, ) if hasattr(field, "serialize"): @@ -308,7 +334,7 @@ def get_request_handler( embed_body_fields: bool = False, ) -> Callable[[Request], Coroutine[Any, Any, Response]]: assert dependant.call is not None, "dependant.call must be a function" - is_coroutine = iscoroutinefunction(dependant.call) + is_coroutine = dependant.is_coroutine_callable is_body_form = body_field and isinstance( body_field.field_info, (params.Form, temp_pydantic_v1_params.Form) ) @@ -324,6 +350,18 @@ def get_request_handler( "fastapi_middleware_astack not found in request scope" ) + # Extract endpoint context for error messages + endpoint_ctx = ( + _extract_endpoint_context(dependant.call) + if dependant.call + else EndpointContext() + ) + + if dependant.path: + # For mounted sub-apps, include the mount path prefix + mount_path = request.scope.get("root_path", "").rstrip("/") + endpoint_ctx["path"] = f"{request.method} {mount_path}{dependant.path}" + # Read body and auto-close files try: body: Any = None @@ -361,6 +399,7 @@ def get_request_handler( } ], body=e.doc, + endpoint_ctx=endpoint_ctx, ) raise validation_error from e except HTTPException: @@ -420,6 +459,7 @@ def get_request_handler( exclude_defaults=response_model_exclude_defaults, exclude_none=response_model_exclude_none, is_coroutine=is_coroutine, + endpoint_ctx=endpoint_ctx, ) response = actual_response_class(content, **response_args) if not is_body_allowed_for_status_code(response.status_code): @@ -427,7 +467,7 @@ def get_request_handler( response.headers.raw.extend(solved_result.response.headers.raw) if errors: validation_error = RequestValidationError( - _normalize_errors(errors), body=body + _normalize_errors(errors), body=body, endpoint_ctx=endpoint_ctx ) raise validation_error @@ -444,6 +484,15 @@ def get_websocket_app( embed_body_fields: bool = False, ) -> Callable[[WebSocket], Coroutine[Any, Any, Any]]: async def app(websocket: WebSocket) -> None: + endpoint_ctx = ( + _extract_endpoint_context(dependant.call) + if dependant.call + else EndpointContext() + ) + if dependant.path: + # For mounted sub-apps, include the mount path prefix + mount_path = websocket.scope.get("root_path", "").rstrip("/") + endpoint_ctx["path"] = f"WS {mount_path}{dependant.path}" async_exit_stack = websocket.scope.get("fastapi_inner_astack") assert isinstance(async_exit_stack, AsyncExitStack), ( "fastapi_inner_astack not found in request scope" @@ -457,7 +506,8 @@ def get_websocket_app( ) if solved_result.errors: raise WebSocketRequestValidationError( - _normalize_errors(solved_result.errors) + _normalize_errors(solved_result.errors), + endpoint_ctx=endpoint_ctx, ) assert dependant.call is not None, "dependant.call must be a function" await dependant.call(**solved_result.values) diff --git a/pyproject.toml b/pyproject.toml index cafcf65c6..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 = [ @@ -236,8 +237,15 @@ ignore = [ "docs_src/custom_response/tutorial007.py" = ["B007"] "docs_src/dataclasses/tutorial003.py" = ["I001"] "docs_src/path_operation_advanced_configuration/tutorial007.py" = ["B904"] +"docs_src/path_operation_advanced_configuration/tutorial007_py39.py" = ["B904"] "docs_src/path_operation_advanced_configuration/tutorial007_pv1.py" = ["B904"] +"docs_src/path_operation_advanced_configuration/tutorial007_pv1_py39.py" = ["B904"] "docs_src/custom_request_and_route/tutorial002.py" = ["B904"] +"docs_src/custom_request_and_route/tutorial002_py39.py" = ["B904"] +"docs_src/custom_request_and_route/tutorial002_py310.py" = ["B904"] +"docs_src/custom_request_and_route/tutorial002_an.py" = ["B904"] +"docs_src/custom_request_and_route/tutorial002_an_py39.py" = ["B904"] +"docs_src/custom_request_and_route/tutorial002_an_py310.py" = ["B904"] "docs_src/dependencies/tutorial008_an.py" = ["F821"] "docs_src/dependencies/tutorial008_an_py39.py" = ["F821"] "docs_src/query_params_str_validations/tutorial012_an.py" = ["B006"] diff --git a/scripts/mkdocs_hooks.py b/scripts/mkdocs_hooks.py index b9e4ff59e..09cfa99e3 100644 --- a/scripts/mkdocs_hooks.py +++ b/scripts/mkdocs_hooks.py @@ -132,7 +132,7 @@ def on_pre_page(page: Page, *, config: MkDocsConfig, files: Files) -> Page: def on_page_markdown( markdown: str, *, page: Page, config: MkDocsConfig, files: Files ) -> str: - # Set matadata["social"]["cards_layout_options"]["title"] to clean title (without + # Set metadata["social"]["cards_layout_options"]["title"] to clean title (without # permalink) title = page.title clean_title = title.split("{ #")[0] diff --git a/tests/forward_reference_type.py b/tests/forward_reference_type.py new file mode 100644 index 000000000..52a0d4a70 --- /dev/null +++ b/tests/forward_reference_type.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +def forwardref_method(input: "ForwardRefModel") -> "ForwardRefModel": + return ForwardRefModel(x=input.x + 1) + + +class ForwardRefModel(BaseModel): + x: int = 0 diff --git a/tests/test_arbitrary_types.py b/tests/test_arbitrary_types.py new file mode 100644 index 000000000..e5fa95ef2 --- /dev/null +++ b/tests/test_arbitrary_types.py @@ -0,0 +1,141 @@ +from typing import List + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from inline_snapshot import snapshot +from typing_extensions import Annotated + +from .utils import needs_pydanticv2 + + +@pytest.fixture(name="client") +def get_client(): + from pydantic import ( + BaseModel, + ConfigDict, + PlainSerializer, + TypeAdapter, + WithJsonSchema, + ) + + class FakeNumpyArray: + def __init__(self): + self.data = [1.0, 2.0, 3.0] + + FakeNumpyArrayPydantic = Annotated[ + FakeNumpyArray, + WithJsonSchema(TypeAdapter(List[float]).json_schema()), + PlainSerializer(lambda v: v.data), + ] + + class MyModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + custom_field: FakeNumpyArrayPydantic + + app = FastAPI() + + @app.get("/") + def test() -> MyModel: + return MyModel(custom_field=FakeNumpyArray()) + + client = TestClient(app) + return client + + +@needs_pydanticv2 +def test_get(client: TestClient): + response = client.get("/") + assert response.json() == {"custom_field": [1.0, 2.0, 3.0]} + + +@needs_pydanticv2 +def test_typeadapter(): + # This test is only to confirm that Pydantic alone is working as expected + from pydantic import ( + BaseModel, + ConfigDict, + PlainSerializer, + TypeAdapter, + WithJsonSchema, + ) + + class FakeNumpyArray: + def __init__(self): + self.data = [1.0, 2.0, 3.0] + + FakeNumpyArrayPydantic = Annotated[ + FakeNumpyArray, + WithJsonSchema(TypeAdapter(List[float]).json_schema()), + PlainSerializer(lambda v: v.data), + ] + + class MyModel(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + custom_field: FakeNumpyArrayPydantic + + ta = TypeAdapter(MyModel) + assert ta.dump_python(MyModel(custom_field=FakeNumpyArray())) == { + "custom_field": [1.0, 2.0, 3.0] + } + assert ta.json_schema() == snapshot( + { + "properties": { + "custom_field": { + "items": {"type": "number"}, + "title": "Custom Field", + "type": "array", + } + }, + "required": ["custom_field"], + "title": "MyModel", + "type": "object", + } + ) + + +@needs_pydanticv2 +def test_openapi_schema(client: TestClient): + response = client.get("openapi.json") + assert response.json() == snapshot( + { + "openapi": "3.1.0", + "info": {"title": "FastAPI", "version": "0.1.0"}, + "paths": { + "/": { + "get": { + "summary": "Test", + "operationId": "test__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MyModel" + } + } + }, + } + }, + } + } + }, + "components": { + "schemas": { + "MyModel": { + "properties": { + "custom_field": { + "items": {"type": "number"}, + "type": "array", + "title": "Custom Field", + } + }, + "type": "object", + "required": ["custom_field"], + "title": "MyModel", + } + } + }, + } + ) diff --git a/tests/test_compat.py b/tests/test_compat.py index c3a97209a..26537c5ab 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -14,7 +14,7 @@ from fastapi.testclient import TestClient from pydantic import BaseModel, ConfigDict from pydantic.fields import FieldInfo -from .utils import needs_py_lt_314, needs_pydanticv2 +from .utils import needs_py310, needs_py_lt_314, needs_pydanticv2 @needs_pydanticv2 @@ -148,6 +148,19 @@ def test_serialize_sequence_value_with_optional_list(): assert isinstance(result, list) +@needs_pydanticv2 +@needs_py310 +def test_serialize_sequence_value_with_optional_list_pipe_union(): + """Test that serialize_sequence_value handles optional lists correctly (with new syntax).""" + from fastapi._compat import v2 + + field_info = FieldInfo(annotation=list[str] | None) + field = v2.ModelField(name="items", field_info=field_info) + result = v2.serialize_sequence_value(field=field, value=["a", "b", "c"]) + assert result == ["a", "b", "c"] + assert isinstance(result, list) + + @needs_pydanticv2 def test_serialize_sequence_value_with_none_first_in_union(): """Test that serialize_sequence_value handles Union[None, List[...]] correctly.""" diff --git a/tests/test_computed_fields.py b/tests/test_computed_fields.py index a1b412168..f2e42999b 100644 --- a/tests/test_computed_fields.py +++ b/tests/test_computed_fields.py @@ -6,8 +6,9 @@ from .utils import needs_pydanticv2 @pytest.fixture(name="client") -def get_client(): - app = FastAPI() +def get_client(request): + separate_input_output_schemas = request.param + app = FastAPI(separate_input_output_schemas=separate_input_output_schemas) from pydantic import BaseModel, computed_field @@ -32,6 +33,7 @@ def get_client(): return client +@pytest.mark.parametrize("client", [True, False], indirect=True) @pytest.mark.parametrize("path", ["/", "/responses"]) @needs_pydanticv2 def test_get(client: TestClient, path: str): @@ -40,6 +42,7 @@ def test_get(client: TestClient, path: str): assert response.json() == {"width": 3, "length": 4, "area": 12} +@pytest.mark.parametrize("client", [True, False], indirect=True) @needs_pydanticv2 def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") diff --git a/tests/test_dependency_class.py b/tests/test_dependency_class.py index 0233492e6..75241b467 100644 --- a/tests/test_dependency_class.py +++ b/tests/test_dependency_class.py @@ -48,6 +48,34 @@ async_callable_gen_dependency = AsyncCallableGenDependency() methods_dependency = MethodsDependency() +@app.get("/callable-dependency-class") +async def get_callable_dependency_class( + value: str, instance: CallableDependency = Depends() +): + return instance(value) + + +@app.get("/callable-gen-dependency-class") +async def get_callable_gen_dependency_class( + value: str, instance: CallableGenDependency = Depends() +): + return next(instance(value)) + + +@app.get("/async-callable-dependency-class") +async def get_async_callable_dependency_class( + value: str, instance: AsyncCallableDependency = Depends() +): + return await instance(value) + + +@app.get("/async-callable-gen-dependency-class") +async def get_async_callable_gen_dependency_class( + value: str, instance: AsyncCallableGenDependency = Depends() +): + return await instance(value).__anext__() + + @app.get("/callable-dependency") async def get_callable_dependency(value: str = Depends(callable_dependency)): return value @@ -114,6 +142,10 @@ client = TestClient(app) ("/synchronous-method-gen-dependency", "synchronous-method-gen-dependency"), ("/asynchronous-method-dependency", "asynchronous-method-dependency"), ("/asynchronous-method-gen-dependency", "asynchronous-method-gen-dependency"), + ("/callable-dependency-class", "callable-dependency-class"), + ("/callable-gen-dependency-class", "callable-gen-dependency-class"), + ("/async-callable-dependency-class", "async-callable-dependency-class"), + ("/async-callable-gen-dependency-class", "async-callable-gen-dependency-class"), ], ) def test_class_dependency(route, value): diff --git a/tests/test_dependency_partial.py b/tests/test_dependency_partial.py new file mode 100644 index 000000000..61a76236f --- /dev/null +++ b/tests/test_dependency_partial.py @@ -0,0 +1,251 @@ +from functools import partial +from typing import AsyncGenerator, Generator + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from typing_extensions import Annotated + +app = FastAPI() + + +def function_dependency(value: str) -> str: + return value + + +async def async_function_dependency(value: str) -> str: + return value + + +def gen_dependency(value: str) -> Generator[str, None, None]: + yield value + + +async def async_gen_dependency(value: str) -> AsyncGenerator[str, None]: + yield value + + +class CallableDependency: + def __call__(self, value: str) -> str: + return value + + +class CallableGenDependency: + def __call__(self, value: str) -> Generator[str, None, None]: + yield value + + +class AsyncCallableDependency: + async def __call__(self, value: str) -> str: + return value + + +class AsyncCallableGenDependency: + async def __call__(self, value: str) -> AsyncGenerator[str, None]: + yield value + + +class MethodsDependency: + def synchronous(self, value: str) -> str: + return value + + async def asynchronous(self, value: str) -> str: + return value + + def synchronous_gen(self, value: str) -> Generator[str, None, None]: + yield value + + async def asynchronous_gen(self, value: str) -> AsyncGenerator[str, None]: + yield value + + +callable_dependency = CallableDependency() +callable_gen_dependency = CallableGenDependency() +async_callable_dependency = AsyncCallableDependency() +async_callable_gen_dependency = AsyncCallableGenDependency() +methods_dependency = MethodsDependency() + + +@app.get("/partial-function-dependency") +async def get_partial_function_dependency( + value: Annotated[ + str, Depends(partial(function_dependency, "partial-function-dependency")) + ], +) -> str: + return value + + +@app.get("/partial-async-function-dependency") +async def get_partial_async_function_dependency( + value: Annotated[ + str, + Depends( + partial(async_function_dependency, "partial-async-function-dependency") + ), + ], +) -> str: + return value + + +@app.get("/partial-gen-dependency") +async def get_partial_gen_dependency( + value: Annotated[str, Depends(partial(gen_dependency, "partial-gen-dependency"))], +) -> str: + return value + + +@app.get("/partial-async-gen-dependency") +async def get_partial_async_gen_dependency( + value: Annotated[ + str, Depends(partial(async_gen_dependency, "partial-async-gen-dependency")) + ], +) -> str: + return value + + +@app.get("/partial-callable-dependency") +async def get_partial_callable_dependency( + value: Annotated[ + str, Depends(partial(callable_dependency, "partial-callable-dependency")) + ], +) -> str: + return value + + +@app.get("/partial-callable-gen-dependency") +async def get_partial_callable_gen_dependency( + value: Annotated[ + str, + Depends(partial(callable_gen_dependency, "partial-callable-gen-dependency")), + ], +) -> str: + return value + + +@app.get("/partial-async-callable-dependency") +async def get_partial_async_callable_dependency( + value: Annotated[ + str, + Depends( + partial(async_callable_dependency, "partial-async-callable-dependency") + ), + ], +) -> str: + return value + + +@app.get("/partial-async-callable-gen-dependency") +async def get_partial_async_callable_gen_dependency( + value: Annotated[ + str, + Depends( + partial( + async_callable_gen_dependency, "partial-async-callable-gen-dependency" + ) + ), + ], +) -> str: + return value + + +@app.get("/partial-synchronous-method-dependency") +async def get_partial_synchronous_method_dependency( + value: Annotated[ + str, + Depends( + partial( + methods_dependency.synchronous, "partial-synchronous-method-dependency" + ) + ), + ], +) -> str: + return value + + +@app.get("/partial-synchronous-method-gen-dependency") +async def get_partial_synchronous_method_gen_dependency( + value: Annotated[ + str, + Depends( + partial( + methods_dependency.synchronous_gen, + "partial-synchronous-method-gen-dependency", + ) + ), + ], +) -> str: + return value + + +@app.get("/partial-asynchronous-method-dependency") +async def get_partial_asynchronous_method_dependency( + value: Annotated[ + str, + Depends( + partial( + methods_dependency.asynchronous, + "partial-asynchronous-method-dependency", + ) + ), + ], +) -> str: + return value + + +@app.get("/partial-asynchronous-method-gen-dependency") +async def get_partial_asynchronous_method_gen_dependency( + value: Annotated[ + str, + Depends( + partial( + methods_dependency.asynchronous_gen, + "partial-asynchronous-method-gen-dependency", + ) + ), + ], +) -> str: + return value + + +client = TestClient(app) + + +@pytest.mark.parametrize( + "route,value", + [ + ("/partial-function-dependency", "partial-function-dependency"), + ( + "/partial-async-function-dependency", + "partial-async-function-dependency", + ), + ("/partial-gen-dependency", "partial-gen-dependency"), + ("/partial-async-gen-dependency", "partial-async-gen-dependency"), + ("/partial-callable-dependency", "partial-callable-dependency"), + ("/partial-callable-gen-dependency", "partial-callable-gen-dependency"), + ("/partial-async-callable-dependency", "partial-async-callable-dependency"), + ( + "/partial-async-callable-gen-dependency", + "partial-async-callable-gen-dependency", + ), + ( + "/partial-synchronous-method-dependency", + "partial-synchronous-method-dependency", + ), + ( + "/partial-synchronous-method-gen-dependency", + "partial-synchronous-method-gen-dependency", + ), + ( + "/partial-asynchronous-method-dependency", + "partial-asynchronous-method-dependency", + ), + ( + "/partial-asynchronous-method-gen-dependency", + "partial-asynchronous-method-gen-dependency", + ), + ], +) +def test_dependency_types_with_partial(route: str, value: str) -> None: + response = client.get(route) + assert response.status_code == 200, response.text + assert response.json() == value diff --git a/tests/test_dependency_wrapped.py b/tests/test_dependency_wrapped.py new file mode 100644 index 000000000..08356712d --- /dev/null +++ b/tests/test_dependency_wrapped.py @@ -0,0 +1,449 @@ +import inspect +import sys +from functools import wraps +from typing import AsyncGenerator, Generator + +import pytest +from fastapi import Depends, FastAPI +from fastapi.concurrency import iterate_in_threadpool, run_in_threadpool +from fastapi.testclient import TestClient + +if sys.version_info >= (3, 13): # pragma: no cover + from inspect import iscoroutinefunction +else: # pragma: no cover + from asyncio import iscoroutinefunction + + +def noop_wrap(func): + @wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +def noop_wrap_async(func): + if inspect.isgeneratorfunction(func): + + @wraps(func) + async def gen_wrapper(*args, **kwargs): + async for item in iterate_in_threadpool(func(*args, **kwargs)): + yield item + + return gen_wrapper + + elif inspect.isasyncgenfunction(func): + + @wraps(func) + async def async_gen_wrapper(*args, **kwargs): + async for item in func(*args, **kwargs): + yield item + + return async_gen_wrapper + + @wraps(func) + async def wrapper(*args, **kwargs): + if inspect.isroutine(func) and iscoroutinefunction(func): + return await func(*args, **kwargs) + if inspect.isclass(func): + return await run_in_threadpool(func, *args, **kwargs) + dunder_call = getattr(func, "__call__", None) # noqa: B004 + if iscoroutinefunction(dunder_call): + return await dunder_call(*args, **kwargs) + return await run_in_threadpool(func, *args, **kwargs) + + return wrapper + + +class ClassInstanceDep: + def __call__(self): + return True + + +class_instance_dep = ClassInstanceDep() +wrapped_class_instance_dep = noop_wrap(class_instance_dep) +wrapped_class_instance_dep_async_wrapper = noop_wrap_async(class_instance_dep) + + +class ClassInstanceGenDep: + def __call__(self): + yield True + + +class_instance_gen_dep = ClassInstanceGenDep() +wrapped_class_instance_gen_dep = noop_wrap(class_instance_gen_dep) + + +class ClassInstanceWrappedDep: + @noop_wrap + def __call__(self): + return True + + +class_instance_wrapped_dep = ClassInstanceWrappedDep() + + +class ClassInstanceWrappedAsyncDep: + @noop_wrap_async + def __call__(self): + return True + + +class_instance_wrapped_async_dep = ClassInstanceWrappedAsyncDep() + + +class ClassInstanceWrappedGenDep: + @noop_wrap + def __call__(self): + yield True + + +class_instance_wrapped_gen_dep = ClassInstanceWrappedGenDep() + + +class ClassInstanceWrappedAsyncGenDep: + @noop_wrap_async + def __call__(self): + yield True + + +class_instance_wrapped_async_gen_dep = ClassInstanceWrappedAsyncGenDep() + + +class ClassDep: + def __init__(self): + self.value = True + + +wrapped_class_dep = noop_wrap(ClassDep) +wrapped_class_dep_async_wrapper = noop_wrap_async(ClassDep) + + +class ClassInstanceAsyncDep: + async def __call__(self): + return True + + +class_instance_async_dep = ClassInstanceAsyncDep() +wrapped_class_instance_async_dep = noop_wrap(class_instance_async_dep) +wrapped_class_instance_async_dep_async_wrapper = noop_wrap_async( + class_instance_async_dep +) + + +class ClassInstanceAsyncGenDep: + async def __call__(self): + yield True + + +class_instance_async_gen_dep = ClassInstanceAsyncGenDep() +wrapped_class_instance_async_gen_dep = noop_wrap(class_instance_async_gen_dep) + + +class ClassInstanceAsyncWrappedDep: + @noop_wrap + async def __call__(self): + return True + + +class_instance_async_wrapped_dep = ClassInstanceAsyncWrappedDep() + + +class ClassInstanceAsyncWrappedAsyncDep: + @noop_wrap_async + async def __call__(self): + return True + + +class_instance_async_wrapped_async_dep = ClassInstanceAsyncWrappedAsyncDep() + + +class ClassInstanceAsyncWrappedGenDep: + @noop_wrap + async def __call__(self): + yield True + + +class_instance_async_wrapped_gen_dep = ClassInstanceAsyncWrappedGenDep() + + +class ClassInstanceAsyncWrappedGenAsyncDep: + @noop_wrap_async + async def __call__(self): + yield True + + +class_instance_async_wrapped_gen_async_dep = ClassInstanceAsyncWrappedGenAsyncDep() + +app = FastAPI() + +# Sync wrapper + + +@noop_wrap +def wrapped_dependency() -> bool: + return True + + +@noop_wrap +def wrapped_gen_dependency() -> Generator[bool, None, None]: + yield True + + +@noop_wrap +async def async_wrapped_dependency() -> bool: + return True + + +@noop_wrap +async def async_wrapped_gen_dependency() -> AsyncGenerator[bool, None]: + yield True + + +@app.get("/wrapped-dependency/") +async def get_wrapped_dependency(value: bool = Depends(wrapped_dependency)): + return value + + +@app.get("/wrapped-gen-dependency/") +async def get_wrapped_gen_dependency(value: bool = Depends(wrapped_gen_dependency)): + return value + + +@app.get("/async-wrapped-dependency/") +async def get_async_wrapped_dependency(value: bool = Depends(async_wrapped_dependency)): + return value + + +@app.get("/async-wrapped-gen-dependency/") +async def get_async_wrapped_gen_dependency( + value: bool = Depends(async_wrapped_gen_dependency), +): + return value + + +@app.get("/wrapped-class-instance-dependency/") +async def get_wrapped_class_instance_dependency( + value: bool = Depends(wrapped_class_instance_dep), +): + return value + + +@app.get("/wrapped-class-instance-async-dependency/") +async def get_wrapped_class_instance_async_dependency( + value: bool = Depends(wrapped_class_instance_async_dep), +): + return value + + +@app.get("/wrapped-class-instance-gen-dependency/") +async def get_wrapped_class_instance_gen_dependency( + value: bool = Depends(wrapped_class_instance_gen_dep), +): + return value + + +@app.get("/wrapped-class-instance-async-gen-dependency/") +async def get_wrapped_class_instance_async_gen_dependency( + value: bool = Depends(wrapped_class_instance_async_gen_dep), +): + return value + + +@app.get("/class-instance-wrapped-dependency/") +async def get_class_instance_wrapped_dependency( + value: bool = Depends(class_instance_wrapped_dep), +): + return value + + +@app.get("/class-instance-wrapped-async-dependency/") +async def get_class_instance_wrapped_async_dependency( + value: bool = Depends(class_instance_wrapped_async_dep), +): + return value + + +@app.get("/class-instance-async-wrapped-dependency/") +async def get_class_instance_async_wrapped_dependency( + value: bool = Depends(class_instance_async_wrapped_dep), +): + return value + + +@app.get("/class-instance-async-wrapped-async-dependency/") +async def get_class_instance_async_wrapped_async_dependency( + value: bool = Depends(class_instance_async_wrapped_async_dep), +): + return value + + +@app.get("/class-instance-wrapped-gen-dependency/") +async def get_class_instance_wrapped_gen_dependency( + value: bool = Depends(class_instance_wrapped_gen_dep), +): + return value + + +@app.get("/class-instance-wrapped-async-gen-dependency/") +async def get_class_instance_wrapped_async_gen_dependency( + value: bool = Depends(class_instance_wrapped_async_gen_dep), +): + return value + + +@app.get("/class-instance-async-wrapped-gen-dependency/") +async def get_class_instance_async_wrapped_gen_dependency( + value: bool = Depends(class_instance_async_wrapped_gen_dep), +): + return value + + +@app.get("/class-instance-async-wrapped-gen-async-dependency/") +async def get_class_instance_async_wrapped_gen_async_dependency( + value: bool = Depends(class_instance_async_wrapped_gen_async_dep), +): + return value + + +@app.get("/wrapped-class-dependency/") +async def get_wrapped_class_dependency(value: ClassDep = Depends(wrapped_class_dep)): + return value.value + + +@app.get("/wrapped-endpoint/") +@noop_wrap +def get_wrapped_endpoint(): + return True + + +@app.get("/async-wrapped-endpoint/") +@noop_wrap +async def get_async_wrapped_endpoint(): + return True + + +# Async wrapper + + +@noop_wrap_async +def wrapped_dependency_async_wrapper() -> bool: + return True + + +@noop_wrap_async +def wrapped_gen_dependency_async_wrapper() -> Generator[bool, None, None]: + yield True + + +@noop_wrap_async +async def async_wrapped_dependency_async_wrapper() -> bool: + return True + + +@noop_wrap_async +async def async_wrapped_gen_dependency_async_wrapper() -> AsyncGenerator[bool, None]: + yield True + + +@app.get("/wrapped-dependency-async-wrapper/") +async def get_wrapped_dependency_async_wrapper( + value: bool = Depends(wrapped_dependency_async_wrapper), +): + return value + + +@app.get("/wrapped-gen-dependency-async-wrapper/") +async def get_wrapped_gen_dependency_async_wrapper( + value: bool = Depends(wrapped_gen_dependency_async_wrapper), +): + return value + + +@app.get("/async-wrapped-dependency-async-wrapper/") +async def get_async_wrapped_dependency_async_wrapper( + value: bool = Depends(async_wrapped_dependency_async_wrapper), +): + return value + + +@app.get("/async-wrapped-gen-dependency-async-wrapper/") +async def get_async_wrapped_gen_dependency_async_wrapper( + value: bool = Depends(async_wrapped_gen_dependency_async_wrapper), +): + return value + + +@app.get("/wrapped-class-instance-dependency-async-wrapper/") +async def get_wrapped_class_instance_dependency_async_wrapper( + value: bool = Depends(wrapped_class_instance_dep_async_wrapper), +): + return value + + +@app.get("/wrapped-class-instance-async-dependency-async-wrapper/") +async def get_wrapped_class_instance_async_dependency_async_wrapper( + value: bool = Depends(wrapped_class_instance_async_dep_async_wrapper), +): + return value + + +@app.get("/wrapped-class-dependency-async-wrapper/") +async def get_wrapped_class_dependency_async_wrapper( + value: ClassDep = Depends(wrapped_class_dep_async_wrapper), +): + return value.value + + +@app.get("/wrapped-endpoint-async-wrapper/") +@noop_wrap_async +def get_wrapped_endpoint_async_wrapper(): + return True + + +@app.get("/async-wrapped-endpoint-async-wrapper/") +@noop_wrap_async +async def get_async_wrapped_endpoint_async_wrapper(): + return True + + +client = TestClient(app) + + +@pytest.mark.parametrize( + "route", + [ + "/wrapped-dependency/", + "/wrapped-gen-dependency/", + "/async-wrapped-dependency/", + "/async-wrapped-gen-dependency/", + "/wrapped-class-instance-dependency/", + "/wrapped-class-instance-async-dependency/", + "/wrapped-class-instance-gen-dependency/", + "/wrapped-class-instance-async-gen-dependency/", + "/class-instance-wrapped-dependency/", + "/class-instance-wrapped-async-dependency/", + "/class-instance-async-wrapped-dependency/", + "/class-instance-async-wrapped-async-dependency/", + "/class-instance-wrapped-gen-dependency/", + "/class-instance-wrapped-async-gen-dependency/", + "/class-instance-async-wrapped-gen-dependency/", + "/class-instance-async-wrapped-gen-async-dependency/", + "/wrapped-class-dependency/", + "/wrapped-endpoint/", + "/async-wrapped-endpoint/", + "/wrapped-dependency-async-wrapper/", + "/wrapped-gen-dependency-async-wrapper/", + "/async-wrapped-dependency-async-wrapper/", + "/async-wrapped-gen-dependency-async-wrapper/", + "/wrapped-class-instance-dependency-async-wrapper/", + "/wrapped-class-instance-async-dependency-async-wrapper/", + "/wrapped-class-dependency-async-wrapper/", + "/wrapped-endpoint-async-wrapper/", + "/async-wrapped-endpoint-async-wrapper/", + ], +) +def test_class_dependency(route): + response = client.get(route) + assert response.status_code == 200, response.text + assert response.json() is True diff --git a/tests/test_openapi_separate_input_output_schemas.py b/tests/test_openapi_separate_input_output_schemas.py index fa73620ea..c9a05418b 100644 --- a/tests/test_openapi_separate_input_output_schemas.py +++ b/tests/test_openapi_separate_input_output_schemas.py @@ -24,6 +24,18 @@ class Item(BaseModel): model_config = {"json_schema_serialization_defaults_required": True} +if PYDANTIC_V2: + from pydantic import computed_field + + class WithComputedField(BaseModel): + name: str + + @computed_field + @property + def computed_field(self) -> str: + return f"computed {self.name}" + + def get_app_client(separate_input_output_schemas: bool = True) -> TestClient: app = FastAPI(separate_input_output_schemas=separate_input_output_schemas) @@ -46,6 +58,14 @@ def get_app_client(separate_input_output_schemas: bool = True) -> TestClient: Item(name="Plumbus"), ] + if PYDANTIC_V2: + + @app.post("/with-computed-field/") + def create_with_computed_field( + with_computed_field: WithComputedField, + ) -> WithComputedField: + return with_computed_field + client = TestClient(app) return client @@ -131,6 +151,23 @@ def test_read_items(): ) +@needs_pydanticv2 +def test_with_computed_field(): + client = get_app_client() + client_no = get_app_client(separate_input_output_schemas=False) + response = client.post("/with-computed-field/", json={"name": "example"}) + response2 = client_no.post("/with-computed-field/", json={"name": "example"}) + assert response.status_code == response2.status_code == 200, response.text + assert ( + response.json() + == response2.json() + == { + "name": "example", + "computed_field": "computed example", + } + ) + + @needs_pydanticv2 def test_openapi_schema(): client = get_app_client() @@ -245,6 +282,44 @@ def test_openapi_schema(): }, } }, + "/with-computed-field/": { + "post": { + "summary": "Create With Computed Field", + "operationId": "create_with_computed_field_with_computed_field__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WithComputedField-Input" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WithComputedField-Output" + } + } + }, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + }, + }, }, "components": { "schemas": { @@ -333,6 +408,25 @@ def test_openapi_schema(): "required": ["subname", "sub_description", "tags"], "title": "SubItem", }, + "WithComputedField-Input": { + "properties": {"name": {"type": "string", "title": "Name"}}, + "type": "object", + "required": ["name"], + "title": "WithComputedField", + }, + "WithComputedField-Output": { + "properties": { + "name": {"type": "string", "title": "Name"}, + "computed_field": { + "type": "string", + "title": "Computed Field", + "readOnly": True, + }, + }, + "type": "object", + "required": ["name", "computed_field"], + "title": "WithComputedField", + }, "ValidationError": { "properties": { "loc": { @@ -458,6 +552,44 @@ def test_openapi_schema_no_separate(): }, } }, + "/with-computed-field/": { + "post": { + "summary": "Create With Computed Field", + "operationId": "create_with_computed_field_with_computed_field__post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WithComputedField-Input" + } + } + }, + "required": True, + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WithComputedField-Output" + } + } + }, + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + }, + }, + }, + }, }, "components": { "schemas": { @@ -508,6 +640,25 @@ def test_openapi_schema_no_separate(): "required": ["subname"], "title": "SubItem", }, + "WithComputedField-Input": { + "properties": {"name": {"type": "string", "title": "Name"}}, + "type": "object", + "required": ["name"], + "title": "WithComputedField", + }, + "WithComputedField-Output": { + "properties": { + "name": {"type": "string", "title": "Name"}, + "computed_field": { + "type": "string", + "title": "Computed Field", + "readOnly": True, + }, + }, + "type": "object", + "required": ["name", "computed_field"], + "title": "WithComputedField", + }, "ValidationError": { "properties": { "loc": { diff --git a/tests/test_pydanticv2_dataclasses_uuid_stringified_annotations.py b/tests/test_pydanticv2_dataclasses_uuid_stringified_annotations.py new file mode 100644 index 000000000..c9f94563b --- /dev/null +++ b/tests/test_pydanticv2_dataclasses_uuid_stringified_annotations.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from typing import List, Union + +from dirty_equals import IsUUID +from fastapi import FastAPI +from fastapi.testclient import TestClient +from inline_snapshot import snapshot + + +@dataclass +class Item: + id: uuid.UUID + name: str + price: float + tags: List[str] = field(default_factory=list) + description: Union[str, None] = None + tax: Union[float, None] = None + + +app = FastAPI() + + +@app.get("/item", response_model=Item) +async def read_item(): + return { + "id": uuid.uuid4(), + "name": "Island In The Moon", + "price": 12.99, + "description": "A place to be be playin' and havin' fun", + "tags": ["breater"], + } + + +client = TestClient(app) + + +def test_annotations(): + response = client.get("/item") + assert response.status_code == 200, response.text + assert response.json() == snapshot( + { + "id": IsUUID(), + "name": "Island In The Moon", + "price": 12.99, + "tags": ["breater"], + "description": "A place to be be playin' and havin' fun", + "tax": None, + } + ) 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..884e1d08a --- /dev/null +++ b/tests/test_request_params/test_body/test_list.py @@ -0,0 +1,523 @@ +from typing import List, Union + +import pytest +from dirty_equals import IsDict, IsOneOf, IsPartialDict +from fastapi import Body, FastAPI +from fastapi._compat import PYDANTIC_V2 +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} # pragma: no cover + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-list-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 models", + strict=False, + ), + ), + "/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} # pragma: no cover + + +@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", + [ + pytest.param( + "/required-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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( # /required-validation-alias fails here + ["body"], ["body", "p_val_alias"] + ), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-list-validation-alias fails here + ) + + 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", + [ + pytest.param( + "/required-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-list-validation-alias fails here + ) + 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} # pragma: no cover + + +@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", + [ + pytest.param( + "/required-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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( # /required-list-alias-and-validation-alias fails here + ["body"], ["body", "p_val_alias"] + ), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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": [ # /required-list-alias-and-validation-alias fails here + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {"p": ["hello", "world"]}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", + [ + pytest.param( + "/required-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-list-alias-and-validation-alias fails here + ) + 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..c86398ce9 --- /dev/null +++ b/tests/test_request_params/test_body/test_optional_list.py @@ -0,0 +1,600 @@ +from typing import List, Optional + +import pytest +from dirty_equals import IsDict +from fastapi import Body, FastAPI +from fastapi._compat import PYDANTIC_V2 +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", + [ + pytest.param( + "/optional-list-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + ), + ), + "/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", + [ + pytest.param( + "/optional-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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} # /optional-list-validation-alias fails here + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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() == { # /optional-list-validation-alias fails here + "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", + [ + pytest.param( + "/optional-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /optional-list-alias-and-validation-alias fails here + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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": [ # /optional-list-alias-and-validation-alias fails here + "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..43ed367dd --- /dev/null +++ b/tests/test_request_params/test_body/test_optional_str.py @@ -0,0 +1,569 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import Body, FastAPI +from fastapi._compat import PYDANTIC_V2 +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", + [ + pytest.param( + "/optional-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + ), + ), + "/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", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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} # /optional-validation-alias fails here + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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"} # /optional-validation-alias fails here + + +# ===================================================================================== +# 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", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /optional-alias-and-validation-alias fails here + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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" # /optional-alias-and-validation-alias fails here + } 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..fba3fe1f6 --- /dev/null +++ b/tests/test_request_params/test_body/test_required_str.py @@ -0,0 +1,514 @@ +from typing import Any, Dict, Union + +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import Body, FastAPI +from fastapi._compat import PYDANTIC_V2 +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", + [ + pytest.param( + "/required-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + strict=False, + ), + ), + "/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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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( # /required-validation-alias fails here + ["body", "p_val_alias"], ["body"] + ), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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( # /required-alias-and-validation-alias fails here + ["body"], ["body", "p_val_alias"] + ), + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-alias-and-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-alias-and-validation-alias fails here + ) + + 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", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-alias-and-validation-alias fails here + ) + + 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..7298baacd --- /dev/null +++ b/tests/test_request_params/test_cookie/test_optional_str.py @@ -0,0 +1,383 @@ +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", + pytest.param( + "/model-optional-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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"} # /model-optional-alias fails here + + +# ===================================================================================== +# 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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"} # /optional-validation-alias fails here + + +# ===================================================================================== +# 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /optional-alias-and-validation-alias fails here + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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" # /optional-alias-and-validation-alias fails here + } 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..9c1442ccb --- /dev/null +++ b/tests/test_request_params/test_cookie/test_required_str.py @@ -0,0 +1,503 @@ +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import Cookie, FastAPI +from fastapi._compat import PYDANTIC_V2 +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} # pragma: no cover + + +@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", + pytest.param( + "/model-required-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 models", + strict=False, + ), + ), + ], +) +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"}, # /model-required-alias PDv2 fails here + ), + } + ] + } + ) | 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", + pytest.param( + "/model-required-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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, ( # /model-required-alias fails here + 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-alias-and-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", # /required-alias-and-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf( # /model-alias-and-validation-alias fails here + None, + {"p": "hello"}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # /required-alias-and-validation-alias fails here + ) + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["cookie", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( # /model-alias-and-validation-alias fails here + None, + {"p_alias": "hello"}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-alias-and-validation-alias fails here + ) + + 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..8722ce5ab --- /dev/null +++ b/tests/test_request_params/test_file/test_list.py @@ -0,0 +1,597 @@ +from typing import List + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, UploadFile +from fastapi._compat import PYDANTIC_V2 +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.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + strict=False, +) +@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", + [ + pytest.param( + "/list-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/list-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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": [ # /list-*-validation-alias fail here + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/list-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/list-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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, ( # /list-*-validation-alias fail here + response.text + ) + + assert response.json() == { # pragma: no cover + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": None, + } + ] + } + + +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # all 2 fail here + assert response.json() == {"file_size": [5, 5]} # pragma: no cover + + +# ===================================================================================== +# 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", + [ + pytest.param( + "/list-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/list-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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", # /list-*-alias-and-validation-alias fail here + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", # /list-*-alias-and-validation-alias fail here + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/list-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/list-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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 # /list-*-alias-and-validation-alias fails here + ) + + assert response.json() == { # pragma: no cover + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": None, + } + ] + } + + +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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, ( # all 2 fail here + response.text + ) + assert response.json() == {"file_size": [5, 5]} # pragma: no cover 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..14fc0a220 --- /dev/null +++ b/tests/test_request_params/test_file/test_optional.py @@ -0,0 +1,443 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, UploadFile +from fastapi._compat import PYDANTIC_V2 +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.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + strict=False, +) +@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", + [ + pytest.param( + "/optional-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/optional-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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() == { # /optional-*-validation-alias fail here + "file_size": None + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/optional-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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} # /optional-*-validation-alias fail here + + +# ===================================================================================== +# 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", + [ + pytest.param( + "/optional-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/optional-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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", + [ + pytest.param( + "/optional-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/optional-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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 + } # /optional-*-alias-and-validation-alias fail here 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..f266642a6 --- /dev/null +++ b/tests/test_request_params/test_file/test_optional_list.py @@ -0,0 +1,487 @@ +from typing import List, Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, UploadFile +from fastapi._compat import PYDANTIC_V2 +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", + [ + pytest.param( + "/optional-list-bytes", + marks=pytest.mark.xfail( + raises=(TypeError, AssertionError), + condition=PYDANTIC_V2, + reason="Fails only with PDv2 due to #14297", + strict=False, + ), + ), + "/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.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + strict=False, +) +@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", + [ + pytest.param( + "/optional-list-bytes-alias", + marks=pytest.mark.xfail( + raises=(TypeError, AssertionError), + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 model due to #14297", + ), + ), + "/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", + [ + pytest.param( + "/optional-list-bytes-validation-alias", + marks=pytest.mark.xfail( + raises=(TypeError, AssertionError), + strict=False, + reason="Fails due to #14297", + ), + ), + pytest.param( + "/optional-list-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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() == { # /optional-list-uploadfile-validation-alias fails here + "file_size": None + } + + +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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] # /optional-list-*-validation-alias fail here + } + + +# ===================================================================================== +# 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", + [ + pytest.param( + "/optional-list-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail( + raises=(TypeError, AssertionError), + strict=False, + reason="Fails due to #14297", + ), + ), + pytest.param( + "/optional-list-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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 ( # /optional-list-uploadfile-alias-and-validation-alias fails here + response.json() == {"file_size": None} + ) + + +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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] # /optional-list-*-alias-and-validation-alias fail here + } 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..e50597370 --- /dev/null +++ b/tests/test_request_params/test_file/test_required.py @@ -0,0 +1,536 @@ +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, File, UploadFile +from fastapi._compat import PYDANTIC_V2 +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.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + strict=False, +) +@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", + [ + pytest.param( + "/required-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/required-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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": [ # /required-*-validation-alias fail here + "body", + "p_val_alias", + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/required-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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, ( # /required-*-validation-alias fail here + response.text + ) + + assert response.json() == { # pragma: no cover + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/required-uploadfile-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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, ( # all 2 fail here + response.text + ) + assert response.json() == {"file_size": 5} # pragma: no cover + + +# ===================================================================================== +# 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", + [ + pytest.param( + "/required-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/required-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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", # /required-*-alias-and-validation-alias fail here + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/required-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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", # /required-*-alias-and-validation-alias fail here + ], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/required-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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 # /required-*-alias-and-validation-alias fails here + ) + + assert response.json() == { # pragma: no cover + "detail": [ + { + "type": "missing", + "loc": ["body", "p_val_alias"], + "msg": "Field required", + "input": None, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-bytes-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + pytest.param( + "/required-uploadfile-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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, ( # all 2 fail here + response.text + ) + assert response.json() == {"file_size": 5} # pragma: no cover 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..c57180f6a --- /dev/null +++ b/tests/test_request_params/test_form/test_list.py @@ -0,0 +1,527 @@ +from typing import List + +import pytest +from dirty_equals import IsDict, IsOneOf, IsPartialDict +from fastapi import FastAPI, Form +from fastapi._compat import PYDANTIC_V2 +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} # pragma: no cover + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-list-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 models", + strict=False, + ), + ), + "/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", + pytest.param( + "/model-required-list-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 models", + strict=False, + ), + ), + ], +) +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( # /model-required-list-alias with PDv2 fails here + 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} # pragma: no cover + + +@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", + [ + pytest.param( + "/required-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-list-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-list-validation-alias fails here + ) + + 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.xfail(raises=AssertionError, strict=False) +@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 # both fail here + + assert response.json() == {"p": ["hello", "world"]} # pragma: no cover + + +# ===================================================================================== +# 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} # pragma: no cover + + +@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", + [ + pytest.param( + "/required-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", + # /required-list-alias-and-validation-alias fails here + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", + # /required-list-alias-and-validation-alias fails here + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf( + None, + # /model-required-list-alias-and-validation-alias fails here + {"p": ["hello", "world"]}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 ( # /required-list-alias-and-validation-alias fails here + 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.xfail(raises=AssertionError, strict=False) +@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 # both fail here + assert response.json() == {"p": ["hello", "world"]} # pragma: no cover 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..288a0cfe4 --- /dev/null +++ b/tests/test_request_params/test_form/test_optional_list.py @@ -0,0 +1,454 @@ +from typing import List, Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, Form +from fastapi._compat import PYDANTIC_V2 +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", + [ + pytest.param( + "/optional-list-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + ), + ), + "/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", + [ + pytest.param( + "/optional-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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} # /optional-list-validation-alias fails here + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # /model-optional-list-validation-alias fails here + ) + assert response.json() == { # /optional-list-validation-alias fails here + "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", + [ + pytest.param( + "/optional-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /optional-list-alias-and-validation-alias fails here + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # /model-optional-list-alias-and-validation-alias fails here + ) + assert response.json() == { + "p": [ # /optional-list-alias-and-validation-alias fails here + "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..66c003a95 --- /dev/null +++ b/tests/test_request_params/test_form/test_optional_str.py @@ -0,0 +1,419 @@ +from typing import Optional + +import pytest +from dirty_equals import IsDict +from fastapi import FastAPI, Form +from fastapi._compat import PYDANTIC_V2 +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", + [ + pytest.param( + "/optional-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + strict=False, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + ), + ), + "/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", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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} # /optional-validation-alias fails here + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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"} # /optional-validation-alias fails here + + +# ===================================================================================== +# 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", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /optional-alias-and-validation-alias fails here + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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" # /optional-alias-and-validation-alias fails here + } 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..fcbce015d --- /dev/null +++ b/tests/test_request_params/test_form/test_required_str.py @@ -0,0 +1,502 @@ +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import FastAPI, Form +from fastapi._compat import PYDANTIC_V2 +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", + [ + pytest.param( + "/required-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2", + strict=False, + ), + ), + "/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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-alias-and-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-alias-and-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {"p": "hello"}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-alias-and-validation-alias fails here + ) + + 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", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-alias-and-validation-alias fails here + ) + + 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..1bd3628b8 --- /dev/null +++ b/tests/test_request_params/test_header/test_list.py @@ -0,0 +1,505 @@ +from typing import List + +import pytest +from dirty_equals import AnyThing, IsDict, IsOneOf, IsPartialDict +from fastapi import FastAPI, Header +from fastapi._compat import PYDANTIC_V2 +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} # pragma: no cover + + +@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", + pytest.param( + "/model-required-list-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 models", + strict=False, + ), + ), + ], +) +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( # /model-required-list-alias with PDv2 fails here + 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", + pytest.param( + "/model-required-list-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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, ( # /model-required-list-alias fails here + 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} # pragma: no cover + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-list-validation-alias fails here + ], + "msg": "Field required", + "input": AnyThing, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-list-validation-alias fails here + + 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.xfail(raises=AssertionError, strict=False) +@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 # both fail here + + assert response.json() == {"p": ["hello", "world"]} # pragma: no cover + + +# ===================================================================================== +# 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} # pragma: no cover + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", + # /required-list-alias-and-validation-alias fails here + "p_val_alias", + ], + "msg": "Field required", + "input": AnyThing, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", + # /required-list-alias-and-validation-alias fails here + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf( + None, + # /model-required-list-alias-and-validation-alias fails here + IsPartialDict({"p": ["hello", "world"]}), + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 ( # /required-list-alias-and-validation-alias fails here + response.status_code == 422 + ) + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + # /model-required-list-alias-and-validation-alias fails here + IsPartialDict({"p_alias": ["hello", "world"]}), + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # both fail here + assert response.json() == {"p": ["hello", "world"]} # pragma: no cover 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..328f039ba --- /dev/null +++ b/tests/test_request_params/test_header/test_optional_list.py @@ -0,0 +1,407 @@ +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", + pytest.param( + "/model-optional-list-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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"] # /model-optional-list-alias fails here + } + + +# ===================================================================================== +# 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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} # /optional-list-validation-alias fails here + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # /model-optional-list-validation-alias fails here + ) + assert response.json() == { # /optional-list-validation-alias fails here + "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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /optional-list-alias-and-validation-alias fails here + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # /model-optional-list-alias-and-validation-alias fails here + ) + assert response.json() == { + "p": [ # /optional-list-alias-and-validation-alias fails here + "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..d63e0a2b8 --- /dev/null +++ b/tests/test_request_params/test_header/test_optional_str.py @@ -0,0 +1,375 @@ +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", + pytest.param( + "/model-optional-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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"} # /model-optional-alias fails here + + +# ===================================================================================== +# 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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} # /optional-validation-alias fails here + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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"} # /optional-validation-alias fails here + + +# ===================================================================================== +# 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /optional-alias-and-validation-alias fails here + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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" # /optional-alias-and-validation-alias fails here + } 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..6eb4fd6f6 --- /dev/null +++ b/tests/test_request_params/test_header/test_required_str.py @@ -0,0 +1,492 @@ +import pytest +from dirty_equals import AnyThing, IsDict, IsOneOf, IsPartialDict +from fastapi import FastAPI, Header +from fastapi._compat import PYDANTIC_V2 +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} # pragma: no cover + + +@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", + pytest.param( + "/model-required-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 models", + strict=False, + ), + ), + ], +) +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", + pytest.param( + "/model-required-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +def test_required_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(path, headers={"p_alias": "hello"}) + assert response.status_code == 200, ( # /model-required-alias fails here + 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-validation-alias fails here + ], + "msg": "Field required", + "input": AnyThing, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-alias-and-validation-alias fails here + ], + "msg": "Field required", + "input": AnyThing, + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", # /required-alias-and-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf( # /model-alias-and-validation-alias fails here + None, + IsPartialDict({"p": "hello"}), + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # /required-alias-and-validation-alias fails here + ) + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["header", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( # /model-alias-and-validation-alias fails here + None, + IsPartialDict({"p_alias": "hello"}), + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-alias-and-validation-alias fails here + ) + + 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..8e2e60004 --- /dev/null +++ b/tests/test_request_params/test_path/test_required_str.py @@ -0,0 +1,102 @@ +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} # pragma: no cover + + +@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} # pragma: no cover + + +@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.mark.xfail(raises=AssertionError, strict=False), + ), + ), + 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, + pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ), + ], +) +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.mark.xfail(raises=AssertionError, strict=False), + ), + ), + pytest.param( + "/required-alias-and-validation-alias", + id="required-alias-and-validation-alias", + marks=( + needs_pydanticv2, + pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ), + ], +) +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..4edd192e0 --- /dev/null +++ b/tests/test_request_params/test_query/test_list.py @@ -0,0 +1,506 @@ +from typing import List + +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import FastAPI, Query +from fastapi._compat import PYDANTIC_V2 +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} # pragma: no cover + + +@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", + pytest.param( + "/model-required-list-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 models", + strict=False, + ), + ), + ], +) +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( # /model-required-list-alias with PDv2 fails here + 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", + pytest.param( + "/model-required-list-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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, ( # /model-required-list-alias fails here + 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} # pragma: no cover + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-list-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-list-validation-alias fails here + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf(None, {"p": ["hello", "world"]}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # both fail here + + assert response.json() == {"p": ["hello", "world"]} # pragma: no cover + + +# ===================================================================================== +# 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} # pragma: no cover + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", + # /required-list-alias-and-validation-alias fails here + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", + # /required-list-alias-and-validation-alias fails here + "p_val_alias", + ], + "msg": "Field required", + "input": IsOneOf( + None, + # /model-required-list-alias-and-validation-alias fails here + { + "p": [ + "hello", + "world", + ] + }, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 ( # /required-list-alias-and-validation-alias fails here + response.status_code == 422 + ) + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( + None, + # /model-required-list-alias-and-validation-alias fails here + {"p_alias": ["hello", "world"]}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # both fail here + assert response.json() == {"p": ["hello", "world"]} # pragma: no cover 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..76f960554 --- /dev/null +++ b/tests/test_request_params/test_query/test_optional_list.py @@ -0,0 +1,403 @@ +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", + pytest.param( + "/model-optional-list-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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"] # /model-optional-list-alias fails here + } + + +# ===================================================================================== +# 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-list-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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} # /optional-list-validation-alias fails here + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # /model-optional-list-validation-alias fails here + ) + assert response.json() == { # /optional-list-validation-alias fails here + "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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-list-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /optional-list-alias-and-validation-alias fails here + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # /model-optional-list-alias-and-validation-alias fails here + ) + assert response.json() == { + "p": [ # /optional-list-alias-and-validation-alias fails here + "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..77da9bee6 --- /dev/null +++ b/tests/test_request_params/test_query/test_optional_str.py @@ -0,0 +1,375 @@ +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", + pytest.param( + "/model-optional-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +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"} # /model-optional-alias fails here + + +# ===================================================================================== +# 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", + [ + pytest.param( + "/optional-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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"} # /optional-validation-alias fails here + + +# ===================================================================================== +# 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /optional-alias-and-validation-alias fails here + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/optional-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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" # /optional-alias-and-validation-alias fails here + } 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..aa3a27683 --- /dev/null +++ b/tests/test_request_params/test_query/test_required_str.py @@ -0,0 +1,495 @@ +import pytest +from dirty_equals import IsDict, IsOneOf +from fastapi import FastAPI, Query +from fastapi._compat import PYDANTIC_V2 +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} # pragma: no cover + + +@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", + pytest.param( + "/model-required-alias", + marks=pytest.mark.xfail( + raises=AssertionError, + condition=PYDANTIC_V2, + reason="Fails only with PDv2 models", + strict=False, + ), + ), + ], +) +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"}, # /model-required-alias PDv2 fails here + ), + } + ] + } + ) | 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", + pytest.param( + "/model-required-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + ], +) +def test_required_alias_by_alias(path: str): + client = TestClient(app) + response = client.get(f"{path}?p_alias=hello") + assert response.status_code == 200, ( # /model-required-alias fails here + 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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", + [ + pytest.param( + "/required-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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, ( # /required-validation-alias fails here + 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.xfail(raises=AssertionError, strict=False) +@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", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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", # /required-alias-and-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf(None, {}), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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", # /required-alias-and-validation-alias fails here + ], + "msg": "Field required", + "input": IsOneOf( # /model-alias-and-validation-alias fails here + None, + {"p": "hello"}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.xfail(raises=AssertionError, strict=False) +@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 # /required-alias-and-validation-alias fails here + ) + + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["query", "p_val_alias"], + "msg": "Field required", + "input": IsOneOf( # /model-alias-and-validation-alias fails here + None, + {"p_alias": "hello"}, + ), + } + ] + } + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/required-alias-and-validation-alias", + marks=pytest.mark.xfail(raises=AssertionError, strict=False), + ), + "/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 # /required-alias-and-validation-alias fails here + ) + + assert response.json() == {"p": "hello"} diff --git a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py new file mode 100644 index 000000000..d41f1dc1f --- /dev/null +++ b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi.py @@ -0,0 +1,198 @@ +# Ref: https://github.com/fastapi/fastapi/issues/14454 + +from typing import Optional + +from fastapi import APIRouter, Depends, FastAPI, Security +from fastapi.security import OAuth2AuthorizationCodeBearer +from fastapi.testclient import TestClient +from inline_snapshot import snapshot +from typing_extensions import Annotated + +oauth2_scheme = OAuth2AuthorizationCodeBearer( + authorizationUrl="authorize", + tokenUrl="token", + auto_error=True, + scopes={"read": "Read access", "write": "Write access"}, +) + + +async def get_token(token: Annotated[str, Depends(oauth2_scheme)]) -> str: + return token + + +app = FastAPI(dependencies=[Depends(get_token)]) + + +@app.get("/") +async def root(): + return {"message": "Hello World"} + + +@app.get( + "/with-oauth2-scheme", + dependencies=[Security(oauth2_scheme, scopes=["read", "write"])], +) +async def read_with_oauth2_scheme(): + return {"message": "Admin Access"} + + +@app.get( + "/with-get-token", dependencies=[Security(get_token, scopes=["read", "write"])] +) +async def read_with_get_token(): + return {"message": "Admin Access"} + + +router = APIRouter(dependencies=[Security(oauth2_scheme, scopes=["read"])]) + + +@router.get("/items/") +async def read_items(token: Optional[str] = Depends(oauth2_scheme)): + return {"token": token} + + +@router.post("/items/") +async def create_item( + token: Optional[str] = Security(oauth2_scheme, scopes=["read", "write"]), +): + return {"token": token} + + +app.include_router(router) + +client = TestClient(app) + + +def test_root(): + response = client.get("/", headers={"Authorization": "Bearer testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"message": "Hello World"} + + +def test_read_with_oauth2_scheme(): + response = client.get( + "/with-oauth2-scheme", headers={"Authorization": "Bearer testtoken"} + ) + assert response.status_code == 200, response.text + assert response.json() == {"message": "Admin Access"} + + +def test_read_with_get_token(): + response = client.get( + "/with-get-token", headers={"Authorization": "Bearer testtoken"} + ) + assert response.status_code == 200, response.text + assert response.json() == {"message": "Admin Access"} + + +def test_read_token(): + response = client.get("/items/", headers={"Authorization": "Bearer testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"token": "testtoken"} + + +def test_create_token(): + response = client.post("/items/", headers={"Authorization": "Bearer testtoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"token": "testtoken"} + + +def test_openapi_schema(): + 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": { + "/": { + "get": { + "summary": "Root", + "operationId": "root__get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "security": [{"OAuth2AuthorizationCodeBearer": []}], + } + }, + "/with-oauth2-scheme": { + "get": { + "summary": "Read With Oauth2 Scheme", + "operationId": "read_with_oauth2_scheme_with_oauth2_scheme_get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "security": [ + {"OAuth2AuthorizationCodeBearer": ["read", "write"]} + ], + } + }, + "/with-get-token": { + "get": { + "summary": "Read With Get Token", + "operationId": "read_with_get_token_with_get_token_get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "security": [ + {"OAuth2AuthorizationCodeBearer": ["read", "write"]} + ], + } + }, + "/items/": { + "get": { + "summary": "Read Items", + "operationId": "read_items_items__get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "security": [ + {"OAuth2AuthorizationCodeBearer": ["read"]}, + ], + }, + "post": { + "summary": "Create Item", + "operationId": "create_item_items__post", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "security": [ + {"OAuth2AuthorizationCodeBearer": ["read", "write"]}, + ], + }, + }, + }, + "components": { + "securitySchemes": { + "OAuth2AuthorizationCodeBearer": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "scopes": { + "read": "Read access", + "write": "Write access", + }, + "authorizationUrl": "authorize", + "tokenUrl": "token", + } + }, + } + } + }, + } + ) diff --git a/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py new file mode 100644 index 000000000..ff866d4fc --- /dev/null +++ b/tests/test_security_oauth2_authorization_code_bearer_scopes_openapi_simple.py @@ -0,0 +1,79 @@ +# Ref: https://github.com/fastapi/fastapi/issues/14454 + +from fastapi import Depends, FastAPI, Security +from fastapi.security import OAuth2AuthorizationCodeBearer +from fastapi.testclient import TestClient +from inline_snapshot import snapshot +from typing_extensions import Annotated + +oauth2_scheme = OAuth2AuthorizationCodeBearer( + authorizationUrl="api/oauth/authorize", + tokenUrl="/api/oauth/token", + scopes={"read": "Read access", "write": "Write access"}, +) + + +async def get_token(token: Annotated[str, Depends(oauth2_scheme)]) -> str: + return token + + +app = FastAPI(dependencies=[Depends(get_token)]) + + +@app.get("/admin", dependencies=[Security(get_token, scopes=["read", "write"])]) +async def read_admin(): + return {"message": "Admin Access"} + + +client = TestClient(app) + + +def test_read_admin(): + response = client.get("/admin", headers={"Authorization": "Bearer faketoken"}) + assert response.status_code == 200, response.text + assert response.json() == {"message": "Admin Access"} + + +def test_openapi_schema(): + 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": { + "/admin": { + "get": { + "summary": "Read Admin", + "operationId": "read_admin_admin_get", + "responses": { + "200": { + "description": "Successful Response", + "content": {"application/json": {"schema": {}}}, + } + }, + "security": [ + {"OAuth2AuthorizationCodeBearer": ["read", "write"]} + ], + } + } + }, + "components": { + "securitySchemes": { + "OAuth2AuthorizationCodeBearer": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "scopes": { + "read": "Read access", + "write": "Write access", + }, + "authorizationUrl": "api/oauth/authorize", + "tokenUrl": "/api/oauth/token", + } + }, + } + } + }, + } + ) diff --git a/tests/test_stringified_annotation_dependency.py b/tests/test_stringified_annotation_dependency.py new file mode 100644 index 000000000..89bb884b5 --- /dev/null +++ b/tests/test_stringified_annotation_dependency.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from inline_snapshot import snapshot +from typing_extensions import Annotated + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import AsyncGenerator + + +class DummyClient: + async def get_people(self) -> list: + return ["John Doe", "Jane Doe"] + + async def close(self) -> None: + pass + + +async def get_client() -> AsyncGenerator[DummyClient, None]: + client = DummyClient() + yield client + await client.close() + + +Client = Annotated[DummyClient, Depends(get_client)] + + +@pytest.fixture(name="client") +def client_fixture() -> TestClient: + app = FastAPI() + + @app.get("/") + async def get_people(client: Client) -> list: + return await client.get_people() + + client = TestClient(app) + return client + + +def test_get(client: TestClient): + response = client.get("/") + assert response.status_code == 200, response.text + assert response.json() == ["John Doe", "Jane Doe"] + + +def test_openapi_schema(client: TestClient): + 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": { + "/": { + "get": { + "summary": "Get People", + "operationId": "get_people__get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": {}, + "type": "array", + "title": "Response Get People Get", + } + } + }, + } + }, + } + } + }, + } + ) diff --git a/tests/test_stringified_annotations_simple.py b/tests/test_stringified_annotations_simple.py new file mode 100644 index 000000000..9bd6d09d6 --- /dev/null +++ b/tests/test_stringified_annotations_simple.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from fastapi import Depends, FastAPI, Request +from fastapi.testclient import TestClient +from typing_extensions import Annotated + +from .utils import needs_py310 + + +class Dep: + def __call__(self, request: Request): + return "test" + + +@needs_py310 +def test_stringified_annotations(): + app = FastAPI() + + client = TestClient(app) + + @app.get("/test/") + def call(test: Annotated[str, Depends(Dep())]): + return {"test": test} + + response = client.get("/test") + assert response.status_code == 200 diff --git a/tests/test_tutorial/test_additional_responses/test_tutorial002.py b/tests/test_tutorial/test_additional_responses/test_tutorial002.py index 588a3160a..91d6ff101 100644 --- a/tests/test_tutorial/test_additional_responses/test_tutorial002.py +++ b/tests/test_tutorial/test_additional_responses/test_tutorial002.py @@ -1,21 +1,36 @@ +import importlib import os import shutil +import pytest from dirty_equals import IsDict from fastapi.testclient import TestClient -from docs_src.additional_responses.tutorial002 import app - -client = TestClient(app) +from tests.utils import needs_py310 -def test_path_operation(): +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial002"), + pytest.param("tutorial002_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.additional_responses.{request.param}") + + client = TestClient(mod.app) + client.headers.clear() + return client + + +def test_path_operation(client: TestClient): response = client.get("/items/foo") assert response.status_code == 200, response.text assert response.json() == {"id": "foo", "value": "there goes my hero"} -def test_path_operation_img(): +def test_path_operation_img(client: TestClient): shutil.copy("./docs/en/docs/img/favicon.png", "./image.png") response = client.get("/items/foo?img=1") assert response.status_code == 200, response.text @@ -24,7 +39,7 @@ def test_path_operation_img(): os.remove("./image.png") -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_additional_responses/test_tutorial004.py b/tests/test_tutorial/test_additional_responses/test_tutorial004.py index 55b556d8e..2d9491467 100644 --- a/tests/test_tutorial/test_additional_responses/test_tutorial004.py +++ b/tests/test_tutorial/test_additional_responses/test_tutorial004.py @@ -1,21 +1,36 @@ +import importlib import os import shutil +import pytest from dirty_equals import IsDict from fastapi.testclient import TestClient -from docs_src.additional_responses.tutorial004 import app - -client = TestClient(app) +from tests.utils import needs_py310 -def test_path_operation(): +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial004"), + pytest.param("tutorial004_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.additional_responses.{request.param}") + + client = TestClient(mod.app) + client.headers.clear() + return client + + +def test_path_operation(client: TestClient): response = client.get("/items/foo") assert response.status_code == 200, response.text assert response.json() == {"id": "foo", "value": "there goes my hero"} -def test_path_operation_img(): +def test_path_operation_img(client: TestClient): shutil.copy("./docs/en/docs/img/favicon.png", "./image.png") response = client.get("/items/foo?img=1") assert response.status_code == 200, response.text @@ -24,7 +39,7 @@ def test_path_operation_img(): os.remove("./image.png") -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py b/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py index e6da630e8..f9fd0d1af 100644 --- a/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py +++ b/tests/test_tutorial/test_custom_request_and_route/test_tutorial001.py @@ -1,23 +1,38 @@ import gzip +import importlib import json import pytest from fastapi import Request from fastapi.testclient import TestClient -from docs_src.custom_request_and_route.tutorial001 import app +from tests.utils import needs_py39, needs_py310 -@app.get("/check-class") -async def check_gzip_request(request: Request): - return {"request_class": type(request).__name__} +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial001"), + pytest.param("tutorial001_py39", marks=needs_py39), + pytest.param("tutorial001_py310", marks=needs_py310), + pytest.param("tutorial001_an"), + pytest.param("tutorial001_an_py39", marks=needs_py39), + pytest.param("tutorial001_an_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.custom_request_and_route.{request.param}") + @mod.app.get("/check-class") + async def check_gzip_request(request: Request): + return {"request_class": type(request).__name__} -client = TestClient(app) + client = TestClient(mod.app) + return client @pytest.mark.parametrize("compress", [True, False]) -def test_gzip_request(compress): +def test_gzip_request(client: TestClient, compress): n = 1000 headers = {} body = [1] * n @@ -30,6 +45,6 @@ def test_gzip_request(compress): assert response.json() == {"sum": n} -def test_request_class(): +def test_request_class(client: TestClient): response = client.get("/check-class") assert response.json() == {"request_class": "GzipRequest"} diff --git a/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py b/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py index 647f1c5dd..c35752ed1 100644 --- a/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py +++ b/tests/test_tutorial/test_custom_request_and_route/test_tutorial002.py @@ -1,17 +1,36 @@ +import importlib + +import pytest from dirty_equals import IsDict, IsOneOf from fastapi.testclient import TestClient -from docs_src.custom_request_and_route.tutorial002 import app - -client = TestClient(app) +from tests.utils import needs_py39, needs_py310 -def test_endpoint_works(): +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial002"), + pytest.param("tutorial002_py39", marks=needs_py39), + pytest.param("tutorial002_py310", marks=needs_py310), + pytest.param("tutorial002_an"), + pytest.param("tutorial002_an_py39", marks=needs_py39), + pytest.param("tutorial002_an_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.custom_request_and_route.{request.param}") + + client = TestClient(mod.app) + return client + + +def test_endpoint_works(client: TestClient): response = client.post("/", json=[1, 2, 3]) assert response.json() == 6 -def test_exception_handler_body_access(): +def test_exception_handler_body_access(client: TestClient): response = client.post("/", json={"numbers": [1, 2, 3]}) assert response.json() == IsDict( { diff --git a/tests/test_tutorial/test_custom_request_and_route/test_tutorial003.py b/tests/test_tutorial/test_custom_request_and_route/test_tutorial003.py index db5dad7cf..9e895b2da 100644 --- a/tests/test_tutorial/test_custom_request_and_route/test_tutorial003.py +++ b/tests/test_tutorial/test_custom_request_and_route/test_tutorial003.py @@ -1,17 +1,32 @@ +import importlib + +import pytest from fastapi.testclient import TestClient -from docs_src.custom_request_and_route.tutorial003 import app - -client = TestClient(app) +from tests.utils import needs_py310 -def test_get(): +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial003"), + pytest.param("tutorial003_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.custom_request_and_route.{request.param}") + + client = TestClient(mod.app) + return client + + +def test_get(client: TestClient): response = client.get("/") assert response.json() == {"message": "Not timed"} assert "X-Response-Time" not in response.headers -def test_get_timed(): +def test_get_timed(client: TestClient): response = client.get("/timed") assert response.json() == {"message": "It's the time of my life"} assert "X-Response-Time" in response.headers diff --git a/tests/test_tutorial/test_dataclasses/test_tutorial001.py b/tests/test_tutorial/test_dataclasses/test_tutorial001.py index 762654d29..b36dee768 100644 --- a/tests/test_tutorial/test_dataclasses/test_tutorial001.py +++ b/tests/test_tutorial/test_dataclasses/test_tutorial001.py @@ -1,12 +1,28 @@ +import importlib + +import pytest from dirty_equals import IsDict from fastapi.testclient import TestClient -from docs_src.dataclasses.tutorial001 import app - -client = TestClient(app) +from tests.utils import needs_py310 -def test_post_item(): +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial001"), + pytest.param("tutorial001_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.dataclasses.{request.param}") + + client = TestClient(mod.app) + client.headers.clear() + return client + + +def test_post_item(client: TestClient): response = client.post("/items/", json={"name": "Foo", "price": 3}) assert response.status_code == 200 assert response.json() == { @@ -17,7 +33,7 @@ def test_post_item(): } -def test_post_invalid_item(): +def test_post_invalid_item(client: TestClient): response = client.post("/items/", json={"name": "Foo", "price": "invalid price"}) assert response.status_code == 422 assert response.json() == IsDict( @@ -45,7 +61,7 @@ def test_post_invalid_item(): ) -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200 assert response.json() == { diff --git a/tests/test_tutorial/test_dataclasses/test_tutorial002.py b/tests/test_tutorial/test_dataclasses/test_tutorial002.py index e6d303cfc..baaea45d8 100644 --- a/tests/test_tutorial/test_dataclasses/test_tutorial002.py +++ b/tests/test_tutorial/test_dataclasses/test_tutorial002.py @@ -1,12 +1,29 @@ +import importlib + +import pytest from dirty_equals import IsDict, IsOneOf from fastapi.testclient import TestClient -from docs_src.dataclasses.tutorial002 import app - -client = TestClient(app) +from tests.utils import needs_py39, needs_py310 -def test_get_item(): +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial002"), + pytest.param("tutorial002_py39", marks=needs_py39), + pytest.param("tutorial002_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.dataclasses.{request.param}") + + client = TestClient(mod.app) + client.headers.clear() + return client + + +def test_get_item(client: TestClient): response = client.get("/items/next") assert response.status_code == 200 assert response.json() == { @@ -18,7 +35,7 @@ def test_get_item(): } -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200 assert response.json() == { diff --git a/tests/test_tutorial/test_dataclasses/test_tutorial003.py b/tests/test_tutorial/test_dataclasses/test_tutorial003.py index e1fa45201..5728d2b6b 100644 --- a/tests/test_tutorial/test_dataclasses/test_tutorial003.py +++ b/tests/test_tutorial/test_dataclasses/test_tutorial003.py @@ -1,13 +1,28 @@ +import importlib + +import pytest from fastapi.testclient import TestClient -from docs_src.dataclasses.tutorial003 import app - -from ...utils import needs_pydanticv1, needs_pydanticv2 - -client = TestClient(app) +from ...utils import needs_py39, needs_py310, needs_pydanticv1, needs_pydanticv2 -def test_post_authors_item(): +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial003"), + pytest.param("tutorial003_py39", marks=needs_py39), + pytest.param("tutorial003_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.dataclasses.{request.param}") + + client = TestClient(mod.app) + client.headers.clear() + return client + + +def test_post_authors_item(client: TestClient): response = client.post( "/authors/foo/items/", json=[{"name": "Bar"}, {"name": "Baz", "description": "Drop the Baz"}], @@ -22,7 +37,7 @@ def test_post_authors_item(): } -def test_get_authors(): +def test_get_authors(client: TestClient): response = client.get("/authors/") assert response.status_code == 200 assert response.json() == [ @@ -54,7 +69,7 @@ def test_get_authors(): @needs_pydanticv2 -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200 assert response.json() == { @@ -191,7 +206,7 @@ def test_openapi_schema(): # TODO: remove when deprecating Pydantic v1 @needs_pydanticv1 -def test_openapi_schema_pv1(): +def test_openapi_schema_pv1(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200 assert response.json() == { diff --git a/tests/test_tutorial/test_handling_errors/test_tutorial004.py b/tests/test_tutorial/test_handling_errors/test_tutorial004.py index 217159a59..c04bf3724 100644 --- a/tests/test_tutorial/test_handling_errors/test_tutorial004.py +++ b/tests/test_tutorial/test_handling_errors/test_tutorial004.py @@ -8,18 +8,8 @@ client = TestClient(app) def test_get_validation_error(): response = client.get("/items/foo") assert response.status_code == 400, response.text - # TODO: remove when deprecating Pydantic v1 - assert ( - # TODO: remove when deprecating Pydantic v1 - "path -> item_id" in response.text - or "'loc': ('path', 'item_id')" in response.text - ) - assert ( - # TODO: remove when deprecating Pydantic v1 - "value is not a valid integer" in response.text - or "Input should be a valid integer, unable to parse string as an integer" - in response.text - ) + assert "Validation errors:" in response.text + assert "Field: ('path', 'item_id')" in response.text def test_get_http_error(): diff --git a/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py b/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py index 73af420ae..2df2b9889 100644 --- a/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py +++ b/tests/test_tutorial/test_openapi_callbacks/test_tutorial001.py @@ -1,12 +1,33 @@ +import importlib +from types import ModuleType + +import pytest from dirty_equals import IsDict from fastapi.testclient import TestClient -from docs_src.openapi_callbacks.tutorial001 import app, invoice_notification - -client = TestClient(app) +from tests.utils import needs_py310 -def test_get(): +@pytest.fixture( + name="mod", + params=[ + pytest.param("tutorial001"), + pytest.param("tutorial001_py310", marks=needs_py310), + ], +) +def get_mod(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.openapi_callbacks.{request.param}") + return mod + + +@pytest.fixture(name="client") +def get_client(mod: ModuleType): + client = TestClient(mod.app) + client.headers.clear() + return client + + +def test_get(client: TestClient): response = client.post( "/invoices/", json={"id": "fooinvoice", "customer": "John", "total": 5.3} ) @@ -14,12 +35,12 @@ def test_get(): assert response.json() == {"msg": "Invoice received"} -def test_dummy_callback(): +def test_dummy_callback(mod: ModuleType): # Just for coverage - invoice_notification({}) + mod.invoice_notification({}) -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial004.py b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial004.py index 4f69e4646..da5782d18 100644 --- a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial004.py +++ b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial004.py @@ -1,13 +1,30 @@ +import importlib + +import pytest from fastapi.testclient import TestClient -from docs_src.path_operation_advanced_configuration.tutorial004 import app - -from ...utils import needs_pydanticv1, needs_pydanticv2 - -client = TestClient(app) +from ...utils import needs_py39, needs_py310, needs_pydanticv1, needs_pydanticv2 -def test_query_params_str_validations(): +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial004"), + pytest.param("tutorial004_py39", marks=needs_py39), + pytest.param("tutorial004_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module( + f"docs_src.path_operation_advanced_configuration.{request.param}" + ) + + client = TestClient(mod.app) + client.headers.clear() + return client + + +def test_query_params_str_validations(client: TestClient): response = client.post("/items/", json={"name": "Foo", "price": 42}) assert response.status_code == 200, response.text assert response.json() == { @@ -20,7 +37,7 @@ def test_query_params_str_validations(): @needs_pydanticv2 -def test_openapi_schema(): +def test_openapi_schema(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { @@ -123,7 +140,7 @@ def test_openapi_schema(): # TODO: remove when deprecating Pydantic v1 @needs_pydanticv1 -def test_openapi_schema_pv1(): +def test_openapi_schema_pv1(client: TestClient): response = client.get("/openapi.json") assert response.status_code == 200, response.text assert response.json() == { diff --git a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py index 8240b60a6..a90337a63 100644 --- a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py +++ b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007.py @@ -1,14 +1,24 @@ +import importlib + import pytest from fastapi.testclient import TestClient -from ...utils import needs_pydanticv2 +from ...utils import needs_py39, needs_pydanticv2 -@pytest.fixture(name="client") -def get_client(): - from docs_src.path_operation_advanced_configuration.tutorial007 import app +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial007"), + pytest.param("tutorial007_py39", marks=needs_py39), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module( + f"docs_src.path_operation_advanced_configuration.{request.param}" + ) - client = TestClient(app) + client = TestClient(mod.app) return client diff --git a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007_pv1.py b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007_pv1.py index ef012f8a6..b38e4947c 100644 --- a/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007_pv1.py +++ b/tests/test_tutorial/test_path_operation_advanced_configurations/test_tutorial007_pv1.py @@ -1,14 +1,24 @@ +import importlib + import pytest from fastapi.testclient import TestClient -from ...utils import needs_pydanticv1 +from ...utils import needs_py39, needs_pydanticv1 -@pytest.fixture(name="client") -def get_client(): - from docs_src.path_operation_advanced_configuration.tutorial007_pv1 import app +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial007_pv1"), + pytest.param("tutorial007_pv1_py39", marks=needs_py39), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module( + f"docs_src.path_operation_advanced_configuration.{request.param}" + ) - client = TestClient(app) + client = TestClient(mod.app) return client diff --git a/tests/test_tutorial/test_response_directly/__init__.py b/tests/test_tutorial/test_response_directly/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_tutorial/test_response_directly/test_tutorial001.py b/tests/test_tutorial/test_response_directly/test_tutorial001.py new file mode 100644 index 000000000..2cc4f3b0c --- /dev/null +++ b/tests/test_tutorial/test_response_directly/test_tutorial001.py @@ -0,0 +1,288 @@ +import importlib + +import pytest +from fastapi.testclient import TestClient + +from ...utils import needs_py310, needs_pydanticv1, needs_pydanticv2 + + +@pytest.fixture( + name="client", + params=[ + pytest.param("tutorial001"), + pytest.param("tutorial001_py310", marks=needs_py310), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.response_directly.{request.param}") + + client = TestClient(mod.app) + return client + + +def test_path_operation(client: TestClient): + response = client.put( + "/items/1", + json={ + "title": "Foo", + "timestamp": "2023-01-01T12:00:00", + "description": "A test item", + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "description": "A test item", + "timestamp": "2023-01-01T12:00:00", + "title": "Foo", + } + + +@needs_pydanticv2 +def test_openapi_schema_pv2(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "info": { + "title": "FastAPI", + "version": "0.1.0", + }, + "openapi": "3.1.0", + "paths": { + "/items/{id}": { + "put": { + "operationId": "update_item_items__id__put", + "parameters": [ + { + "in": "path", + "name": "id", + "required": True, + "schema": {"title": "Id", "type": "string"}, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item", + }, + }, + }, + "required": True, + }, + "responses": { + "200": { + "content": { + "application/json": {"schema": {}}, + }, + "description": "Successful Response", + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError", + }, + }, + }, + "description": "Validation Error", + }, + }, + "summary": "Update Item", + }, + }, + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError", + }, + "title": "Detail", + "type": "array", + }, + }, + "title": "HTTPValidationError", + "type": "object", + }, + "Item": { + "properties": { + "description": { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ], + "title": "Description", + }, + "timestamp": { + "format": "date-time", + "title": "Timestamp", + "type": "string", + }, + "title": {"title": "Title", "type": "string"}, + }, + "required": [ + "title", + "timestamp", + ], + "title": "Item", + "type": "object", + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + {"type": "string"}, + {"type": "integer"}, + ], + }, + "title": "Location", + "type": "array", + }, + "msg": {"title": "Message", "type": "string"}, + "type": {"title": "Error Type", "type": "string"}, + }, + "required": ["loc", "msg", "type"], + "title": "ValidationError", + "type": "object", + }, + }, + }, + } + + +@needs_pydanticv1 +def test_openapi_schema_pv1(client: TestClient): + response = client.get("/openapi.json") + assert response.status_code == 200, response.text + assert response.json() == { + "info": { + "title": "FastAPI", + "version": "0.1.0", + }, + "openapi": "3.1.0", + "paths": { + "/items/{id}": { + "put": { + "operationId": "update_item_items__id__put", + "parameters": [ + { + "in": "path", + "name": "id", + "required": True, + "schema": { + "title": "Id", + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item", + }, + }, + }, + "required": True, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": {}, + }, + }, + "description": "Successful Response", + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError", + }, + }, + }, + "description": "Validation Error", + }, + }, + "summary": "Update Item", + }, + }, + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError", + }, + "title": "Detail", + "type": "array", + }, + }, + "title": "HTTPValidationError", + "type": "object", + }, + "Item": { + "properties": { + "description": { + "title": "Description", + "type": "string", + }, + "timestamp": { + "format": "date-time", + "title": "Timestamp", + "type": "string", + }, + "title": { + "title": "Title", + "type": "string", + }, + }, + "required": [ + "title", + "timestamp", + ], + "title": "Item", + "type": "object", + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "integer", + }, + ], + }, + "title": "Location", + "type": "array", + }, + "msg": { + "title": "Message", + "type": "string", + }, + "type": { + "title": "Error Type", + "type": "string", + }, + }, + "required": [ + "loc", + "msg", + "type", + ], + "title": "ValidationError", + "type": "object", + }, + }, + }, + } diff --git a/tests/test_tutorial/test_settings/test_app02.py b/tests/test_tutorial/test_settings/test_app02.py index eced88c04..5e1232ea0 100644 --- a/tests/test_tutorial/test_settings/test_app02.py +++ b/tests/test_tutorial/test_settings/test_app02.py @@ -1,20 +1,45 @@ +import importlib +from types import ModuleType + +import pytest from pytest import MonkeyPatch -from ...utils import needs_pydanticv2 +from ...utils import needs_py39, needs_pydanticv2 + + +@pytest.fixture( + name="mod_path", + params=[ + pytest.param("app02"), + pytest.param("app02_an"), + pytest.param("app02_an_py39", marks=needs_py39), + ], +) +def get_mod_path(request: pytest.FixtureRequest): + mod_path = f"docs_src.settings.{request.param}" + return mod_path + + +@pytest.fixture(name="main_mod") +def get_main_mod(mod_path: str) -> ModuleType: + main_mod = importlib.import_module(f"{mod_path}.main") + return main_mod + + +@pytest.fixture(name="test_main_mod") +def get_test_main_mod(mod_path: str) -> ModuleType: + test_main_mod = importlib.import_module(f"{mod_path}.test_main") + return test_main_mod @needs_pydanticv2 -def test_settings(monkeypatch: MonkeyPatch): - from docs_src.settings.app02 import main - +def test_settings(main_mod: ModuleType, monkeypatch: MonkeyPatch): monkeypatch.setenv("ADMIN_EMAIL", "admin@example.com") - settings = main.get_settings() + settings = main_mod.get_settings() assert settings.app_name == "Awesome API" assert settings.items_per_user == 50 @needs_pydanticv2 -def test_override_settings(): - from docs_src.settings.app02 import test_main - - test_main.test_app() +def test_override_settings(test_main_mod: ModuleType): + test_main_mod.test_app() diff --git a/tests/test_tutorial/test_settings/test_app03.py b/tests/test_tutorial/test_settings/test_app03.py new file mode 100644 index 000000000..d9872c15f --- /dev/null +++ b/tests/test_tutorial/test_settings/test_app03.py @@ -0,0 +1,59 @@ +import importlib +from types import ModuleType + +import pytest +from fastapi.testclient import TestClient +from pytest import MonkeyPatch + +from ...utils import needs_py39, needs_pydanticv1, needs_pydanticv2 + + +@pytest.fixture( + name="mod_path", + params=[ + pytest.param("app03"), + pytest.param("app03_an"), + pytest.param("app03_an_py39", marks=needs_py39), + ], +) +def get_mod_path(request: pytest.FixtureRequest): + mod_path = f"docs_src.settings.{request.param}" + return mod_path + + +@pytest.fixture(name="main_mod") +def get_main_mod(mod_path: str) -> ModuleType: + main_mod = importlib.import_module(f"{mod_path}.main") + return main_mod + + +@needs_pydanticv2 +def test_settings(main_mod: ModuleType, monkeypatch: MonkeyPatch): + monkeypatch.setenv("ADMIN_EMAIL", "admin@example.com") + settings = main_mod.get_settings() + assert settings.app_name == "Awesome API" + assert settings.admin_email == "admin@example.com" + assert settings.items_per_user == 50 + + +@needs_pydanticv1 +def test_settings_pv1(mod_path: str, monkeypatch: MonkeyPatch): + monkeypatch.setenv("ADMIN_EMAIL", "admin@example.com") + config_mod = importlib.import_module(f"{mod_path}.config_pv1") + settings = config_mod.Settings() + assert settings.app_name == "Awesome API" + assert settings.admin_email == "admin@example.com" + assert settings.items_per_user == 50 + + +@needs_pydanticv2 +def test_endpoint(main_mod: ModuleType, monkeypatch: MonkeyPatch): + monkeypatch.setenv("ADMIN_EMAIL", "admin@example.com") + client = TestClient(main_mod.app) + response = client.get("/info") + assert response.status_code == 200 + assert response.json() == { + "app_name": "Awesome API", + "admin_email": "admin@example.com", + "items_per_user": 50, + } diff --git a/tests/test_tutorial/test_using_request_directly/__init__.py b/tests/test_tutorial/test_using_request_directly/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_tutorial/test_using_request_directly/test_tutorial001.py b/tests/test_tutorial/test_using_request_directly/test_tutorial001.py new file mode 100644 index 000000000..54c53ae1e --- /dev/null +++ b/tests/test_tutorial/test_using_request_directly/test_tutorial001.py @@ -0,0 +1,112 @@ +from fastapi.testclient import TestClient + +from docs_src.using_request_directly.tutorial001 import app + +client = TestClient(app) + + +def test_path_operation(): + response = client.get("/items/foo") + assert response.status_code == 200 + assert response.json() == {"client_host": "testclient", "item_id": "foo"} + + +def test_openapi(): + response = client.get("/openapi.json") + assert response.status_code == 200 + assert response.json() == { + "info": { + "title": "FastAPI", + "version": "0.1.0", + }, + "openapi": "3.1.0", + "paths": { + "/items/{item_id}": { + "get": { + "operationId": "read_root_items__item_id__get", + "parameters": [ + { + "in": "path", + "name": "item_id", + "required": True, + "schema": { + "title": "Item Id", + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {}, + }, + }, + "description": "Successful Response", + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError", + }, + }, + }, + "description": "Validation Error", + }, + }, + "summary": "Read Root", + }, + }, + }, + "components": { + "schemas": { + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError", + }, + "title": "Detail", + "type": "array", + }, + }, + "title": "HTTPValidationError", + "type": "object", + }, + "ValidationError": { + "properties": { + "loc": { + "items": { + "anyOf": [ + { + "type": "string", + }, + { + "type": "integer", + }, + ], + }, + "title": "Location", + "type": "array", + }, + "msg": { + "title": "Message", + "type": "string", + }, + "type": { + "title": "Error Type", + "type": "string", + }, + }, + "required": [ + "loc", + "msg", + "type", + ], + "title": "ValidationError", + "type": "object", + }, + }, + }, + } diff --git a/tests/test_tutorial/test_websockets/test_tutorial003.py b/tests/test_tutorial/test_websockets/test_tutorial003.py index dbcad3b02..85efc1859 100644 --- a/tests/test_tutorial/test_websockets/test_tutorial003.py +++ b/tests/test_tutorial/test_websockets/test_tutorial003.py @@ -1,16 +1,45 @@ +import importlib +from types import ModuleType + +import pytest from fastapi.testclient import TestClient -from docs_src.websockets.tutorial003 import app, html - -client = TestClient(app) +from ...utils import needs_py39 -def test_get(): +@pytest.fixture( + name="mod", + params=[ + pytest.param("tutorial003"), + pytest.param("tutorial003_py39", marks=needs_py39), + ], +) +def get_mod(request: pytest.FixtureRequest): + mod = importlib.import_module(f"docs_src.websockets.{request.param}") + + return mod + + +@pytest.fixture(name="html") +def get_html(mod: ModuleType): + return mod.html + + +@pytest.fixture(name="client") +def get_client(mod: ModuleType): + client = TestClient(mod.app) + + return client + + +@needs_py39 +def test_get(client: TestClient, html: str): response = client.get("/") assert response.text == html -def test_websocket_handle_disconnection(): +@needs_py39 +def test_websocket_handle_disconnection(client: TestClient): with client.websocket_connect("/ws/1234") as connection, client.websocket_connect( "/ws/5678" ) as connection_two: diff --git a/tests/test_tutorial/test_websockets/test_tutorial003_py39.py b/tests/test_tutorial/test_websockets/test_tutorial003_py39.py deleted file mode 100644 index 06c4a9279..000000000 --- a/tests/test_tutorial/test_websockets/test_tutorial003_py39.py +++ /dev/null @@ -1,50 +0,0 @@ -import pytest -from fastapi import FastAPI -from fastapi.testclient import TestClient - -from ...utils import needs_py39 - - -@pytest.fixture(name="app") -def get_app(): - from docs_src.websockets.tutorial003_py39 import app - - return app - - -@pytest.fixture(name="html") -def get_html(): - from docs_src.websockets.tutorial003_py39 import html - - return html - - -@pytest.fixture(name="client") -def get_client(app: FastAPI): - client = TestClient(app) - - return client - - -@needs_py39 -def test_get(client: TestClient, html: str): - response = client.get("/") - assert response.text == html - - -@needs_py39 -def test_websocket_handle_disconnection(client: TestClient): - with client.websocket_connect("/ws/1234") as connection, client.websocket_connect( - "/ws/5678" - ) as connection_two: - connection.send_text("Hello from 1234") - data1 = connection.receive_text() - assert data1 == "You wrote: Hello from 1234" - data2 = connection_two.receive_text() - client1_says = "Client #1234 says: Hello from 1234" - assert data2 == client1_says - data1 = connection.receive_text() - assert data1 == client1_says - connection_two.close() - data1 = connection.receive_text() - assert data1 == "Client #5678 left the chat" diff --git a/tests/test_validation_error_context.py b/tests/test_validation_error_context.py new file mode 100644 index 000000000..844b8a64f --- /dev/null +++ b/tests/test_validation_error_context.py @@ -0,0 +1,168 @@ +from fastapi import FastAPI, Request, WebSocket +from fastapi.exceptions import ( + RequestValidationError, + ResponseValidationError, + WebSocketRequestValidationError, +) +from fastapi.testclient import TestClient +from pydantic import BaseModel + + +class Item(BaseModel): + id: int + name: str + + +class ExceptionCapture: + def __init__(self): + self.exception = None + + def capture(self, exc): + self.exception = exc + return exc + + +app = FastAPI() +sub_app = FastAPI() +captured_exception = ExceptionCapture() + +app.mount(path="/sub", app=sub_app) + + +@app.exception_handler(RequestValidationError) +@sub_app.exception_handler(RequestValidationError) +async def request_validation_handler(request: Request, exc: RequestValidationError): + captured_exception.capture(exc) + raise exc + + +@app.exception_handler(ResponseValidationError) +@sub_app.exception_handler(ResponseValidationError) +async def response_validation_handler(_: Request, exc: ResponseValidationError): + captured_exception.capture(exc) + raise exc + + +@app.exception_handler(WebSocketRequestValidationError) +@sub_app.exception_handler(WebSocketRequestValidationError) +async def websocket_validation_handler( + websocket: WebSocket, exc: WebSocketRequestValidationError +): + captured_exception.capture(exc) + raise exc + + +@app.get("/users/{user_id}") +def get_user(user_id: int): + return {"user_id": user_id} # pragma: no cover + + +@app.get("/items/", response_model=Item) +def get_item(): + return {"name": "Widget"} + + +@sub_app.get("/items/", response_model=Item) +def get_sub_item(): + return {"name": "Widget"} # pragma: no cover + + +@app.websocket("/ws/{item_id}") +async def websocket_endpoint(websocket: WebSocket, item_id: int): + await websocket.accept() # pragma: no cover + await websocket.send_text(f"Item: {item_id}") # pragma: no cover + await websocket.close() # pragma: no cover + + +@sub_app.websocket("/ws/{item_id}") +async def subapp_websocket_endpoint(websocket: WebSocket, item_id: int): + await websocket.accept() # pragma: no cover + await websocket.send_text(f"Item: {item_id}") # pragma: no cover + await websocket.close() # pragma: no cover + + +client = TestClient(app) + + +def test_request_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + client.get("/users/invalid") + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "get_user" in error_str + assert "/users/" in error_str + + +def test_response_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + client.get("/items/") + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "get_item" in error_str + assert "/items/" in error_str + + +def test_websocket_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + with client.websocket_connect("/ws/invalid"): + pass # pragma: no cover + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "websocket_endpoint" in error_str + assert "/ws/" in error_str + + +def test_subapp_request_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + client.get("/sub/items/") + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "get_sub_item" in error_str + assert "/sub/items/" in error_str + + +def test_subapp_websocket_validation_error_includes_endpoint_context(): + captured_exception.exception = None + try: + with client.websocket_connect("/sub/ws/invalid"): + pass # pragma: no cover + except Exception: + pass + + assert captured_exception.exception is not None + error_str = str(captured_exception.exception) + assert "subapp_websocket_endpoint" in error_str + assert "/sub/ws/" in error_str + + +def test_validation_error_with_only_path(): + errors = [{"type": "missing", "loc": ("body", "name"), "msg": "Field required"}] + exc = RequestValidationError(errors, endpoint_ctx={"path": "GET /api/test"}) + error_str = str(exc) + assert "Endpoint: GET /api/test" in error_str + assert 'File "' not in error_str + + +def test_validation_error_with_no_context(): + errors = [{"type": "missing", "loc": ("body", "name"), "msg": "Field required"}] + exc = RequestValidationError(errors, endpoint_ctx={}) + error_str = str(exc) + assert "1 validation error:" in error_str + assert "Endpoint" not in error_str + assert 'File "' not in error_str diff --git a/tests/test_wrapped_method_forward_reference.py b/tests/test_wrapped_method_forward_reference.py new file mode 100644 index 000000000..7403f6002 --- /dev/null +++ b/tests/test_wrapped_method_forward_reference.py @@ -0,0 +1,31 @@ +import functools + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from .forward_reference_type import forwardref_method + + +def passthrough(f): + @functools.wraps(f) + def method(*args, **kwargs): + return f(*args, **kwargs) + + return method + + +def test_wrapped_method_type_inference(): + """ + Regression test ensuring that when a method imported from another module + is decorated with something that sets the __wrapped__ attribute (functools.wraps), + then the types are still processed correctly, including dereferencing of forward + references. + """ + app = FastAPI() + client = TestClient(app) + app.post("/endpoint")(passthrough(forwardref_method)) + app.post("/endpoint2")(passthrough(passthrough(forwardref_method))) + with client: + response = client.post("/endpoint", json={"input": {"x": 0}}) + response2 = client.post("/endpoint2", json={"input": {"x": 0}}) + assert response.json() == response2.json() == {"x": 1}