Skip to content

Commit 397eae7

Browse files
author
David Montague
committed
Add docs for CBV
1 parent 67470d4 commit 397eae7

File tree

13 files changed

+299
-26
lines changed

13 files changed

+299
-26
lines changed

Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
pkg_src = fastapi_utils
44
tests_src = tests
5+
docs_src = docs/src
56
all_src = $(pkg_src) $(tests_src)
67

78
isort = isort -rc $(all_src)
@@ -93,6 +94,12 @@ docs-build:
9394
cp ./docs/index.md ./README.md
9495
cp ./docs/contributing.md ./CONTRIBUTING.md
9596

97+
.PHONY: docs-format ## Format the python code that is part of the docs
98+
docs-format:
99+
isort -rc docs/src
100+
autoflake -r --remove-all-unused-imports --ignore-init-module-imports docs/src
101+
black -l 82 docs/src
102+
96103

97104
.PHONY: docs-live ## Serve the docs with live reload as you make changes
98105
docs-live:

docs/src/api_model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ class UserORM:
1919
"""
2020
You can pretend this class is a SQLAlchemy model
2121
"""
22+
2223
user_id: UserID
2324
email_address: str
2425

docs/src/class_based_views1.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from typing import NewType, Optional
2+
from uuid import UUID
3+
4+
import sqlalchemy as sa
5+
from fastapi import Depends, FastAPI, Header, HTTPException
6+
from sqlalchemy.ext.declarative import declarative_base
7+
from sqlalchemy.orm import Session
8+
from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
9+
10+
from fastapi_utils.api_model import APIMessage, APIModel
11+
from fastapi_utils.guid_type import GUID
12+
13+
# Begin setup
14+
UserID = NewType("UserID", UUID)
15+
ItemID = NewType("ItemID", UUID)
16+
17+
Base = declarative_base()
18+
19+
20+
class ItemORM(Base):
21+
__tablename__ = "item"
22+
23+
item_id = sa.Column(GUID, primary_key=True)
24+
owner = sa.Column(GUID, nullable=False)
25+
name = sa.Column(sa.String, nullable=False)
26+
27+
28+
class ItemCreate(APIModel):
29+
name: str
30+
31+
32+
class ItemInDB(ItemCreate):
33+
item_id: ItemID
34+
owner: UserID
35+
36+
37+
def get_jwt_user(authorization: str = Header(...)) -> UserID:
38+
""" Pretend this function gets a UserID from a JWT in the auth header """
39+
40+
41+
def get_db() -> Session:
42+
""" Pretend this function returns a SQLAlchemy ORM session"""
43+
44+
45+
def get_owned_item(session: Session, owner: UserID, item_id: ItemID) -> ItemORM:
46+
item: Optional[ItemORM] = session.query(ItemORM).get(item_id)
47+
if item is not None and item.owner != owner:
48+
raise HTTPException(status_code=HTTP_403_FORBIDDEN)
49+
if item is None:
50+
raise HTTPException(status_code=HTTP_404_NOT_FOUND)
51+
return item
52+
53+
54+
# End setup
55+
app = FastAPI()
56+
57+
58+
@app.post("/item", response_model=ItemInDB)
59+
def create_item(
60+
*,
61+
session: Session = Depends(get_db),
62+
user_id: UserID = Depends(get_jwt_user),
63+
item: ItemCreate,
64+
) -> ItemInDB:
65+
item_orm = ItemORM(name=item.name, owner=user_id)
66+
session.add(item_orm)
67+
session.commit()
68+
return ItemInDB.from_orm(item_orm)
69+
70+
71+
@app.get("/item/{item_id}", response_model=ItemInDB)
72+
def read_item(
73+
*,
74+
session: Session = Depends(get_db),
75+
user_id: UserID = Depends(get_jwt_user),
76+
item_id: ItemID,
77+
) -> ItemInDB:
78+
item_orm = get_owned_item(session, user_id, item_id)
79+
return ItemInDB.from_orm(item_orm)
80+
81+
82+
@app.put("/item/{item_id}", response_model=ItemInDB)
83+
def update_item(
84+
*,
85+
session: Session = Depends(get_db),
86+
user_id: UserID = Depends(get_jwt_user),
87+
item_id: ItemID,
88+
item: ItemCreate,
89+
) -> ItemInDB:
90+
item_orm = get_owned_item(session, user_id, item_id)
91+
item_orm.name = item.name
92+
session.add(item_orm)
93+
session.commit()
94+
return ItemInDB.from_orm(item_orm)
95+
96+
97+
@app.delete("/item/{item_id}", response_model=APIMessage)
98+
def delete_item(
99+
*,
100+
session: Session = Depends(get_db),
101+
user_id: UserID = Depends(get_jwt_user),
102+
item_id: ItemID,
103+
) -> APIMessage:
104+
item = get_owned_item(session, user_id, item_id)
105+
session.delete(item)
106+
session.commit()
107+
return APIMessage(detail=f"Deleted item {item_id}")

docs/src/class_based_views2.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from typing import NewType, Optional
2+
from uuid import UUID
3+
4+
import sqlalchemy as sa
5+
from fastapi import Depends, FastAPI, Header, HTTPException
6+
from sqlalchemy.ext.declarative import declarative_base
7+
from sqlalchemy.orm import Session
8+
from starlette.status import HTTP_403_FORBIDDEN, HTTP_404_NOT_FOUND
9+
10+
from fastapi_utils.api_model import APIMessage, APIModel
11+
from fastapi_utils.cbv import cbv
12+
from fastapi_utils.guid_type import GUID
13+
14+
# Begin Setup
15+
from fastapi_utils.inferring_router import InferringRouter
16+
17+
UserID = NewType("UserID", UUID)
18+
ItemID = NewType("ItemID", UUID)
19+
20+
Base = declarative_base()
21+
22+
23+
class ItemORM(Base):
24+
__tablename__ = "item"
25+
26+
item_id = sa.Column(GUID, primary_key=True)
27+
owner = sa.Column(GUID, nullable=False)
28+
name = sa.Column(sa.String, nullable=False)
29+
30+
31+
class ItemCreate(APIModel):
32+
name: str
33+
owner: UserID
34+
35+
36+
class ItemInDB(ItemCreate):
37+
item_id: ItemID
38+
39+
40+
def get_jwt_user(authorization: str = Header(...)) -> UserID:
41+
""" Pretend this function gets a UserID from a JWT in the auth header """
42+
43+
44+
def get_db() -> Session:
45+
""" Pretend this function returns a SQLAlchemy ORM session"""
46+
47+
48+
def get_owned_item(session: Session, owner: UserID, item_id: ItemID) -> ItemORM:
49+
item: Optional[ItemORM] = session.query(ItemORM).get(item_id)
50+
if item is not None and item.owner != owner:
51+
raise HTTPException(status_code=HTTP_403_FORBIDDEN)
52+
if item is None:
53+
raise HTTPException(status_code=HTTP_404_NOT_FOUND)
54+
return item
55+
56+
57+
# End Setup
58+
app = FastAPI()
59+
router = InferringRouter() # Step 1: Create a router
60+
61+
62+
@cbv(router) # Step 2: Create and decorate a class to hold the endpoints
63+
class ItemCBV:
64+
# Step 3: Add dependencies as class attributes
65+
session: Session = Depends(get_db)
66+
user_id: UserID = Depends(get_jwt_user)
67+
68+
@router.post("/item")
69+
def create_item(self, item: ItemCreate) -> ItemInDB:
70+
# Step 4: Use `self.<dependency_name>` to access shared dependencies
71+
item_orm = ItemORM(name=item.name, owner=self.user_id)
72+
self.session.add(item_orm)
73+
self.session.commit()
74+
return ItemInDB.from_orm(item_orm)
75+
76+
@router.get("/item/{item_id}")
77+
def read_item(self, item_id: ItemID) -> ItemInDB:
78+
item_orm = get_owned_item(self.session, self.user_id, item_id)
79+
return ItemInDB.from_orm(item_orm)
80+
81+
@router.put("/item/{item_id}")
82+
def update_item(self, item_id: ItemID, item: ItemCreate) -> ItemInDB:
83+
item_orm = get_owned_item(self.session, self.user_id, item_id)
84+
item_orm.name = item.name
85+
self.session.add(item_orm)
86+
self.session.commit()
87+
return ItemInDB.from_orm(item_orm)
88+
89+
@router.delete("/item/{item_id}")
90+
def delete_item(self, item_id: ItemID) -> APIMessage:
91+
item = get_owned_item(self.session, self.user_id, item_id)
92+
self.session.delete(item)
93+
self.session.commit()
94+
return APIMessage(detail=f"Deleted item {item_id}")
95+
96+
97+
app.include_router(router)

docs/src/inferring_router1.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,16 @@
33
app = FastAPI()
44

55

6-
@app.get("/resource")
6+
@app.get("/default")
77
def get_resource(resource_id: int) -> str:
88
# the response will be serialized as a JSON number, *not* a string
99
return resource_id
10+
11+
12+
def get_response_schema(openapi_spec, endpoint_path):
13+
responses = openapi_spec["paths"][endpoint_path]["get"]["responses"]
14+
return responses["200"]["content"]["application/json"]["schema"]
15+
16+
17+
openapi_spec = app.openapi()
18+
assert get_response_schema(openapi_spec, "/default") == {}

docs/src/inferring_router2.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
app = FastAPI()
66

77

8-
@app.get("/resource")
8+
@app.get("/default")
99
def get_resource(resource_id: int) -> str:
1010
# the response will be serialized as a JSON number, *not* a string
1111
return resource_id
@@ -14,20 +14,20 @@ def get_resource(resource_id: int) -> str:
1414
router = InferringRouter()
1515

1616

17-
@router.get("/inferred-resource")
17+
@router.get("/inferred")
1818
def get_resource(resource_id: int) -> str:
19-
# thanks to InferringRouter, the response *will* be serialized as a JSON string
19+
# thanks to InferringRouter, the response will be serialized as a string
2020
return resource_id
2121

2222

2323
app.include_router(router)
24-
openapi_paths = app.openapi()["paths"]
25-
resource_response_schema = (
26-
openapi_paths["/resource"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
27-
)
28-
assert resource_response_schema == {}
29-
30-
inferred_resource_response_schema = (
31-
openapi_paths["/inferred-resource"]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
32-
)
33-
assert inferred_resource_response_schema["type"] == "string"
24+
25+
26+
def get_response_schema(openapi_spec, endpoint_path):
27+
responses = openapi_spec["paths"][endpoint_path]["get"]["responses"]
28+
return responses["200"]["content"]["application/json"]["schema"]
29+
30+
31+
openapi_spec = app.openapi()
32+
assert get_response_schema(openapi_spec, "/default") == {}
33+
assert get_response_schema(openapi_spec, "/inferred")["type"] == "string"

docs/src/openapi1.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ def get_resource(resource_id: int) -> int:
88
return resource_id
99

1010

11-
operation_id = app.openapi()["paths"]["/api/v1/resource/{resource_id}"]["get"]["operationId"]
11+
path_spec = app.openapi()["paths"]["/api/v1/resource/{resource_id}"]
12+
operation_id = path_spec["get"]["operationId"]
1213
assert operation_id == "get_resource_api_v1_resource__resource_id__get"

docs/src/openapi2.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ def get_resource(resource_id: int) -> int:
1212

1313
simplify_operation_ids(app)
1414

15-
operation_id = app.openapi()["paths"]["/api/v1/resource/{resource_id}"]["get"]["operationId"]
15+
path_spec = app.openapi()["paths"]["/api/v1/resource/{resource_id}"]
16+
operation_id = path_spec["get"]["operationId"]
1617
assert operation_id == "get_resource"

docs/user-guide/basics/api-model.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,6 @@ In addition, if you set the `response_model` argument to the endpoint decorator
5454
be converted to a dict, but has appropriately named fields, FastAPI will use pydantic's `orm_mode` to automatically
5555
serialize it.
5656

57-
```python hl_lines="29 30 31"
57+
```python hl_lines="30 32"
5858
{!./src/api_model.py!}
5959
```
Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,50 @@
1-
Coming soon!
1+
As you create more complex FastAPI applications, you may find yourself
2+
frequently repeating the same dependencies in multiple related endpoints.
3+
4+
A common question people have as they become more comfortable with FastAPI
5+
is how they can reduce the number of times they have to copy/paste the same dependency
6+
into related routes.
7+
8+
`fastapi_utils` provides a "class-based view" decorator (`@cbv`) to help reduce the amount of boilerplate
9+
necessary when developing related routes.
10+
11+
## A basic CRUD app
12+
13+
Consider a basic create-read-update-delete (CRUD) app where users can create "Item" instances,
14+
but only the user that created an item is allowed to view or modify it:
15+
16+
```python hl_lines="61 62 74 75 85 86 100 101"
17+
{!./src/class_based_views1.py!}
18+
```
19+
20+
If you look at the highlighted lines above, you can see `get_db`
21+
and `get_jwt_user` repeated in each endpoint.
22+
23+
24+
## The `@cbv` decorator
25+
26+
By using the `fastapi_utils.cbv.cbv` decorator, we can consolidate the
27+
endpoint signatures and reduce the number of repeated dependencies.
28+
29+
To use the `@cbv` decorator, you need to:
30+
31+
1. Create an APIRouter to which you will add the endpoints
32+
2. Create a class whose methods will be endpoints with shared depedencies, and decorate it with `@cbv(router)`
33+
3. For each shared dependency, add a class attribute with a value of type `Depends`
34+
4. Replace use of the original "unshared" dependencies with accesses like `self.dependency`
35+
36+
Let's follow these steps to simplify the example above, while preserving all of the original logic:
37+
38+
```python hl_lines="59 62 64 65 66 70 71 72"
39+
{!./src/class_based_views2.py!}
40+
```
41+
42+
The highlighted lines above show the results of performing each of the numbered steps.
43+
44+
Note how the signature of each endpoint definition now includes only the parts specific
45+
to that endpoint.
46+
47+
(Also note that we've also used the [`InferringRouter`](inferring-router.md){.internal-link target=_blank}
48+
here to remove the need to specify a `response_model` in the endpoint decorators.)
49+
50+
Hopefully this helps you to better reuse dependencies across endpoints!

0 commit comments

Comments
 (0)