Skip to content

Commit 6e5c9b5

Browse files
authored
Merge pull request #12 from igormagalhaesr/client-side-cache
Client side cache
2 parents 2720c25 + 2f2fd96 commit 6e5c9b5

File tree

4 files changed

+108
-27
lines changed

4 files changed

+108
-27
lines changed

README.md

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414
- Pydantic V2 and SQLAlchemy 2.0
1515
- User authentication with JWT
1616
- Easy redis caching
17+
- Easy client-side caching
1718
- Easily extendable
1819
- Flexible
1920

2021
### 1.1 To do
2122
- [x] Redis cache
2223
- [ ] Arq job queues
23-
- [ ] App settings (such as database connection, etc) only for what's inherited in core.config.Settings
24+
- [x] App settings (such as database connection, etc) only for what's inherited in core.config.Settings
2425

2526
## 2. Contents
2627
0. [About](#0-about)
@@ -73,12 +74,12 @@ poetry install
7374
```
7475

7576
### 4.2 Environment Variables
76-
Then create a .env file:
77+
Then create a `.env` file:
7778
```sh
7879
touch .env
7980
```
8081

81-
Inside of .env, create the following app settings variables:
82+
Inside of `.env`, create the following app settings variables:
8283
```
8384
# ------------- app settings -------------
8485
APP_NAME="Your app name here"
@@ -105,7 +106,7 @@ Start by running
105106
openssl rand -hex 32
106107
```
107108

108-
And then create in .env:
109+
And then create in `.env`:
109110
```
110111
# ------------- crypt -------------
111112
SECRET_KEY= # result of openssl rand -hex 32
@@ -127,6 +128,12 @@ Optionally, for redis caching:
127128
# ------------- redis -------------
128129
REDIS_CACHE_HOST="your_host" # default localhost
129130
REDIS_CACHE_PORT=6379
131+
132+
And for client-side caching:
133+
```
134+
# ------------- redis -------------
135+
REDIS_CACHE_HOST="your_host" # default localhost
136+
REDIS_CACHE_PORT=6379
130137
```
131138
___
132139
## 5. Running Databases With Docker:
@@ -183,14 +190,14 @@ redis:alpine
183190
[`If you didn't create the .env variables yet, click here.`](#environment-variables)
184191
___
185192
## 6. Running the api
186-
While in the **src** folder, run to start the application with uvicorn server:
193+
While in the `src` folder, run to start the application with uvicorn server:
187194
```sh
188195
poetry run uvicorn app.main:app --reload
189196
```
190197

191198
___
192199
## 7. Creating the first superuser:
193-
While in the **src** folder, run (after you started the application at least once to create the tables):
200+
While in the `src` folder, run (after you started the application at least once to create the tables):
194201
```sh
195202
poetry run python -m scripts.create_first_superuser
196203
```
@@ -199,7 +206,7 @@ ___
199206
## 8. Database Migrations
200207
Migrations done via [Alembic](https://alembic.sqlalchemy.org/en/latest/):
201208

202-
Whenever you change something in the database, in the **src** directory, run to create the script:
209+
Whenever you change something in the database, in the `src` directory, run to create the script:
203210
```sh
204211
poetry run alembic revision --autogenerate
205212
```
@@ -216,7 +223,7 @@ Create the new entities and relationships and add them to the model
216223
![diagram](https://user-images.githubusercontent.com/43156212/274053323-31bbdb41-15bf-45f2-8c8e-0b04b71c5b0b.png)
217224

218225
### 9.2 SQLAlchemy Model
219-
Inside **app/models**, create a new **entity.py** for each new entity (replacing entity with the name) and define the attributes according to [SQLAlchemy 2.0 standards](https://docs.sqlalchemy.org/en/20/orm/mapping_styles.html#orm-mapping-styles):
226+
Inside `app/models`, create a new `entity.py` for each new entity (replacing entity with the name) and define the attributes according to [SQLAlchemy 2.0 standards](https://docs.sqlalchemy.org/en/20/orm/mapping_styles.html#orm-mapping-styles):
220227
```python
221228
from sqlalchemy import String, DateTime
222229
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -234,7 +241,7 @@ class Entity(Base):
234241
```
235242

236243
### 9.3 Pydantic Schemas
237-
Inside app/schemas, create a new entity.py for for each new entity (replacing entity with the name) and create the schemas according to [Pydantic V2](https://docs.pydantic.dev/latest/#pydantic-examples) standards:
244+
Inside `app/schemas`, create a new `entity.py` for for each new entity (replacing entity with the name) and create the schemas according to [Pydantic V2](https://docs.pydantic.dev/latest/#pydantic-examples) standards:
238245
```python
239246
from typing import Annotated
240247

@@ -274,7 +281,7 @@ class EntityDelete(BaseModel):
274281
```
275282

276283
### 9.4 Alembic Migration
277-
Then, while in the **src** folder, run Alembic migrations:
284+
Then, while in the `src` folder, run Alembic migrations:
278285
```sh
279286
poetry run alembic revision --autogenerate
280287
```
@@ -285,7 +292,7 @@ poetry run alembic upgrade head
285292
```
286293

287294
### 9.5 CRUD
288-
Inside **app/crud**, create a new crud_entities.py inheriting from CRUDBase for each new entity:
295+
Inside `app/crud`, create a new `crud_entities.py` inheriting from `CRUDBase` for each new entity:
289296
```python
290297
from app.crud.crud_base import CRUDBase
291298
from app.models.entity import Entity
@@ -296,7 +303,7 @@ crud_entity = CRUDEntity(Entity)
296303
```
297304

298305
### 9.6 Routes
299-
Inside **app/api/v1**, create a new entities.py file and create the desired routes
306+
Inside `app/api/v1`, create a new `entities.py` file and create the desired routes
300307
```python
301308
from typing import Annotated
302309

@@ -315,7 +322,7 @@ async def read_entities(db: Annotated[AsyncSession, Depends(async_get_db)]):
315322

316323
...
317324
```
318-
Then in **app/api/v1/__init__.py** add the router such as:
325+
Then in `app/api/v1/__init__.py` add the router such as:
319326
```python
320327
from fastapi import APIRouter
321328
from app.api.v1.entity import router as entity_router
@@ -327,10 +334,13 @@ router.include_router(entity_router)
327334
```
328335

329336
### 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.
337+
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.
338+
339+
Caching the response of an endpoint is really simple, just apply the `cache` decorator to the endpoint function.
340+
341+
> **Warning**
342+
> Note that you should always pass request as a variable to your endpoint function if you plan to use the cache decorator.
331343
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.
334344
```python
335345
...
336346
from app.core.cache import cache
@@ -347,10 +357,10 @@ async def sample_endpoint(request: Request, my_id: int):
347357
```
348358

349359
The way it works is:
350-
- the data is saved in redis with the following cache key: "sample_data:{my_id}"
360+
- the data is saved in redis with the following cache key: `sample_data:{my_id}`
351361
- then the the time to expire is set as 3600 seconds (that's the default)
352362

353-
Another option is not passing the resource_id_name, but passing the resource_id_type (default int):
363+
Another option is not passing the `resource_id_name`, but passing the `resource_id_type` (default int):
354364
```python
355365
...
356366
from app.core.cache import cache
@@ -365,8 +375,8 @@ async def sample_endpoint(request: Request, my_id: int):
365375
return {"data": "my_data"}
366376
```
367377
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}"
378+
- the `resource_id` will be inferred from the keyword arguments (`my_id` in this case)
379+
- the data is saved in redis with the following cache key: `sample_data:{my_id}`
370380
- then the the time to expire is set as 3600 seconds (that's the default)
371381

372382
Passing resource_id_name is usually preferred.
@@ -375,9 +385,9 @@ Passing resource_id_name is usually preferred.
375385
The behaviour of the `cache` decorator changes based on the request method of your endpoint.
376386
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**).
377387

378-
If you also want to invalidate cache with a different key, you can use the decorator with the "to_invalidate_extra" variable.
388+
If you also want to invalidate cache with a different key, you can use the decorator with the `to_invalidate_extra` variable.
379389

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.
390+
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.
381391

382392
```python
383393
# The cache here will be saved as "{username}_posts:{username}":
@@ -426,17 +436,20 @@ async def patch_post(
426436
...
427437
```
428438

429-
Note that this will not work for **GET** requests.
439+
> **Warning**
440+
> Note that this will not work for **GET** requests.
441+
442+
For `client-side caching`, all you have to do is let the `Settings` class defined in `app/core/config.py` inherit from the `ClientSideCacheSettings` class. You can set the `CLIENT_CACHE_MAX_AGE` value in `.env,` it defaults to 60 (seconds).
430443

431444
### 9.9 Running
432-
While in the **src** folder, run to start the application with uvicorn server:
445+
While in the `src` folder, run to start the application with uvicorn server:
433446
```sh
434447
poetry run uvicorn app.main:app --reload
435448
```
436449

437450
___
438451
## 10. Testing
439-
For tests, create in .env:
452+
For tests, create in `.env`:
440453
```
441454
# ------------- test -------------
442455
TEST_NAME="Tester User"

src/app/core/cache.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@
88
from fastapi import Request, Response
99
from redis.asyncio import Redis, ConnectionPool
1010
from sqlalchemy.orm import class_mapper, DeclarativeBase
11+
from fastapi import FastAPI
12+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
1113

1214
from app.core.exceptions import CacheIdentificationInferenceError, InvalidRequestError
1315

16+
# --------------- server side caching ---------------
17+
1418
pool: ConnectionPool | None = None
1519
client: Redis | None = None
1620

@@ -285,3 +289,59 @@ async def inner(request: Request, *args, **kwargs) -> Response:
285289
return inner
286290

287291
return wrapper
292+
293+
# --------------- client side caching ---------------
294+
295+
class ClientCacheMiddleware(BaseHTTPMiddleware):
296+
"""
297+
Middleware to set the `Cache-Control` header for client-side caching on all responses.
298+
299+
Parameters
300+
----------
301+
app: FastAPI
302+
The FastAPI application instance.
303+
max_age: int, optional
304+
Duration (in seconds) for which the response should be cached. Defaults to 60 seconds.
305+
306+
Attributes
307+
----------
308+
max_age: int
309+
Duration (in seconds) for which the response should be cached.
310+
311+
Methods
312+
-------
313+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
314+
Process the request and set the `Cache-Control` header in the response.
315+
316+
Note
317+
----
318+
- The `Cache-Control` header instructs clients (e.g., browsers) to cache the response for the specified duration.
319+
"""
320+
321+
def __init__(self, app: FastAPI, max_age: int = 60) -> None:
322+
super().__init__(app)
323+
self.max_age = max_age
324+
325+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
326+
"""
327+
Process the request and set the `Cache-Control` header in the response.
328+
329+
Parameters
330+
----------
331+
request: Request
332+
The incoming request.
333+
call_next: RequestResponseEndpoint
334+
The next middleware or route handler in the processing chain.
335+
336+
Returns
337+
-------
338+
Response
339+
The response object with the `Cache-Control` header set.
340+
341+
Note
342+
----
343+
- This method is automatically called by Starlette for processing the request-response cycle.
344+
"""
345+
response: Response = await call_next(request)
346+
response.headers['Cache-Control'] = f"public, max-age={self.max_age}"
347+
return response

src/app/core/config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,18 @@ class RedisCacheSettings(BaseSettings):
7272
REDIS_CACHE_URL: str = f"redis://{REDIS_CACHE_HOST}:{REDIS_CACHE_PORT}"
7373

7474

75+
class ClientSideCacheSettings(BaseSettings):
76+
CLIENT_CACHE_MAX_AGE: int = config("CLIENT_CACHE_MAX_AGE", default=60)
77+
78+
7579
class Settings(
7680
AppSettings,
7781
PostgresSettings,
7882
CryptSettings,
7983
FirstUserSettings,
8084
TestSettings,
81-
RedisCacheSettings
85+
RedisCacheSettings,
86+
ClientSideCacheSettings
8287
):
8388
pass
8489

src/app/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from app.core.database import Base
55
from app.core.database import async_engine as engine
6-
from app.core.config import settings, DatabaseSettings, RedisCacheSettings, AppSettings
6+
from app.core.config import settings, DatabaseSettings, RedisCacheSettings, AppSettings, ClientSideCacheSettings
77
from app.api import router
88
from app.core import cache
99

@@ -44,6 +44,9 @@ def create_application() -> FastAPI:
4444
application.add_event_handler("startup", create_redis_cache_pool)
4545
application.add_event_handler("shutdown", close_redis_cache_pool)
4646

47+
if isinstance(settings, ClientSideCacheSettings):
48+
application.add_middleware(cache.ClientCacheMiddleware, max_age=60)
49+
4750
return application
4851

4952

0 commit comments

Comments
 (0)