From 4e59e1c6fe5cba3de24252b3f810d064dd32bef7 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 13:09:20 +0300 Subject: [PATCH] refactor: split the Deck response contract from its detail view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single Deck schema served two endpoints with different card-loading strategies, leaking that difference into a cards: list[Card] | None field. Worse, list_decks reported cards: [] for every deck regardless of its real cards, because the relationship is lazy="noload" — the list contract was lying, and the | None was dead (noload yields []). Split into Deck (no cards) and DeckWithCards(Deck) (cards: list[Card], always populated). get_deck returns DeckWithCards; list/create/update return the light Deck. Each endpoint's type now states exactly what it loads, and the dead | None is gone. This is an intentional wire-contract change: the cards key disappears from list/create/update responses. The two-shape convention is recorded in CLAUDE.md and in docstrings on the Deck/DeckWithCards schemas. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 3 ++- app/api/decks.py | 4 ++-- app/schemas.py | 9 ++++++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5033363..46b62e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,6 +36,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`. -- 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)`). +- 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. - `ruff` is configured with `select = ["ALL"]` and a line length of 120 — expect strict lint. Type-check with `ty`; use `# ty: ignore[]` for suppressions (already used for `invalid-argument-type` around `LifespanManager` / `ASGITransport` / DTO list construction). diff --git a/app/api/decks.py b/app/api/decks.py index f2e53a6..0ac8ae0 100644 --- a/app/api/decks.py +++ b/app/api/decks.py @@ -14,9 +14,9 @@ async def list_decks(decks_repository: DecksRepository) -> schemas.Decks: @litestar.get("/decks/{deck_id:int}/") -async def get_deck(deck_id: FromPath[int], decks_repository: DecksRepository) -> schemas.Deck: +async def get_deck(deck_id: FromPath[int], decks_repository: DecksRepository) -> schemas.DeckWithCards: instance = await decks_repository.fetch_with_cards(deck_id) - return schemas.Deck.model_validate(instance) + return schemas.DeckWithCards.model_validate(instance) @litestar.put("/decks/{deck_id:int}/") diff --git a/app/schemas.py b/app/schemas.py index a4b2d5a..2dc630d 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -49,8 +49,15 @@ class DeckCreate(DeckBase): class Deck(DeckBase): + """Light deck view for lists and writes; cards are not loaded.""" + id: PositiveInt - cards: list[Card] | None + + +class DeckWithCards(Deck): + """Deck detail view; cards are eager-loaded (selectinload) and always present.""" + + cards: list[Card] class Decks(Collection[Deck]):