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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Python is 3.14, dependencies managed by `uv`. The API is exposed on `:8000`.

## Conventions

- Routes live in `app/api/`, registered into a single `ROUTER` (prefix `/api`) referenced from `application.py`. Add a new resource by creating `app/api/<name>.py`, defining handlers + a `Router`, and adding it to `application.build_app`.
- Routes live in `app/api/`, one module per resource (`decks.py`, `cards.py`), each exposing its own `ROUTER` (`litestar.Router`, prefix `/api`). `application.build_app` registers them all via `route_handlers=[decks.ROUTER, cards.ROUTER]`. Add a new resource by creating `app/api/<name>.py`, defining handlers + a `ROUTER`, and adding it to that list.
- Pydantic schemas in `app/schemas.py` use `from_attributes=True` (via `Base`) so they validate directly from ORM instances (`schemas.X.model_validate(orm_instance)`). Collection responses go through `Collection[T].from_models(...)` (e.g. `schemas.Decks`, `schemas.Cards`).
- Deck responses are deliberately two-shaped: `list_decks`/`create_deck`/`update_deck` return the light `schemas.Deck` (no `cards`), while `get_deck` returns `schemas.DeckWithCards`. The split mirrors loading — lists use `noload` (no cards query), detail uses `selectinload` via `fetch_with_cards` — so the type states exactly what each endpoint loads.
- Domain exceptions: register handlers in `application.build_app`'s `exception_handlers` dict (see `DuplicateKeyError` → `exceptions.duplicate_key_error_handler`). For per-handler 404s the code raises `litestar.exceptions.HTTPException` directly.
Expand Down
41 changes: 41 additions & 0 deletions app/api/cards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import typing

import litestar
from litestar.params import FromPath # noqa: TC002

from app import models, schemas
from app.repositories import CardsRepository # noqa: TC001


@litestar.get("/decks/{deck_id:int}/cards/")
async def list_cards(deck_id: FromPath[int], cards_repository: CardsRepository) -> schemas.Cards:
objects = await cards_repository.list_for_deck(deck_id)
return schemas.Cards.from_models(objects)


@litestar.get("/cards/{card_id:int}/")
async def get_card(card_id: FromPath[int], cards_repository: CardsRepository) -> schemas.Card:
instance = await cards_repository.get_one(models.Card.id == card_id)
return schemas.Card.model_validate(instance)


@litestar.post("/decks/{deck_id:int}/cards/")
async def create_cards(
deck_id: FromPath[int], data: list[schemas.CardCreate], cards_repository: CardsRepository
) -> schemas.Cards:
objects = await cards_repository.add_cards(deck_id, data)
return schemas.Cards.from_models(objects)


@litestar.put("/decks/{deck_id:int}/cards/")
async def update_cards(
deck_id: FromPath[int], data: list[schemas.Card], cards_repository: CardsRepository
) -> schemas.Cards:
objects = await cards_repository.upsert_cards(deck_id, data)
return schemas.Cards.from_models(objects)


ROUTER: typing.Final = litestar.Router(
path="/api",
route_handlers=[list_cards, get_card, create_cards, update_cards],
)
34 changes: 3 additions & 31 deletions app/api/decks.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import litestar
from litestar.params import FromPath # noqa: TC002

from app import models, schemas
from app.repositories import CardsRepository, DecksRepository # noqa: TC001
from app import schemas
from app.repositories import DecksRepository # noqa: TC001


@litestar.get("/decks/")
Expand Down Expand Up @@ -35,35 +35,7 @@ async def create_deck(data: schemas.DeckCreate, decks_repository: DecksRepositor
return schemas.Deck.model_validate(instance)


@litestar.get("/decks/{deck_id:int}/cards/")
async def list_cards(deck_id: FromPath[int], cards_repository: CardsRepository) -> schemas.Cards:
objects = await cards_repository.list_for_deck(deck_id)
return schemas.Cards.from_models(objects)


@litestar.get("/cards/{card_id:int}/")
async def get_card(card_id: FromPath[int], cards_repository: CardsRepository) -> schemas.Card:
instance = await cards_repository.get_one(models.Card.id == card_id)
return schemas.Card.model_validate(instance)


@litestar.post("/decks/{deck_id:int}/cards/")
async def create_cards(
deck_id: FromPath[int], data: list[schemas.CardCreate], cards_repository: CardsRepository
) -> schemas.Cards:
objects = await cards_repository.add_cards(deck_id, data)
return schemas.Cards.from_models(objects)


@litestar.put("/decks/{deck_id:int}/cards/")
async def update_cards(
deck_id: FromPath[int], data: list[schemas.Card], cards_repository: CardsRepository
) -> schemas.Cards:
objects = await cards_repository.upsert_cards(deck_id, data)
return schemas.Cards.from_models(objects)


ROUTER: typing.Final = litestar.Router(
path="/api",
route_handlers=[list_decks, get_deck, update_deck, create_deck, list_cards, get_card, create_cards, update_cards],
route_handlers=[list_decks, get_deck, update_deck, create_deck],
)
4 changes: 2 additions & 2 deletions app/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor

from app import exceptions, ioc, repositories
from app.api.decks import ROUTER
from app.api import cards, decks
from app.settings import settings


Expand All @@ -27,7 +27,7 @@ def build_app() -> litestar.Litestar:
DuplicateKeyError: exceptions.duplicate_key_error_handler,
NotFoundError: exceptions.not_found_error_handler,
},
route_handlers=[ROUTER],
route_handlers=[decks.ROUTER, cards.ROUTER],
plugins=[modern_di_litestar.ModernDIPlugin(di_container)],
dependencies={
"decks_repository": modern_di_litestar.FromDI(repositories.DecksRepository),
Expand Down