Skip to content

Commit a8b965d

Browse files
committed
cache decorator now fully working, documentation updated
1 parent c320fd8 commit a8b965d

File tree

3 files changed

+157
-7
lines changed

3 files changed

+157
-7
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/core/cache.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from redis.asyncio import Redis, ConnectionPool
1010
from sqlalchemy.orm import class_mapper, DeclarativeBase
1111

12-
from app.core.exceptions import CacheIdentificationInferenceError
12+
from app.core.exceptions import CacheIdentificationInferenceError, InvalidRequestError
1313

1414
pool: ConnectionPool | None = None
1515
client: Redis | None = None
@@ -209,13 +209,19 @@ async def inner(request: Request, *args, **kwargs) -> Response:
209209
cache_key = f"{formatted_key_prefix}:{resource_id}"
210210

211211
if request.method == "GET":
212+
if to_invalidate_extra:
213+
raise InvalidRequestError
214+
212215
cached_data = await client.get(cache_key)
213216
if cached_data:
214217
return json.loads(cached_data.decode())
215218

216219
result = await func(request, *args, **kwargs)
217220

218221
if request.method == "GET":
222+
if to_invalidate_extra:
223+
raise InvalidRequestError
224+
219225
if isinstance(result, list):
220226
serialized_data = json.dumps(
221227
[_serialize_sqlalchemy_object(obj) for obj in result]

src/app/core/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,9 @@ class CacheIdentificationInferenceError(Exception):
22
def __init__(self, message="Could not infer id for resource being cached."):
33
self.message = message
44
super().__init__(self.message)
5+
6+
7+
class InvalidRequestError(Exception):
8+
def __init__(self, message="Type of request not supported."):
9+
self.message = message
10+
super().__init__(self.message)

0 commit comments

Comments
 (0)