From ade4887e1453ecddbfbc83f41f5df837a6f28676 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Fri, 26 Jun 2026 12:49:17 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20deepen=20ORM=E2=86=92HTTP=20seriali?= =?UTF-8?q?zation=20behind=20a=20Collection=20seam?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistence→HTTP serialization seam was shallow and leaked: every list handler built its response envelope from raw ORM rows (schemas.Decks(items=objects)), each carrying a ty: ignore for the Sequence[Model] → list[Schema] mismatch, and get_card carried a stray return_dto=PydanticDTO on top of a manual model_validate. Introduce a generic Collection[T] base with a from_models classmethod that routes through model_validate, so the ORM→schema coercion happens once at the seam. Decks/Cards become named subclasses (clean OpenAPI names); list handlers call schemas.Xs.from_models(objects); get_card drops its redundant DTO. Removes all four ty: ignore[invalid-argument-type] suppressions. Wire contract unchanged; existing HTTP tests cover the seam. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/decks.py | 11 +++++------ app/schemas.py | 22 ++++++++++++++++++---- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/app/api/decks.py b/app/api/decks.py index 8110f6f..f2e53a6 100644 --- a/app/api/decks.py +++ b/app/api/decks.py @@ -2,7 +2,6 @@ import litestar from litestar.params import FromPath # noqa: TC002 -from litestar.plugins.pydantic import PydanticDTO from app import models, schemas from app.repositories import CardsRepository, DecksRepository # noqa: TC001 @@ -11,7 +10,7 @@ @litestar.get("/decks/") async def list_decks(decks_repository: DecksRepository) -> schemas.Decks: objects = await decks_repository.get_many() - return schemas.Decks(items=objects) # ty: ignore[invalid-argument-type] + return schemas.Decks.from_models(objects) @litestar.get("/decks/{deck_id:int}/") @@ -39,10 +38,10 @@ async def create_deck(data: schemas.DeckCreate, decks_repository: DecksRepositor @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(items=objects) # ty: ignore[invalid-argument-type] + return schemas.Cards.from_models(objects) -@litestar.get("/cards/{card_id:int}/", return_dto=PydanticDTO[schemas.Card]) +@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) @@ -53,7 +52,7 @@ 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(items=objects) # ty: ignore[invalid-argument-type] + return schemas.Cards.from_models(objects) @litestar.put("/decks/{deck_id:int}/cards/") @@ -61,7 +60,7 @@ 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(items=objects) # ty: ignore[invalid-argument-type] + return schemas.Cards.from_models(objects) ROUTER: typing.Final = litestar.Router( diff --git a/app/schemas.py b/app/schemas.py index 84b82f6..a4b2d5a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,11 +1,25 @@ +from typing import TYPE_CHECKING, Self + import pydantic from pydantic import BaseModel, PositiveInt +if TYPE_CHECKING: + from collections.abc import Iterable + + class Base(BaseModel): model_config = pydantic.ConfigDict(from_attributes=True) +class Collection[T: Base](Base): + items: list[T] + + @classmethod + def from_models(cls, objects: Iterable[object]) -> Self: + return cls.model_validate({"items": list(objects)}) + + class CardBase(Base): front: str back: str | None = None @@ -21,8 +35,8 @@ class Card(CardBase): deck_id: PositiveInt | None = None -class Cards(Base): - items: list[Card] +class Cards(Collection[Card]): + pass class DeckBase(Base): @@ -39,5 +53,5 @@ class Deck(DeckBase): cards: list[Card] | None -class Decks(Base): - items: list[Deck] +class Decks(Collection[Deck]): + pass