Skip to content

Commit 3cf5b4f

Browse files
authored
Merge pull request #9 from igormagalhaesr/redis-cache
Redis cache
2 parents e261392 + 644d4fb commit 3cf5b4f

File tree

11 files changed

+693
-30
lines changed

11 files changed

+693
-30
lines changed

README.md

Lines changed: 144 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,20 @@
77
- [`Pydantic V2`](https://docs.pydantic.dev/2.4/): the most widely used data validation library for Python, now rewritten in Rust [`(5x to 50x speed improvement)`](https://docs.pydantic.dev/latest/blog/pydantic-v2-alpha/)
88
- [`SQLAlchemy 2.0`](https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html): Python SQL toolkit and Object Relational Mapper
99
- [`PostgreSQL`](https://www.postgresql.org): The World's Most Advanced Open Source Relational Database
10+
- [`Redis`](https://redis.io): The open source, in-memory data store used by millions of developers as a database, cache, streaming engine, and message broker.
1011

1112
## 1. Features
1213
- Fully async
1314
- Pydantic V2 and SQLAlchemy 2.0
1415
- User authentication with JWT
16+
- Easy redis caching
1517
- Easily extendable
1618
- Flexible
1719

1820
### 1.1 To do
19-
- [ ] Redis cache
20-
- [ ] Google SSO
21+
- [x] Redis cache
2122
- [ ] Arq job queues
23+
- [ ] App settings (such as database connection, etc) only for what's inherited in core.config.Settings
2224

2325
## 2. Contents
2426
0. [About](#0-about)
@@ -29,7 +31,9 @@
2931
4. [Requirements](#4-requirements)
3032
1. [Packages](#41-packages)
3133
2. [Environment Variables](#42-environment-variables)
32-
5. [Running PostgreSQL with docker](#5-running-postgresql-with-docker)
34+
5. [Running Databases With Docker](#5-running-databases-with-docker)
35+
1. [PostgreSQL](#51-postgresql-main-database)
36+
2. [Redis](#52-redis-for-caching)
3337
6. [Running the api](#6-running-the-api)
3438
7. [Creating the first superuser](#7-creating-the-first-superuser)
3539
8. [Database Migrations](#8-database-migrations)
@@ -40,7 +44,9 @@
4044
4. [Alembic Migrations](#94-alembic-migration)
4145
5. [CRUD](#95-crud)
4246
6. [Routes](#96-routes)
43-
7. [Running](#97-running)
47+
7. [Caching](#97-caching)
48+
8. [More Advanced Caching](#98-more-advanced-caching)
49+
9. [Running](#99-running)
4450
10. [Testing](#10-testing)
4551
11. [Contributing](#11-contributing)
4652
12. [References](#12-references)
@@ -116,8 +122,15 @@ ADMIN_USERNAME="your_username"
116122
ADMIN_PASSWORD="your_password"
117123
```
118124

125+
Optionally, for redis caching:
126+
```
127+
# ------------- redis -------------
128+
REDIS_CACHE_HOST="your_host" # default localhost
129+
REDIS_CACHE_PORT=6379
130+
```
119131
___
120-
## 5. Running PostgreSQL with docker:
132+
## 5. Running Databases With Docker:
133+
### 5.1 PostgreSQL (main database)
121134
Install docker if you don't have it yet, then run:
122135
```sh
123136
docker pull postgres
@@ -145,6 +158,29 @@ docker run -d \
145158

146159
[`If you didn't create the .env variables yet, click here.`](#environment-variables)
147160

161+
### 5.2 Redis (for caching)
162+
Install docker if you don't have it yet, then run:
163+
```sh
164+
docker pull redis:alpine
165+
```
166+
167+
And pick the name and port, replacing the fields:
168+
```sh
169+
docker run -d \
170+
--name {NAME} \
171+
-p {PORT}:{PORT} \
172+
redis:alpine
173+
```
174+
175+
Such as
176+
```sh
177+
docker run -d \
178+
--name redis \
179+
-p 6379:6379 \
180+
redis:alpine
181+
```
182+
183+
[`If you didn't create the .env variables yet, click here.`](#environment-variables)
148184
___
149185
## 6. Running the api
150186
While in the **src** folder, run to start the application with uvicorn server:
@@ -290,7 +326,109 @@ router = APIRouter(prefix="/v1") # this should be there already
290326
router.include_router(entity_router)
291327
```
292328

293-
### 9.7 Running
329+
### 9.7 Caching
330+
The cache decorator allows you to cache the results of FastAPI endpoint functions, enhancing response times and reducing the load on your application by storing and retrieving data in a cache.
331+
332+
Caching the response of an endpoint is really simple, just apply the cache decorator to the endpoint function.
333+
Note that you should always pass request as a variable to your endpoint function.
334+
```python
335+
...
336+
from app.core.cache import cache
337+
338+
@app.get("/sample/{my_id}")
339+
@cache(
340+
key_prefix="sample_data",
341+
expiration=3600,
342+
resource_id_name="my_id"
343+
)
344+
async def sample_endpoint(request: Request, my_id: int):
345+
# Endpoint logic here
346+
return {"data": "my_data"}
347+
```
348+
349+
The way it works is:
350+
- the data is saved in redis with the following cache key: "sample_data:{my_id}"
351+
- then the the time to expire is set as 3600 seconds (that's the default)
352+
353+
Another option is not passing the resource_id_name, but passing the resource_id_type (default int):
354+
```python
355+
...
356+
from app.core.cache import cache
357+
358+
@app.get("/sample/{my_id}")
359+
@cache(
360+
key_prefix="sample_data",
361+
resource_id_type=int
362+
)
363+
async def sample_endpoint(request: Request, my_id: int):
364+
# Endpoint logic here
365+
return {"data": "my_data"}
366+
```
367+
In this case, what will happen is:
368+
- the resource_id will be inferred from the keyword arguments (my_id in this case)
369+
- the data is saved in redis with the following cache key: "sample_data:{my_id}"
370+
- then the the time to expire is set as 3600 seconds (that's the default)
371+
372+
Passing resource_id_name is usually preferred.
373+
374+
### 9.8 More Advanced Caching
375+
The behaviour of the `cache` decorator changes based on the request method of your endpoint.
376+
It caches the result if you are passing it to a **GET** endpoint, and it invalidates the cache with this key_prefix and id if passed to other endpoints (**PATCH**, **DELETE**).
377+
378+
If you also want to invalidate cache with a different key, you can use the decorator with the "to_invalidate_extra" variable.
379+
380+
In the following example, I want to invalidate the cache for a certain user_id, since I'm deleting it, but I also want to invalidate the cache for the list of users, so it will not be out of sync.
381+
382+
```python
383+
# The cache here will be saved as "{username}_posts:{username}":
384+
@router.get("/{username}/posts", response_model=List[PostRead])
385+
@cache(key_prefix="{username}_posts", resource_id_name="username")
386+
async def read_posts(
387+
request: Request,
388+
username: str,
389+
db: Annotated[AsyncSession, Depends(async_get_db)]
390+
):
391+
...
392+
393+
...
394+
395+
# I'll invalidate the cache for the former endpoint by just passing the key_prefix and id as a dictionary:
396+
@router.delete("/{username}/post/{id}")
397+
@cache(
398+
"{username}_post_cache",
399+
resource_id_name="id",
400+
to_invalidate_extra={"{username}_posts": "{username}"} # Now it will also invalidate the cache with id "{username}_posts:{username}"
401+
)
402+
async def erase_post(
403+
request: Request,
404+
username: str,
405+
id: int,
406+
current_user: Annotated[UserRead, Depends(get_current_user)],
407+
db: Annotated[AsyncSession, Depends(async_get_db)]
408+
):
409+
...
410+
411+
# And now I'll also invalidate when I update the user:
412+
@router.patch("/{username}/post/{id}", response_model=PostRead)
413+
@cache(
414+
"{username}_post_cache",
415+
resource_id_name="id",
416+
to_invalidate_extra={"{username}_posts": "{username}"}
417+
)
418+
async def patch_post(
419+
request: Request,
420+
username: str,
421+
id: int,
422+
values: PostUpdate,
423+
current_user: Annotated[UserRead, Depends(get_current_user)],
424+
db: Annotated[AsyncSession, Depends(async_get_db)]
425+
):
426+
...
427+
```
428+
429+
Note that this will not work for **GET** requests.
430+
431+
### 9.9 Running
294432
While in the **src** folder, run to start the application with uvicorn server:
295433
```sh
296434
poetry run uvicorn app.main:app --reload

src/app/api/v1/login.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
@router.post("/login", response_model=Token)
1717
async def login_for_access_token(
1818
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
19-
db: AsyncSession = Depends(async_get_db)
19+
db: Annotated[AsyncSession, Depends(async_get_db)]
2020
):
2121

2222
user = await authenticate_user(

src/app/api/v1/posts.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import List, Annotated
22

3-
from fastapi import Depends, HTTPException
3+
from fastapi import Request, Depends, HTTPException
44
from sqlalchemy.ext.asyncio import AsyncSession
55
import fastapi
66

@@ -11,11 +11,13 @@
1111
from app.crud.crud_posts import crud_posts
1212
from app.crud.crud_users import crud_users
1313
from app.api.exceptions import privileges_exception
14+
from app.core.cache import cache
1415

1516
router = fastapi.APIRouter(tags=["posts"])
1617

1718
@router.post("/{username}/post", response_model=PostRead, status_code=201)
1819
async def write_post(
20+
request: Request,
1921
username: str,
2022
post: PostCreate,
2123
current_user: Annotated[UserRead, Depends(get_current_user)],
@@ -35,7 +37,9 @@ async def write_post(
3537

3638

3739
@router.get("/{username}/posts", response_model=List[PostRead])
40+
@cache(key_prefix="{username}_posts", resource_id_name="username")
3841
async def read_posts(
42+
request: Request,
3943
username: str,
4044
db: Annotated[AsyncSession, Depends(async_get_db)]
4145
):
@@ -48,7 +52,9 @@ async def read_posts(
4852

4953

5054
@router.get("/{username}/post/{id}", response_model=PostRead)
55+
@cache(key_prefix="{username}_post_cache", resource_id_name="id")
5156
async def read_post(
57+
request: Request,
5258
username: str,
5359
id: int,
5460
db: Annotated[AsyncSession, Depends(async_get_db)]
@@ -65,7 +71,13 @@ async def read_post(
6571

6672

6773
@router.patch("/{username}/post/{id}", response_model=PostRead)
74+
@cache(
75+
"{username}_post_cache",
76+
resource_id_name="id",
77+
to_invalidate_extra={"{username}_posts": "{username}"}
78+
)
6879
async def patch_post(
80+
request: Request,
6981
username: str,
7082
id: int,
7183
values: PostUpdate,
@@ -87,7 +99,13 @@ async def patch_post(
8799

88100

89101
@router.delete("/{username}/post/{id}")
102+
@cache(
103+
"{username}_post_cache",
104+
resource_id_name="id",
105+
to_invalidate_extra={"{username}_posts": "{username}"}
106+
)
90107
async def erase_post(
108+
request: Request,
91109
username: str,
92110
id: int,
93111
current_user: Annotated[UserRead, Depends(get_current_user)],
@@ -114,7 +132,13 @@ async def erase_post(
114132

115133

116134
@router.delete("/{username}/db_post/{id}", dependencies=[Depends(get_current_superuser)])
135+
@cache(
136+
"{username}_post_cache",
137+
resource_id_name="id",
138+
to_invalidate_extra={"{username}_posts": "{username}"}
139+
)
117140
async def erase_db_post(
141+
request: Request,
118142
username: str,
119143
id: int,
120144
db: Annotated[AsyncSession, Depends(async_get_db)]

src/app/api/v1/users.py

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

33
from fastapi import Depends, HTTPException
44
from sqlalchemy.ext.asyncio import AsyncSession
5+
from fastapi import Request
56
import fastapi
67

78
from app.schemas.user import UserCreate, UserCreateInternal, UserUpdate, UserRead, UserBase
@@ -14,7 +15,7 @@
1415
router = fastapi.APIRouter(tags=["users"])
1516

1617
@router.post("/user", response_model=UserRead, status_code=201)
17-
async def write_user(user: UserCreate, db: AsyncSession = Depends(async_get_db)):
18+
async def write_user(request: Request, user: UserCreate, db: Annotated[AsyncSession, Depends(async_get_db)]):
1819
db_user = await crud_users.get(db=db, email=user.email)
1920
if db_user:
2021
raise HTTPException(status_code=400, detail="Email is already registered")
@@ -32,33 +33,36 @@ async def write_user(user: UserCreate, db: AsyncSession = Depends(async_get_db))
3233

3334

3435
@router.get("/users", response_model=List[UserRead])
35-
async def read_users(db: AsyncSession = Depends(async_get_db)):
36+
async def read_users(request: Request, db: Annotated[AsyncSession, Depends(async_get_db)]):
3637
users = await crud_users.get_multi(db=db, is_deleted=False)
3738
return users
3839

3940

4041
@router.get("/user/me/", response_model=UserRead)
4142
async def read_users_me(
42-
current_user: Annotated[UserRead, Depends(get_current_user)]
43+
request: Request, current_user: Annotated[UserRead, Depends(get_current_user)]
4344
):
4445
return current_user
4546

47+
from app.core.cache import cache
48+
4649

4750
@router.get("/user/{username}", response_model=UserRead)
48-
async def read_user(username: str, db: AsyncSession = Depends(async_get_db)):
51+
async def read_user(request: Request, username: str, db: Annotated[AsyncSession, Depends(async_get_db)]):
4952
db_user = await crud_users.get(db=db, username=username, is_deleted=False)
5053
if db_user is None:
5154
raise HTTPException(status_code=404, detail="User not found")
52-
55+
5356
return db_user
5457

5558

5659
@router.patch("/user/{username}", response_model=UserRead)
5760
async def patch_user(
61+
request: Request,
5862
values: UserUpdate,
5963
username: str,
6064
current_user: Annotated[UserRead, Depends(get_current_user)],
61-
db: AsyncSession = Depends(async_get_db)
65+
db: Annotated[AsyncSession, Depends(async_get_db)]
6266
):
6367
db_user = await crud_users.get(db=db, username=username)
6468
if db_user is None:
@@ -83,9 +87,10 @@ async def patch_user(
8387

8488
@router.delete("/user/{username}")
8589
async def erase_user(
90+
request: Request,
8691
username: str,
8792
current_user: Annotated[UserRead, Depends(get_current_user)],
88-
db: AsyncSession = Depends(async_get_db)
93+
db: Annotated[AsyncSession, Depends(async_get_db)]
8994
):
9095
db_user = await crud_users.get(db=db, username=username)
9196
if db_user is None:
@@ -100,8 +105,9 @@ async def erase_user(
100105

101106
@router.delete("/db_user/{username}", dependencies=[Depends(get_current_superuser)])
102107
async def erase_db_user(
108+
request: Request,
103109
username: str,
104-
db: AsyncSession = Depends(async_get_db)
110+
db: Annotated[AsyncSession, Depends(async_get_db)]
105111
):
106112
db_user = await crud_users.get(db=db, username=username)
107113
if db_user is None:

0 commit comments

Comments
 (0)