Skip to content

Commit dbd4614

Browse files
committed
alembic migration fix, pagination suport in get_multi endpoints, get and get_multi now return a dict
1 parent 4ffd63c commit dbd4614

File tree

13 files changed

+237
-111
lines changed

13 files changed

+237
-111
lines changed

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ services:
3737
- ./src/.env
3838
volumes:
3939
- postgres-data:/var/lib/postgresql/data
40+
ports:
41+
- "5432:5432"
4042

4143
redis:
4244
image: redis:alpine

src/app/api/v1/posts.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from app.crud.crud_users import crud_users
1313
from app.api.exceptions import privileges_exception
1414
from app.core.cache import cache
15+
from app.core.models import PaginatedListResponse
1516

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

@@ -27,29 +28,45 @@ async def write_post(
2728
if db_user is None:
2829
raise HTTPException(status_code=404, detail="User not found")
2930

30-
if current_user.id != db_user.id:
31+
if current_user.id != db_user["id"]:
3132
raise privileges_exception
3233

3334
post_internal_dict = post.model_dump()
34-
post_internal_dict["created_by_user_id"] = db_user.id
35+
post_internal_dict["created_by_user_id"] = db_user["id"]
3536

3637
post_internal = PostCreateInternal(**post_internal_dict)
3738
return await crud_posts.create(db=db, object=post_internal)
3839

3940

40-
@router.get("/{username}/posts", response_model=List[PostRead])
41+
@router.get("/{username}/posts", response_model=PaginatedListResponse[PostRead])
4142
@cache(key_prefix="{username}_posts", resource_id_name="username")
4243
async def read_posts(
4344
request: Request,
44-
username: str,
45-
db: Annotated[AsyncSession, Depends(async_get_db)]
45+
username: str,
46+
db: Annotated[AsyncSession, Depends(async_get_db)],
47+
page: int = 1,
48+
items_per_page: int = 10
4649
):
4750
db_user = await crud_users.get(db=db, schema_to_select=UserRead, username=username, is_deleted=False)
48-
if db_user is None:
51+
if not db_user:
4952
raise HTTPException(status_code=404, detail="User not found")
50-
51-
posts = await crud_posts.get_multi(db=db, schema_to_select=PostRead, created_by_user_id=db_user.id, is_deleted=False)
52-
return posts
53+
54+
posts_data = await crud_posts.get_multi(
55+
db=db,
56+
offset=(page - 1) * items_per_page,
57+
limit=items_per_page,
58+
schema_to_select=PostRead,
59+
created_by_user_id=db_user["id"],
60+
is_deleted=False
61+
)
62+
63+
return {
64+
"data": posts_data["data"],
65+
"total_count": posts_data["total_count"],
66+
"has_more": (page * items_per_page) < posts_data["total_count"],
67+
"page": page,
68+
"items_per_page": items_per_page
69+
}
5370

5471

5572
@router.get("/{username}/post/{id}", response_model=PostRead)
@@ -64,10 +81,10 @@ async def read_post(
6481
if db_user is None:
6582
raise HTTPException(status_code=404, detail="User not found")
6683

67-
db_post = await crud_posts.get(db=db, schema_to_select=PostRead, id=id, created_by_user_id=db_user.id, is_deleted=False)
84+
db_post = await crud_posts.get(db=db, schema_to_select=PostRead, id=id, created_by_user_id=db_user["id"], is_deleted=False)
6885
if db_post is None:
6986
raise HTTPException(status_code=404, detail="Post not found")
70-
87+
7188
return db_post
7289

7390

@@ -89,7 +106,7 @@ async def patch_post(
89106
if db_user is None:
90107
raise HTTPException(status_code=404, detail="User not found")
91108

92-
if current_user.id != db_user.id:
109+
if current_user.id != db_user["id"]:
93110
raise privileges_exception
94111

95112
db_post = await crud_posts.get(db=db, schema_to_select=PostRead, id=id, is_deleted=False)

src/app/api/v1/users.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from app.core.security import get_password_hash
1212
from app.crud.crud_users import crud_users
1313
from app.api.exceptions import privileges_exception
14+
from app.core.models import PaginatedListResponse
1415

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

@@ -36,10 +37,28 @@ async def write_user(
3637
return await crud_users.create(db=db, object=user_internal)
3738

3839

39-
@router.get("/users", response_model=List[UserRead])
40-
async def read_users(request: Request, db: Annotated[AsyncSession, Depends(async_get_db)]):
41-
users = await crud_users.get_multi(db=db, schema_to_select=UserRead, is_deleted=False)
42-
return users
40+
@router.get("/users", response_model=PaginatedListResponse[UserRead])
41+
async def read_users(
42+
request: Request,
43+
db: Annotated[AsyncSession, Depends(async_get_db)],
44+
page: int = 1,
45+
items_per_page: int = 10
46+
):
47+
users_data = await crud_users.get_multi(
48+
db=db,
49+
offset=(page - 1) * items_per_page,
50+
limit=items_per_page,
51+
schema_to_select=UserRead,
52+
is_deleted=False
53+
)
54+
55+
return {
56+
"data": users_data["data"],
57+
"total_count": users_data["total_count"],
58+
"has_more": (page * items_per_page) < users_data["total_count"],
59+
"page": page,
60+
"items_per_page": items_per_page
61+
}
4362

4463

4564
@router.get("/user/me/", response_model=UserRead)

src/app/core/cache.py

Lines changed: 14 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,52 +6,18 @@
66
import re
77

88
from fastapi import Request, Response
9+
from fastapi.encoders import jsonable_encoder
910
from redis.asyncio import Redis, ConnectionPool
10-
from sqlalchemy.orm import class_mapper, DeclarativeBase
1111
from fastapi import FastAPI
1212
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
1313

14-
from app.core.exceptions import CacheIdentificationInferenceError, InvalidRequestError
14+
from app.core.exceptions import CacheIdentificationInferenceError, InvalidRequestError, InvalidOutputTypeError
1515

1616
# --------------- server side caching ---------------
1717

1818
pool: ConnectionPool | None = None
1919
client: Redis | None = None
2020

21-
def _serialize_sqlalchemy_object(obj: DeclarativeBase) -> Dict[str, Any]:
22-
"""
23-
Serialize a SQLAlchemy DeclarativeBase object to a dictionary.
24-
25-
Parameters
26-
----------
27-
obj: DeclarativeBase
28-
The SQLAlchemy DeclarativeBase object to be serialized.
29-
30-
Returns
31-
-------
32-
Dict[str, Any]
33-
A dictionary containing the serialized attributes of the object.
34-
35-
Note
36-
----
37-
- Datetime objects are converted to ISO 8601 string format.
38-
- UUID objects are converted to strings before serializing to JSON.
39-
"""
40-
if isinstance(obj, DeclarativeBase):
41-
data = {}
42-
for column in class_mapper(obj.__class__).columns:
43-
value = getattr(obj, column.name)
44-
45-
if isinstance(value, datetime):
46-
value = value.isoformat()
47-
48-
if isinstance(value, UUID):
49-
value = str(value)
50-
51-
data[column.name] = value
52-
return data
53-
54-
5521
def _infer_resource_id(kwargs: Dict[str, Any], resource_id_type: Union[type, str]) -> Union[None, int, str]:
5622
"""
5723
Infer the resource ID from a dictionary of keyword arguments.
@@ -236,46 +202,43 @@ async def sample_endpoint(request: Request, resource_id: int):
236202
This decorator caches the response data of the endpoint function using a unique cache key.
237203
The cached data is retrieved for GET requests, and the cache is invalidated for other types of requests.
238204
239-
Note:
240-
- For caching lists of objects, ensure that the response is a list of objects, and the decorator will handle caching accordingly.
205+
Note
206+
----
241207
- resource_id_type is used only if resource_id is not passed.
242208
"""
243209
def wrapper(func: Callable) -> Callable:
244210
@functools.wraps(func)
245211
async def inner(request: Request, *args, **kwargs) -> Response:
212+
if "output_type" in kwargs.keys() and kwargs["output_type"] == list:
213+
raise InvalidOutputTypeError
214+
246215
if resource_id_name:
247216
resource_id = kwargs[resource_id_name]
248217
else:
249218
resource_id = _infer_resource_id(kwargs=kwargs, resource_id_type=resource_id_type)
250219

251220
formatted_key_prefix = _format_prefix(key_prefix, kwargs)
252221
cache_key = f"{formatted_key_prefix}:{resource_id}"
253-
254222
if request.method == "GET":
255223
if to_invalidate_extra:
256224
raise InvalidRequestError
257225

258226
cached_data = await client.get(cache_key)
259227
if cached_data:
228+
print("cache hit")
260229
return json.loads(cached_data.decode())
261-
230+
262231
result = await func(request, *args, **kwargs)
263232

264233
if request.method == "GET":
265-
if to_invalidate_extra:
266-
raise InvalidRequestError
234+
serializable_data = jsonable_encoder(result)
235+
serialized_data = json.dumps(serializable_data)
267236

268-
if isinstance(result, list):
269-
serialized_data = json.dumps(
270-
[_serialize_sqlalchemy_object(obj) for obj in result]
271-
)
272-
else:
273-
serialized_data = json.dumps(
274-
_serialize_sqlalchemy_object(result)
275-
)
276-
277237
await client.set(cache_key, serialized_data)
278238
await client.expire(cache_key, expiration)
239+
240+
serialized_data = json.loads(serialized_data)
241+
279242
else:
280243
await client.delete(cache_key)
281244
if to_invalidate_extra:

src/app/core/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@ class InvalidRequestError(Exception):
88
def __init__(self, message="Type of request not supported."):
99
self.message = message
1010
super().__init__(self.message)
11+
12+
13+
class InvalidOutputTypeError(Exception):
14+
def __init__(self, message="output_type not allowed. If caching, use dict"):
15+
self.message = message
16+
super().__init__(self.message)

src/app/core/models.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
1+
from typing import TypeVar, Generic, List
12
import uuid as uuid_pkg
23
from datetime import datetime
34

4-
from pydantic import BaseModel, Field
5+
from pydantic import BaseModel, Field, field_serializer
56
from sqlalchemy import text
67

7-
from app.core.database import Base
8+
ReadSchemaType = TypeVar("ReadSchemaType", bound=BaseModel)
9+
10+
class ListResponse(BaseModel, Generic[ReadSchemaType]):
11+
data: List[ReadSchemaType]
12+
13+
14+
class PaginatedListResponse(ListResponse[ReadSchemaType]):
15+
total_count: int
16+
has_more: bool
17+
page: int | None = None
18+
items_per_page: int | None = None
19+
820

921
class HealthCheck(BaseModel):
1022
name: str
@@ -47,7 +59,16 @@ class TimestampModel(BaseModel):
4759
}
4860
)
4961

50-
62+
@field_serializer("created_at")
63+
def serialize_dt(self, created_at: datetime | None, _info):
64+
return created_at.isoformat()
65+
66+
@field_serializer("updated_at")
67+
def serialize_updated_at(self, updated_at: datetime | None, _info):
68+
if updated_at is not None:
69+
return updated_at.isoformat()
70+
71+
5172
class PersistentDeletion(BaseModel):
5273
deleted_at: datetime | None = Field(
5374
default=None,
@@ -58,3 +79,8 @@ class PersistentDeletion(BaseModel):
5879
)
5980

6081
is_deleted: bool = False
82+
83+
@field_serializer('deleted_at')
84+
def serialize_dates(self, deleted_at: datetime | None, _info):
85+
if deleted_at is not None:
86+
return deleted_at.isoformat()

0 commit comments

Comments
 (0)