Skip to content

Commit fcddf6b

Browse files
authored
Merge pull request #738 from python-openapi/feature/fastapi-integration
FastAPI integration
2 parents 383d097 + 64f4dd0 commit fcddf6b

File tree

14 files changed

+637
-13
lines changed

14 files changed

+637
-13
lines changed

Diff for: docs/integrations/fastapi.rst

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
FastAPI
2+
=========
3+
4+
This section describes integration with `FastAPI <https://fastapi.tiangolo.com>`__ ASGI framework.
5+
6+
.. note::
7+
8+
FastAPI also provides OpenAPI support. The main difference is that, unlike FastAPI's code-first approach, OpenAPI-core allows you to laverage your existing specification that alligns with API-First approach. You can read more about API-first vs. code-first in the [Guide to API-first](https://www.postman.com/api-first/).
9+
10+
Middleware
11+
----------
12+
13+
FastAPI can be integrated by `middleware <https://fastapi.tiangolo.com/tutorial/middleware/>`__ to apply OpenAPI validation to your entire application.
14+
15+
Add ``FastAPIOpenAPIMiddleware`` with OpenAPI object to your ``middleware`` list.
16+
17+
.. code-block:: python
18+
:emphasize-lines: 2,5
19+
20+
from fastapi import FastAPI
21+
from openapi_core.contrib.fastapi.middlewares import FastAPIOpenAPIMiddleware
22+
23+
app = FastAPI()
24+
app.add_middleware(FastAPIOpenAPIMiddleware, openapi=openapi)
25+
26+
After that all your requests and responses will be validated.
27+
28+
Also you have access to unmarshal result object with all unmarshalled request data through ``openapi`` scope of request object.
29+
30+
.. code-block:: python
31+
32+
async def homepage(request):
33+
# get parameters object with path, query, cookies and headers parameters
34+
unmarshalled_params = request.scope["openapi"].parameters
35+
# or specific location parameters
36+
unmarshalled_path_params = request.scope["openapi"].parameters.path
37+
38+
# get body
39+
unmarshalled_body = request.scope["openapi"].body
40+
41+
# get security data
42+
unmarshalled_security = request.scope["openapi"].security
43+
44+
Response validation
45+
^^^^^^^^^^^^^^^^^^^
46+
47+
You can skip response validation process: by setting ``response_cls`` to ``None``
48+
49+
.. code-block:: python
50+
:emphasize-lines: 2
51+
52+
app = FastAPI()
53+
app.add_middleware(FastAPIOpenAPIMiddleware, openapi=openapi, response_cls=None)
54+
55+
Low level
56+
---------
57+
58+
For low level integration see `Starlette <starlette.rst>`_ integration.

Diff for: docs/integrations/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Openapi-core integrates with your popular libraries and frameworks. Each integra
1010
bottle
1111
django
1212
falcon
13+
fastapi
1314
flask
1415
pyramid
1516
requests

Diff for: openapi_core/contrib/fastapi/__init__.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from openapi_core.contrib.fastapi.middlewares import FastAPIOpenAPIMiddleware
2+
from openapi_core.contrib.fastapi.requests import FastAPIOpenAPIRequest
3+
from openapi_core.contrib.fastapi.responses import FastAPIOpenAPIResponse
4+
5+
__all__ = [
6+
"FastAPIOpenAPIMiddleware",
7+
"FastAPIOpenAPIRequest",
8+
"FastAPIOpenAPIResponse",
9+
]

Diff for: openapi_core/contrib/fastapi/middlewares.py

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from openapi_core.contrib.starlette.middlewares import (
2+
StarletteOpenAPIMiddleware as FastAPIOpenAPIMiddleware,
3+
)
4+
5+
__all__ = ["FastAPIOpenAPIMiddleware"]

Diff for: openapi_core/contrib/fastapi/requests.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from fastapi import Request
2+
3+
from openapi_core.contrib.starlette.requests import StarletteOpenAPIRequest
4+
5+
6+
class FastAPIOpenAPIRequest(StarletteOpenAPIRequest):
7+
def __init__(self, request: Request):
8+
super().__init__(request)

Diff for: openapi_core/contrib/fastapi/responses.py

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from typing import Optional
2+
3+
from fastapi import Response
4+
5+
from openapi_core.contrib.starlette.responses import StarletteOpenAPIResponse
6+
7+
8+
class FastAPIOpenAPIResponse(StarletteOpenAPIResponse):
9+
def __init__(self, response: Response, data: Optional[bytes] = None):
10+
super().__init__(response, data=data)

Diff for: poetry.lock

+33-13
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: pyproject.toml

+3
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,12 @@ jsonschema-path = "^0.3.1"
7676
jsonschema = "^4.18.0"
7777
multidict = {version = "^6.0.4", optional = true}
7878
aioitertools = {version = "^0.11.0", optional = true}
79+
fastapi = {version = "^0.108.0", optional = true}
7980

8081
[tool.poetry.extras]
8182
django = ["django"]
8283
falcon = ["falcon"]
84+
fastapi = ["fastapi"]
8385
flask = ["flask"]
8486
requests = ["requests"]
8587
aiohttp = ["aiohttp", "multidict"]
@@ -108,6 +110,7 @@ aiohttp = "^3.8.4"
108110
pytest-aiohttp = "^1.0.4"
109111
bump2version = "^1.0.1"
110112
pyflakes = "^3.1.0"
113+
fastapi = "^0.108.0"
111114

112115
[tool.poetry.group.docs.dependencies]
113116
sphinx = ">=5.3,<8.0"

Diff for: tests/integration/contrib/fastapi/data/v3.0/fastapiproject/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from fastapi import FastAPI
2+
from fastapiproject.openapi import openapi
3+
from fastapiproject.routers import pets
4+
5+
from openapi_core.contrib.fastapi.middlewares import FastAPIOpenAPIMiddleware
6+
7+
app = FastAPI()
8+
app.add_middleware(FastAPIOpenAPIMiddleware, openapi=openapi)
9+
app.include_router(pets.router)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from pathlib import Path
2+
3+
import yaml
4+
5+
from openapi_core import OpenAPI
6+
7+
openapi_spec_path = Path("tests/integration/data/v3.0/petstore.yaml")
8+
spec_dict = yaml.load(openapi_spec_path.read_text(), yaml.Loader)
9+
openapi = OpenAPI.from_dict(spec_dict)

Diff for: tests/integration/contrib/fastapi/data/v3.0/fastapiproject/routers/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
from base64 import b64decode
2+
3+
from fastapi import APIRouter
4+
from fastapi import Body
5+
from fastapi import Request
6+
from fastapi import Response
7+
from fastapi import status
8+
9+
try:
10+
from typing import Annotated
11+
except ImportError:
12+
from typing_extensions import Annotated
13+
14+
OPENID_LOGO = b64decode(
15+
"""
16+
R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d
17+
3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA
18+
AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg
19+
EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD
20+
Fzk0lpcjIQA7
21+
"""
22+
)
23+
24+
25+
router = APIRouter(
26+
prefix="/v1/pets",
27+
tags=["pets"],
28+
responses={404: {"description": "Not found"}},
29+
)
30+
31+
32+
@router.get("")
33+
async def list_pets(request: Request, response: Response):
34+
assert request.scope["openapi"]
35+
assert not request.scope["openapi"].errors
36+
assert request.scope["openapi"].parameters.query == {
37+
"page": 1,
38+
"limit": 12,
39+
"search": "",
40+
}
41+
data = [
42+
{
43+
"id": 12,
44+
"name": "Cat",
45+
"ears": {
46+
"healthy": True,
47+
},
48+
},
49+
]
50+
response.headers["X-Rate-Limit"] = "12"
51+
return {"data": data}
52+
53+
54+
@router.post("")
55+
async def create_pet(request: Request):
56+
assert request.scope["openapi"].parameters.cookie == {
57+
"user": 1,
58+
}
59+
assert request.scope["openapi"].parameters.header == {
60+
"api-key": "12345",
61+
}
62+
assert request.scope["openapi"].body.__class__.__name__ == "PetCreate"
63+
assert request.scope["openapi"].body.name in ["Cat", "Bird"]
64+
if request.scope["openapi"].body.name == "Cat":
65+
assert request.scope["openapi"].body.ears.__class__.__name__ == "Ears"
66+
assert request.scope["openapi"].body.ears.healthy is True
67+
if request.scope["openapi"].body.name == "Bird":
68+
assert (
69+
request.scope["openapi"].body.wings.__class__.__name__ == "Wings"
70+
)
71+
assert request.scope["openapi"].body.wings.healthy is True
72+
73+
headers = {
74+
"X-Rate-Limit": "12",
75+
}
76+
return Response(status_code=status.HTTP_201_CREATED, headers=headers)
77+
78+
79+
@router.get("/{petId}")
80+
async def detail_pet(request: Request, response: Response):
81+
assert request.scope["openapi"]
82+
assert not request.scope["openapi"].errors
83+
assert request.scope["openapi"].parameters.path == {
84+
"petId": 12,
85+
}
86+
data = {
87+
"id": 12,
88+
"name": "Cat",
89+
"ears": {
90+
"healthy": True,
91+
},
92+
}
93+
response.headers["X-Rate-Limit"] = "12"
94+
return {
95+
"data": data,
96+
}
97+
98+
99+
@router.get("/{petId}/photo")
100+
async def download_pet_photo():
101+
return Response(content=OPENID_LOGO, media_type="image/gif")
102+
103+
104+
@router.post("/{petId}/photo")
105+
async def upload_pet_photo(
106+
image: Annotated[bytes, Body(media_type="image/jpg")],
107+
):
108+
assert image == OPENID_LOGO
109+
return Response(status_code=status.HTTP_201_CREATED)

0 commit comments

Comments
 (0)