Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 144 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@
- [`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/)
- [`SQLAlchemy 2.0`](https://docs.sqlalchemy.org/en/20/changelog/whatsnew_20.html): Python SQL toolkit and Object Relational Mapper
- [`PostgreSQL`](https://www.postgresql.org): The World's Most Advanced Open Source Relational Database
- [`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.

## 1. Features
- Fully async
- Pydantic V2 and SQLAlchemy 2.0
- User authentication with JWT
- Easy redis caching
- Easily extendable
- Flexible

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

## 2. Contents
0. [About](#0-about)
Expand All @@ -29,7 +31,9 @@
4. [Requirements](#4-requirements)
1. [Packages](#41-packages)
2. [Environment Variables](#42-environment-variables)
5. [Running PostgreSQL with docker](#5-running-postgresql-with-docker)
5. [Running Databases With Docker](#5-running-databases-with-docker)
1. [PostgreSQL](#51-postgresql-main-database)
2. [Redis](#52-redis-for-caching)
6. [Running the api](#6-running-the-api)
7. [Creating the first superuser](#7-creating-the-first-superuser)
8. [Database Migrations](#8-database-migrations)
Expand All @@ -40,7 +44,9 @@
4. [Alembic Migrations](#94-alembic-migration)
5. [CRUD](#95-crud)
6. [Routes](#96-routes)
7. [Running](#97-running)
7. [Caching](#97-caching)
8. [More Advanced Caching](#98-more-advanced-caching)
9. [Running](#99-running)
10. [Testing](#10-testing)
11. [Contributing](#11-contributing)
12. [References](#12-references)
Expand Down Expand Up @@ -116,8 +122,15 @@ ADMIN_USERNAME="your_username"
ADMIN_PASSWORD="your_password"
```

Optionally, for redis caching:
```
# ------------- redis -------------
REDIS_CACHE_HOST="your_host" # default localhost
REDIS_CACHE_PORT=6379
```
___
## 5. Running PostgreSQL with docker:
## 5. Running Databases With Docker:
### 5.1 PostgreSQL (main database)
Install docker if you don't have it yet, then run:
```sh
docker pull postgres
Expand Down Expand Up @@ -145,6 +158,29 @@ docker run -d \

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

### 5.2 Redis (for caching)
Install docker if you don't have it yet, then run:
```sh
docker pull redis:alpine
```

And pick the name and port, replacing the fields:
```sh
docker run -d \
--name {NAME} \
-p {PORT}:{PORT} \
redis:alpine
```

Such as
```sh
docker run -d \
--name redis \
-p 6379:6379 \
redis:alpine
```

[`If you didn't create the .env variables yet, click here.`](#environment-variables)
___
## 6. Running the api
While in the **src** folder, run to start the application with uvicorn server:
Expand Down Expand Up @@ -290,7 +326,109 @@ router = APIRouter(prefix="/v1") # this should be there already
router.include_router(entity_router)
```

### 9.7 Running
### 9.7 Caching
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.

Caching the response of an endpoint is really simple, just apply the cache decorator to the endpoint function.
Note that you should always pass request as a variable to your endpoint function.
```python
...
from app.core.cache import cache

@app.get("/sample/{my_id}")
@cache(
key_prefix="sample_data",
expiration=3600,
resource_id_name="my_id"
)
async def sample_endpoint(request: Request, my_id: int):
# Endpoint logic here
return {"data": "my_data"}
```

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

Another option is not passing the resource_id_name, but passing the resource_id_type (default int):
```python
...
from app.core.cache import cache

@app.get("/sample/{my_id}")
@cache(
key_prefix="sample_data",
resource_id_type=int
)
async def sample_endpoint(request: Request, my_id: int):
# Endpoint logic here
return {"data": "my_data"}
```
In this case, what will happen is:
- the resource_id will be inferred from the keyword arguments (my_id in this case)
- the data is saved in redis with the following cache key: "sample_data:{my_id}"
- then the the time to expire is set as 3600 seconds (that's the default)

Passing resource_id_name is usually preferred.

### 9.8 More Advanced Caching
The behaviour of the `cache` decorator changes based on the request method of your endpoint.
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**).

If you also want to invalidate cache with a different key, you can use the decorator with the "to_invalidate_extra" variable.

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.

```python
# The cache here will be saved as "{username}_posts:{username}":
@router.get("/{username}/posts", response_model=List[PostRead])
@cache(key_prefix="{username}_posts", resource_id_name="username")
async def read_posts(
request: Request,
username: str,
db: Annotated[AsyncSession, Depends(async_get_db)]
):
...

...

# I'll invalidate the cache for the former endpoint by just passing the key_prefix and id as a dictionary:
@router.delete("/{username}/post/{id}")
@cache(
"{username}_post_cache",
resource_id_name="id",
to_invalidate_extra={"{username}_posts": "{username}"} # Now it will also invalidate the cache with id "{username}_posts:{username}"
)
async def erase_post(
request: Request,
username: str,
id: int,
current_user: Annotated[UserRead, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(async_get_db)]
):
...

# And now I'll also invalidate when I update the user:
@router.patch("/{username}/post/{id}", response_model=PostRead)
@cache(
"{username}_post_cache",
resource_id_name="id",
to_invalidate_extra={"{username}_posts": "{username}"}
)
async def patch_post(
request: Request,
username: str,
id: int,
values: PostUpdate,
current_user: Annotated[UserRead, Depends(get_current_user)],
db: Annotated[AsyncSession, Depends(async_get_db)]
):
...
```

Note that this will not work for **GET** requests.

### 9.9 Running
While in the **src** folder, run to start the application with uvicorn server:
```sh
poetry run uvicorn app.main:app --reload
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
@router.post("/login", response_model=Token)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: AsyncSession = Depends(async_get_db)
db: Annotated[AsyncSession, Depends(async_get_db)]
):

user = await authenticate_user(
Expand Down
26 changes: 25 additions & 1 deletion src/app/api/v1/posts.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import List, Annotated

from fastapi import Depends, HTTPException
from fastapi import Request, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import fastapi

Expand All @@ -11,11 +11,13 @@
from app.crud.crud_posts import crud_posts
from app.crud.crud_users import crud_users
from app.api.exceptions import privileges_exception
from app.core.cache import cache

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

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


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


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


@router.patch("/{username}/post/{id}", response_model=PostRead)
@cache(
"{username}_post_cache",
resource_id_name="id",
to_invalidate_extra={"{username}_posts": "{username}"}
)
async def patch_post(
request: Request,
username: str,
id: int,
values: PostUpdate,
Expand All @@ -87,7 +99,13 @@ async def patch_post(


@router.delete("/{username}/post/{id}")
@cache(
"{username}_post_cache",
resource_id_name="id",
to_invalidate_extra={"{username}_posts": "{username}"}
)
async def erase_post(
request: Request,
username: str,
id: int,
current_user: Annotated[UserRead, Depends(get_current_user)],
Expand All @@ -114,7 +132,13 @@ async def erase_post(


@router.delete("/{username}/db_post/{id}", dependencies=[Depends(get_current_superuser)])
@cache(
"{username}_post_cache",
resource_id_name="id",
to_invalidate_extra={"{username}_posts": "{username}"}
)
async def erase_db_post(
request: Request,
username: str,
id: int,
db: Annotated[AsyncSession, Depends(async_get_db)]
Expand Down
22 changes: 14 additions & 8 deletions src/app/api/v1/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from fastapi import Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import Request
import fastapi

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

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


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


@router.get("/user/me/", response_model=UserRead)
async def read_users_me(
current_user: Annotated[UserRead, Depends(get_current_user)]
request: Request, current_user: Annotated[UserRead, Depends(get_current_user)]
):
return current_user

from app.core.cache import cache


@router.get("/user/{username}", response_model=UserRead)
async def read_user(username: str, db: AsyncSession = Depends(async_get_db)):
async def read_user(request: Request, username: str, db: Annotated[AsyncSession, Depends(async_get_db)]):
db_user = await crud_users.get(db=db, username=username, is_deleted=False)
if db_user is None:
raise HTTPException(status_code=404, detail="User not found")

return db_user


@router.patch("/user/{username}", response_model=UserRead)
async def patch_user(
request: Request,
values: UserUpdate,
username: str,
current_user: Annotated[UserRead, Depends(get_current_user)],
db: AsyncSession = Depends(async_get_db)
db: Annotated[AsyncSession, Depends(async_get_db)]
):
db_user = await crud_users.get(db=db, username=username)
if db_user is None:
Expand All @@ -83,9 +87,10 @@ async def patch_user(

@router.delete("/user/{username}")
async def erase_user(
request: Request,
username: str,
current_user: Annotated[UserRead, Depends(get_current_user)],
db: AsyncSession = Depends(async_get_db)
db: Annotated[AsyncSession, Depends(async_get_db)]
):
db_user = await crud_users.get(db=db, username=username)
if db_user is None:
Expand All @@ -100,8 +105,9 @@ async def erase_user(

@router.delete("/db_user/{username}", dependencies=[Depends(get_current_superuser)])
async def erase_db_user(
request: Request,
username: str,
db: AsyncSession = Depends(async_get_db)
db: Annotated[AsyncSession, Depends(async_get_db)]
):
db_user = await crud_users.get(db=db, username=username)
if db_user is None:
Expand Down
Loading