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
15 changes: 11 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,12 @@ default JWT secret.
**Dependency injection** (`app/ioc.py`): one `modern_di.Container` built from
`ALL_GROUPS = [Database, Repositories, UseCases]`, attached via
`modern_di_litestar.ModernDIPlugin`. Route handlers receive use cases as
parameters; each `app/api/endpoints/*.py` module declares them with
`modern_di_litestar.FromDI(...)` (wired centrally in `build_app`'s
`dependencies=` dict) so Litestar resolves them per-request. Provider scopes:
parameters typed `NamedDependency[SomeUseCase]`; nothing wires them by hand.
`build_app` passes `autowired_groups=[ioc.UseCases]` to the plugin, which
registers one Litestar dependency per provider on that group, named after the
provider attribute (`create_chat_use_case`, ...). `Database` and `Repositories`
are deliberately **not** autowired — a route handler has no business resolving
a session, a transaction or a repository directly. Provider scopes:
- `Database.database_engine` — app-scoped factory, `cache=` finalizer disposes
the engine.
- `Database.database_session` — request-scoped, finalizer closes the session.
Expand Down Expand Up @@ -135,7 +138,11 @@ env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
`route_handlers=[auth_endpoints.ROUTER, chats_endpoints.ROUTER,
messages_endpoints.ROUTER]`. Add a new resource by creating
`app/api/endpoints/<name>.py`, defining handlers + a `ROUTER`, and adding it
to that list plus `build_app`'s `dependencies=` dict for any new use case.
to that list. A new use case needs no wiring beyond its `ioc.UseCases`
provider: `autowired_groups` exposes it under the provider's own name.
Handlers that need the caller annotate the request `app.api.auth.AuthedRequest`
and pass `actor=request.user` explicitly; every use case `__call__` is
keyword-only.
- Use cases live in `app/use_cases/`, one `@dataclasses.dataclass(kw_only=True,
frozen=True, slots=True)` per operation with an async `__call__` decorated
`@db_retry.postgres_retry`. Shared authorization logic that more than one
Expand Down
31 changes: 8 additions & 23 deletions app/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,6 @@
from app.api.endpoints import messages as messages_endpoints
from app.exceptions import ConflictError, PermissionDeniedError, ValidationError
from app.settings import settings
from app.use_cases.authenticate_user import AuthenticateUserUseCase
from app.use_cases.create_chat import CreateChatUseCase
from app.use_cases.create_message import CreateMessageUseCase
from app.use_cases.delete_message import DeleteMessageUseCase
from app.use_cases.edit_message import EditMessageUseCase
from app.use_cases.fetch_chat import FetchChatUseCase
from app.use_cases.fetch_chats import FetchChatsUseCase
from app.use_cases.fetch_messages import FetchMessagesUseCase
from app.use_cases.mark_read import MarkReadUseCase
from app.use_cases.register_user import RegisterUserUseCase


def build_app() -> litestar.Litestar:
Expand All @@ -45,19 +35,14 @@ def build_app() -> litestar.Litestar:
ConflictError: exception_handlers.conflict_error_handler,
},
route_handlers=[auth_endpoints.ROUTER, chats_endpoints.ROUTER, messages_endpoints.ROUTER],
plugins=[modern_di_litestar.ModernDIPlugin(di_container), JWTCookieAuthPlugin()],
dependencies={
"register_user_use_case": modern_di_litestar.FromDI(RegisterUserUseCase),
"authenticate_user_use_case": modern_di_litestar.FromDI(AuthenticateUserUseCase),
"create_chat_use_case": modern_di_litestar.FromDI(CreateChatUseCase),
"fetch_chat_use_case": modern_di_litestar.FromDI(FetchChatUseCase),
"create_message_use_case": modern_di_litestar.FromDI(CreateMessageUseCase),
"fetch_messages_use_case": modern_di_litestar.FromDI(FetchMessagesUseCase),
"edit_message_use_case": modern_di_litestar.FromDI(EditMessageUseCase),
"delete_message_use_case": modern_di_litestar.FromDI(DeleteMessageUseCase),
"fetch_chats_use_case": modern_di_litestar.FromDI(FetchChatsUseCase),
"mark_read_use_case": modern_di_litestar.FromDI(MarkReadUseCase),
},
# autowired_groups exposes one Litestar dependency per UseCases provider, named
# after the provider attribute - which is what every handler parameter is already
# called. Database and Repositories are deliberately left out: route handlers have
# no business resolving a session, a transaction or a repository directly.
plugins=[
modern_di_litestar.ModernDIPlugin(di_container, autowired_groups=[ioc.UseCases]),
JWTCookieAuthPlugin(),
],
request_max_body_size=settings.request_max_body_size,
),
opentelemetry_instrumentors=[
Expand Down
6 changes: 6 additions & 0 deletions app/api/auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import datetime
import typing

import litestar
import modern_di_litestar
from litestar.config.app import AppConfig
from litestar.connection import ASGIConnection
Expand All @@ -13,6 +14,11 @@
from app.settings import settings


# Every authenticated handler annotates its request with this; `request.user` is a UsersTable
# because retrieve_user_handler below is what populates it.
type AuthedRequest = litestar.Request[tables.UsersTable, Token, typing.Any]


async def retrieve_user_handler(token: Token, connection: ASGIConnection) -> tables.UsersTable | None:
# Auth middleware runs before request-scoped DI is available, so resolve the app-scoped
# engine and open a short-lived session through the same factory the container uses. That
Expand Down
9 changes: 4 additions & 5 deletions app/api/endpoints/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
from litestar.exceptions import NotAuthorizedException
from litestar.response import Response

from app.api.auth import jwt_cookie_auth
from app.database import tables
from app.api.auth import AuthedRequest, jwt_cookie_auth
from app.schemas import api as schemas
from app.use_cases.authenticate_user import AuthenticateUserUseCase
from app.use_cases.register_user import RegisterUserUseCase
Expand All @@ -18,7 +17,7 @@ async def register(
data: schemas.RegisterRequest,
register_user_use_case: NamedDependency[RegisterUserUseCase],
) -> Response[schemas.User]:
user: typing.Final = await register_user_use_case(data)
user: typing.Final = await register_user_use_case(data=data)
return jwt_cookie_auth.login(
identifier=str(user.id),
response_body=schemas.User.model_validate(user),
Expand All @@ -31,7 +30,7 @@ async def login(
data: schemas.LoginRequest,
authenticate_user_use_case: NamedDependency[AuthenticateUserUseCase],
) -> Response[schemas.User]:
user: typing.Final = await authenticate_user_use_case(data.username, data.password)
user: typing.Final = await authenticate_user_use_case(username=data.username, password=data.password)
if user is None:
raise NotAuthorizedException(detail="Invalid username or password")
return jwt_cookie_auth.login(
Expand All @@ -49,7 +48,7 @@ async def logout() -> Response[None]:


@litestar.get("/auth/me/")
async def me(request: litestar.Request[tables.UsersTable, typing.Any, typing.Any]) -> schemas.User:
async def me(request: AuthedRequest) -> schemas.User:
return schemas.User.model_validate(request.user)


Expand Down
22 changes: 9 additions & 13 deletions app/api/endpoints/chats.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from litestar.openapi.datastructures import ResponseSpec
from litestar.params import FromPath

from app.database import tables
from app.api.auth import AuthedRequest
from app.schemas import api as schemas
from app.use_cases.create_chat import CreateChatUseCase
from app.use_cases.fetch_chat import FetchChatUseCase
Expand All @@ -24,10 +24,10 @@
)
async def create_chat(
data: schemas.CreateChatRequest,
request: litestar.Request[tables.UsersTable, typing.Any, typing.Any],
request: AuthedRequest,
create_chat_use_case: NamedDependency[CreateChatUseCase],
) -> litestar.Response[schemas.ChatDetail]:
chat, created = await create_chat_use_case(request.user, data)
chat, created = await create_chat_use_case(actor=request.user, data=data)
return litestar.Response(
content=schemas.ChatDetail.model_validate(chat),
status_code=status_codes.HTTP_201_CREATED if created else status_codes.HTTP_200_OK,
Expand All @@ -36,34 +36,30 @@ async def create_chat(

@litestar.get("/chats/")
async def list_chats(
request: litestar.Request[tables.UsersTable, typing.Any, typing.Any],
request: AuthedRequest,
fetch_chats_use_case: NamedDependency[FetchChatsUseCase],
) -> schemas.Chats:
rows: typing.Final = await fetch_chats_use_case(request.user)
return schemas.Chats.from_models(
schemas.ChatListItem.from_row(row.chat, unread_count=row.unread_count, last_message=row.last_message)
for row in rows
)
return schemas.Chats.from_models(await fetch_chats_use_case(actor=request.user))


@litestar.get("/chats/{chat_id:int}/")
async def get_chat(
chat_id: FromPath[int],
request: litestar.Request[tables.UsersTable, typing.Any, typing.Any],
request: AuthedRequest,
fetch_chat_use_case: NamedDependency[FetchChatUseCase],
) -> schemas.ChatDetail:
chat: typing.Final = await fetch_chat_use_case(request.user, chat_id)
chat: typing.Final = await fetch_chat_use_case(actor=request.user, chat_id=chat_id)
return schemas.ChatDetail.model_validate(chat)


@litestar.post("/chats/{chat_id:int}/read/", status_code=status_codes.HTTP_200_OK)
async def mark_read(
chat_id: FromPath[int],
data: schemas.MarkReadRequest,
request: litestar.Request[tables.UsersTable, typing.Any, typing.Any],
request: AuthedRequest,
mark_read_use_case: NamedDependency[MarkReadUseCase],
) -> schemas.ChatMember:
member: typing.Final = await mark_read_use_case(request.user, chat_id, data)
member: typing.Final = await mark_read_use_case(actor=request.user, chat_id=chat_id, data=data)
return schemas.ChatMember.model_validate(member)


Expand Down
18 changes: 9 additions & 9 deletions app/api/endpoints/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from litestar.openapi.datastructures import ResponseSpec
from litestar.params import FromPath, FromQuery

from app.database import tables
from app.api.auth import AuthedRequest
from app.schemas import api as schemas
from app.use_cases.create_message import CreateMessageUseCase
from app.use_cases.delete_message import DeleteMessageUseCase
Expand All @@ -25,10 +25,10 @@
async def send_message(
chat_id: FromPath[int],
data: schemas.SendMessageRequest,
request: litestar.Request[tables.UsersTable, typing.Any, typing.Any],
request: AuthedRequest,
create_message_use_case: NamedDependency[CreateMessageUseCase],
) -> litestar.Response[schemas.Message]:
message, created = await create_message_use_case(request.user, chat_id, data)
message, created = await create_message_use_case(actor=request.user, chat_id=chat_id, data=data)
return litestar.Response(
content=schemas.Message.model_validate(message),
status_code=status_codes.HTTP_201_CREATED if created else status_codes.HTTP_200_OK,
Expand All @@ -38,15 +38,15 @@ async def send_message(
@litestar.get("/chats/{chat_id:int}/messages/")
async def list_messages( # noqa: PLR0913 - each is a distinct Litestar-bound path/query/DI param
chat_id: FromPath[int],
request: litestar.Request[tables.UsersTable, typing.Any, typing.Any],
request: AuthedRequest,
fetch_messages_use_case: NamedDependency[FetchMessagesUseCase],
*,
before_id: FromQuery[int | None] = None,
after_id: FromQuery[int | None] = None,
limit: FromQuery[int] = 50,
) -> schemas.Messages:
messages: typing.Final = await fetch_messages_use_case(
request.user, chat_id, before_id=before_id, after_id=after_id, limit=limit
actor=request.user, chat_id=chat_id, before_id=before_id, after_id=after_id, limit=limit
)
return schemas.Messages.from_models(messages)

Expand All @@ -55,20 +55,20 @@ async def list_messages( # noqa: PLR0913 - each is a distinct Litestar-bound pa
async def edit_message(
message_id: FromPath[int],
data: schemas.EditMessageRequest,
request: litestar.Request[tables.UsersTable, typing.Any, typing.Any],
request: AuthedRequest,
edit_message_use_case: NamedDependency[EditMessageUseCase],
) -> schemas.Message:
message: typing.Final = await edit_message_use_case(request.user, message_id, data)
message: typing.Final = await edit_message_use_case(actor=request.user, message_id=message_id, data=data)
return schemas.Message.model_validate(message)


@litestar.delete("/messages/{message_id:int}/")
async def delete_message(
message_id: FromPath[int],
request: litestar.Request[tables.UsersTable, typing.Any, typing.Any],
request: AuthedRequest,
delete_message_use_case: NamedDependency[DeleteMessageUseCase],
) -> None:
await delete_message_use_case(request.user, message_id)
await delete_message_use_case(actor=request.user, message_id=message_id)


ROUTER: typing.Final = litestar.Router(
Expand Down
24 changes: 24 additions & 0 deletions app/database/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,30 @@ class ChatsTable(BigIntAuditBase):
"ChatMembersTable", lazy="noload", uselist=True, viewonly=True
)

# Per-viewer state that the chat listing needs alongside the chat's own columns. Both are
# mapped here rather than assembled in Python so that a listed chat is a plain ChatsTable
# whose attribute names already match the response schema - no per-row DTO, no aliases.
#
# unread_count is only populated when the query asks for it via with_expression(); every
# other query gets default_expr's literal 0, so the attribute is never None.
unread_count: orm.Mapped[int] = orm.query_expression(default_expr=sa.literal(0))
# last_message_id deliberately carries no ForeignKey (a chats -> messages FK would close a
# cycle with messages.chat_id), so the join column has to be annotated foreign() by hand.
# The soft-delete guard lives in the join rather than at the call site: a chat must never
# preview a deleted message, whatever loads it. DeleteMessageUseCase still repoints
# last_message_id in the same commit as the delete - this only keeps the mapping correct on
# its own if some other write path ever fails to.
last_message: orm.Mapped[MessagesTable | None] = orm.relationship(
"MessagesTable",
primaryjoin=lambda: sa.and_(
orm.foreign(ChatsTable.last_message_id) == MessagesTable.id,
MessagesTable.deleted_at.is_(None),
),
lazy="noload",
uselist=False,
viewonly=True,
)


class ChatMembersTable(BigIntBase):
__tablename__ = "chat_members"
Expand Down
15 changes: 12 additions & 3 deletions app/repositories/chats_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async def fetch_direct_by_key(self, direct_key: str) -> tables.ChatsTable | None
load=[orm.selectinload(tables.ChatsTable.members)],
)

async def list_for_user(self, user_id: int) -> Sequence[sa.Row[tuple[tables.ChatsTable, int]]]:
async def list_for_user(self, user_id: int) -> Sequence[tables.ChatsTable]:
unread_count: typing.Final = (
sa.select(sa.func.count(tables.MessagesTable.id))
.where(
Expand All @@ -41,10 +41,19 @@ async def list_for_user(self, user_id: int) -> Sequence[sa.Row[tuple[tables.Chat
.label("unread_count")
)
statement: typing.Final = (
sa.select(tables.ChatsTable, unread_count)
sa.select(tables.ChatsTable)
.options(
orm.with_expression(tables.ChatsTable.unread_count, unread_count),
orm.selectinload(tables.ChatsTable.last_message),
)
.join(tables.ChatMembersTable, tables.ChatMembersTable.chat_id == tables.ChatsTable.id)
.where(tables.ChatMembersTable.user_id == user_id)
.order_by(sa.func.coalesce(tables.ChatsTable.last_message_id, 0).desc())
# Sessions run expire_on_commit=False, so a ChatsTable already in the identity map
# keeps whatever unread_count/last_message it was loaded with; without this, a second
# listing in the same session would hand back the first one's values. Safe here only
# because this query is read-only - populate_existing overwrites in-memory state.
.execution_options(populate_existing=True)
)
result: typing.Final = await self.repository.session.execute(statement)
return result.all()
return result.scalars().all()
19 changes: 2 additions & 17 deletions app/schemas/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,26 +87,11 @@ class Messages(Collection[Message]):


class ChatListItem(Chat):
# ChatsTable maps unread_count and last_message itself (see app/database/tables.py), so a
# listed chat validates straight through from_attributes like any other ORM instance.
last_message: Message | None = None
unread_count: int = 0

@classmethod
def from_row(cls, chat: tables.ChatsTable, *, unread_count: int, last_message: tables.MessagesTable | None) -> Self:
# `chat` alone (via Chat's from_attributes=True) has no unread_count/last_message
# attributes - those are computed by FetchChatsUseCase, not columns on ChatsTable - so
# this validates them together from a dict instead of Chat.model_validate(chat) plus an
# unvalidated model_copy(update=...) patch.
return cls.model_validate(
{
"id": chat.id,
"chat_type": chat.chat_type,
"title": chat.title,
"created_by_id": chat.created_by_id,
"unread_count": unread_count,
"last_message": last_message,
}
)


class Chats(Collection[ChatListItem]):
pass
2 changes: 1 addition & 1 deletion app/use_cases/authenticate_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ class AuthenticateUserUseCase:
users_repository: UsersRepository

@postgres_retry
async def __call__(self, username: str, password: str) -> tables.UsersTable | None:
async def __call__(self, *, username: str, password: str) -> tables.UsersTable | None:
user: typing.Final = await self.users_repository.get_one_or_none(username=username)
if user is None:
# Hash anyway: skipping the argon2 work on an unknown username makes the
Expand Down
2 changes: 1 addition & 1 deletion app/use_cases/create_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class CreateChatUseCase:
chat_members_repository: ChatMembersRepository

@postgres_retry
async def __call__(self, actor: tables.UsersTable, data: CreateChatRequest) -> tuple[tables.ChatsTable, bool]:
async def __call__(self, *, actor: tables.UsersTable, data: CreateChatRequest) -> tuple[tables.ChatsTable, bool]:
member_ids: typing.Final = {actor.id, *data.member_ids}
direct_key: str | None = None

Expand Down
2 changes: 1 addition & 1 deletion app/use_cases/create_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class CreateMessageUseCase:

@postgres_retry
async def __call__(
self, actor: tables.UsersTable, chat_id: int, data: SendMessageRequest
self, *, actor: tables.UsersTable, chat_id: int, data: SendMessageRequest
) -> tuple[tables.MessagesTable, bool]:
if not await self.chat_members_repository.is_member(chat_id, actor.id):
msg = "Not a member of this chat"
Expand Down
2 changes: 1 addition & 1 deletion app/use_cases/delete_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class DeleteMessageUseCase:
chats_repository: ChatsRepository

@postgres_retry
async def __call__(self, actor: tables.UsersTable, message_id: int) -> None:
async def __call__(self, *, actor: tables.UsersTable, message_id: int) -> None:
async with self.transaction:
message = await fetch_message_for_author(
messages_repository=self.messages_repository,
Expand Down
2 changes: 1 addition & 1 deletion app/use_cases/edit_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class EditMessageUseCase:

@postgres_retry
async def __call__(
self, actor: tables.UsersTable, message_id: int, data: EditMessageRequest
self, *, actor: tables.UsersTable, message_id: int, data: EditMessageRequest
) -> tables.MessagesTable:
async with self.transaction:
message = await fetch_message_for_author(
Expand Down
Loading