diff --git a/CLAUDE.md b/CLAUDE.md index 46b62e4..eb5ecee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.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/.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. diff --git a/app/api/cards.py b/app/api/cards.py new file mode 100644 index 0000000..8f04a6f --- /dev/null +++ b/app/api/cards.py @@ -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], +) diff --git a/app/api/decks.py b/app/api/decks.py index 0ac8ae0..2dc6743 100644 --- a/app/api/decks.py +++ b/app/api/decks.py @@ -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/") @@ -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], ) diff --git a/app/application.py b/app/application.py index 28f2dd2..a827753 100644 --- a/app/application.py +++ b/app/application.py @@ -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 @@ -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),