diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fae84a..ecbddac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- Pydantic response models and a generic `Page` pagination envelope. +- `limit` / `offset` pagination on `/matches`, `/games/{id}/events`, `/games/{id}/frames`. +- Async Alembic migrations with the initial schema (production source of truth). +- OpenAPI tags and richer API metadata (description, contact, license). +- API endpoint tests (response shapes + pagination). + +### Changed + +- REST routes now return typed Pydantic models instead of raw dicts. + ## [0.1.0] - 2026-06-08 ### Added diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..0fd71f8 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +# The DB URL is injected from app settings in alembic/env.py (DATABASE_URL). + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..10aea70 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,61 @@ +"""Alembic environment — async-aware, wired to the app's settings and metadata.""" + +from __future__ import annotations + +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context +from app.config import settings +from app.db import models # noqa: F401 (registers the models on the metadata) +from app.db.base import Base + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def _run_migrations(connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, # safe ALTERs on SQLite + ) + with context.begin_transaction(): + context.run_migrations() + + +async def run_migrations_online() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + async with connectable.connect() as connection: + await connection.run_sync(_run_migrations) + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + asyncio.run(run_migrations_online()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..00b1a7e --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..7fe5d0b --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -0,0 +1,109 @@ +"""initial schema + +Revision ID: 0001_initial +Revises: +Create Date: 2026-06-08 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "0001_initial" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "leagues", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("slug", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=True), + sa.Column("region", sa.String(), nullable=True), + sa.Column("image", sa.String(), nullable=True), + ) + op.create_table( + "teams", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("code", sa.String(), nullable=True), + sa.Column("name", sa.String(), nullable=True), + sa.Column("image", sa.String(), nullable=True), + ) + op.create_table( + "players", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("handle", sa.String(), nullable=True), + sa.Column("team_id", sa.String(), sa.ForeignKey("teams.id"), nullable=True), + sa.Column("role", sa.String(), nullable=True), + ) + op.create_table( + "matches", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("league_id", sa.String(), sa.ForeignKey("leagues.id"), nullable=True), + sa.Column("best_of", sa.Integer(), nullable=True), + sa.Column("status", sa.String(), nullable=True), + sa.Column("scheduled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("blue_code", sa.String(), nullable=True), + sa.Column("red_code", sa.String(), nullable=True), + ) + op.create_table( + "games", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("match_id", sa.String(), sa.ForeignKey("matches.id"), nullable=False), + sa.Column("number", sa.Integer(), nullable=True), + sa.Column("patch", sa.String(), nullable=True), + sa.Column("state", sa.String(), nullable=True), + sa.Column("picks", sa.JSON(), nullable=True), + ) + op.create_table( + "frames", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("game_id", sa.String(), sa.ForeignKey("games.id"), nullable=False), + sa.Column("ts", sa.DateTime(timezone=True), nullable=False), + sa.Column("state", sa.String(), nullable=True), + sa.Column("blue_kills", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("blue_towers", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("blue_barons", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("blue_inhibitors", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("blue_gold", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("blue_dragons", sa.JSON(), nullable=True), + sa.Column("red_kills", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("red_towers", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("red_barons", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("red_inhibitors", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("red_gold", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("red_dragons", sa.JSON(), nullable=True), + ) + op.create_index(op.f("ix_frames_game_id"), "frames", ["game_id"]) + op.create_index(op.f("ix_frames_ts"), "frames", ["ts"]) + op.create_table( + "events", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("game_id", sa.String(), sa.ForeignKey("games.id"), nullable=False), + sa.Column("ts", sa.DateTime(timezone=True), nullable=False), + sa.Column("type", sa.String(), nullable=False), + sa.Column("side", sa.String(), nullable=False), + sa.Column("info", sa.String(), nullable=True), + ) + op.create_index(op.f("ix_events_game_id"), "events", ["game_id"]) + op.create_index(op.f("ix_events_ts"), "events", ["ts"]) + + +def downgrade() -> None: + op.drop_index(op.f("ix_events_ts"), table_name="events") + op.drop_index(op.f("ix_events_game_id"), table_name="events") + op.drop_table("events") + op.drop_index(op.f("ix_frames_ts"), table_name="frames") + op.drop_index(op.f("ix_frames_game_id"), table_name="frames") + op.drop_table("frames") + op.drop_table("games") + op.drop_table("matches") + op.drop_table("players") + op.drop_table("teams") + op.drop_table("leagues") diff --git a/app/api/routes.py b/app/api/routes.py index 55e711c..f324d02 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -1,6 +1,8 @@ -"""REST routes for the API (in-house normalized schema, reading from the database). +"""REST routes for the public API (in-house normalized schema, reads from the DB). -Phase 3 (upcoming): WebSocket /live/{game_id} to push live events in real time. +Responses are typed with Pydantic models (`schemas/api.py`) and list endpoints are +paginated with a `Page` envelope. Phase 3 (planned): WebSocket `/live/{game_id}` +to push events in real time. """ from __future__ import annotations @@ -8,11 +10,21 @@ from collections.abc import AsyncIterator from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db import models from app.db.base import SessionLocal +from app.schemas.api import ( + EventOut, + FrameOut, + GameOut, + LeagueOut, + MatchDetailOut, + MatchOut, + Page, + TeamSideFrame, +) router = APIRouter() @@ -22,80 +34,131 @@ async def get_session() -> AsyncIterator[AsyncSession]: yield session -@router.get("/leagues") +async def _count(session: AsyncSession, model, *where) -> int: + stmt = select(func.count()).select_from(model) + for clause in where: + stmt = stmt.where(clause) + return (await session.execute(stmt)).scalar_one() + + +@router.get("/leagues", response_model=list[LeagueOut], tags=["catalog"], summary="List leagues") async def list_leagues(session: AsyncSession = Depends(get_session)): - rows = (await session.execute(select(models.League))).scalars().all() - return [ - {"id": r.id, "slug": r.slug, "name": r.name, "region": r.region, "image": r.image} - for r in rows - ] + rows = ( + (await session.execute(select(models.League).order_by(models.League.name))).scalars().all() + ) + return [LeagueOut.model_validate(r) for r in rows] -@router.get("/matches") +@router.get("/matches", response_model=Page[MatchOut], tags=["matches"], summary="List matches") async def list_matches( - status: str | None = Query(default=None, description="unstarted | inProgress | completed"), - limit: int = Query(default=50, le=200), + status: str | None = Query(None, description="unstarted | inProgress | completed"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), session: AsyncSession = Depends(get_session), ): + where = [models.Match.status == status] if status else [] + total = await _count(session, models.Match, *where) stmt = select(models.Match) - if status: - stmt = stmt.where(models.Match.status == status) - rows = (await session.execute(stmt.limit(limit))).scalars().all() - return [ - { - "id": r.id, - "league_id": r.league_id, - "best_of": r.best_of, - "status": r.status, - "scheduled_at": r.scheduled_at, - "teams": [r.blue_code, r.red_code], - } + for clause in where: + stmt = stmt.where(clause) + rows = ( + ( + await session.execute( + stmt.order_by(models.Match.scheduled_at.desc()).limit(limit).offset(offset) + ) + ) + .scalars() + .all() + ) + items = [ + MatchOut( + id=r.id, + league_id=r.league_id, + best_of=r.best_of, + status=r.status, + scheduled_at=r.scheduled_at, + teams=[r.blue_code, r.red_code], + ) for r in rows ] + return Page[MatchOut](items=items, total=total, limit=limit, offset=offset) -@router.get("/matches/{match_id}") +@router.get( + "/matches/{match_id}", + response_model=MatchDetailOut, + tags=["matches"], + summary="Match details (with games)", +) async def get_match(match_id: str, session: AsyncSession = Depends(get_session)): match = await session.get(models.Match, match_id) if match is None: raise HTTPException(status_code=404, detail="match not found") games = ( - (await session.execute(select(models.Game).where(models.Game.match_id == match_id))) + ( + await session.execute( + select(models.Game) + .where(models.Game.match_id == match_id) + .order_by(models.Game.number) + ) + ) .scalars() .all() ) - return { - "id": match.id, - "status": match.status, - "teams": [match.blue_code, match.red_code], - "games": [ - {"id": g.id, "number": g.number, "patch": g.patch, "state": g.state} for g in games - ], - } - - -@router.get("/games/{game_id}/events") -async def game_events(game_id: str, session: AsyncSession = Depends(get_session)): + return MatchDetailOut( + id=match.id, + league_id=match.league_id, + best_of=match.best_of, + status=match.status, + scheduled_at=match.scheduled_at, + teams=[match.blue_code, match.red_code], + games=[GameOut.model_validate(g) for g in games], + ) + + +@router.get( + "/games/{game_id}/events", + response_model=Page[EventOut], + tags=["games"], + summary="Derived events for a game", +) +async def game_events( + game_id: str, + limit: int = Query(200, ge=1, le=1000), + offset: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + total = await _count(session, models.Event, models.Event.game_id == game_id) rows = ( ( await session.execute( select(models.Event) .where(models.Event.game_id == game_id) .order_by(models.Event.ts) + .limit(limit) + .offset(offset) ) ) .scalars() .all() ) - return [{"ts": e.ts, "type": e.type, "side": e.side, "info": e.info} for e in rows] + items = [EventOut.model_validate(e) for e in rows] + return Page[EventOut](items=items, total=total, limit=limit, offset=offset) -@router.get("/games/{game_id}/frames") +@router.get( + "/games/{game_id}/frames", + response_model=Page[FrameOut], + tags=["games"], + summary="Raw frames for a game", +) async def game_frames( game_id: str, - limit: int = Query(default=500, le=2000), + limit: int = Query(500, ge=1, le=2000), + offset: int = Query(0, ge=0), session: AsyncSession = Depends(get_session), ): + total = await _count(session, models.Frame, models.Frame.game_id == game_id) rows = ( ( await session.execute( @@ -103,31 +166,33 @@ async def game_frames( .where(models.Frame.game_id == game_id) .order_by(models.Frame.ts) .limit(limit) + .offset(offset) ) ) .scalars() .all() ) - return [ - { - "ts": f.ts, - "state": f.state, - "blue": { - "kills": f.blue_kills, - "towers": f.blue_towers, - "barons": f.blue_barons, - "inhibitors": f.blue_inhibitors, - "gold": f.blue_gold, - "dragons": f.blue_dragons, - }, - "red": { - "kills": f.red_kills, - "towers": f.red_towers, - "barons": f.red_barons, - "inhibitors": f.red_inhibitors, - "gold": f.red_gold, - "dragons": f.red_dragons, - }, - } + items = [ + FrameOut( + ts=f.ts, + state=f.state, + blue=TeamSideFrame( + kills=f.blue_kills, + towers=f.blue_towers, + barons=f.blue_barons, + inhibitors=f.blue_inhibitors, + gold=f.blue_gold, + dragons=f.blue_dragons or [], + ), + red=TeamSideFrame( + kills=f.red_kills, + towers=f.red_towers, + barons=f.red_barons, + inhibitors=f.red_inhibitors, + gold=f.red_gold, + dragons=f.red_dragons or [], + ), + ) for f in rows ] + return Page[FrameOut](items=items, total=total, limit=limit, offset=offset) diff --git a/app/db/models.py b/app/db/models.py index bc34915..10ae703 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -8,7 +8,7 @@ from datetime import datetime -from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, text from sqlalchemy.orm import Mapped, mapped_column from app.db.base import Base @@ -66,17 +66,17 @@ class Frame(Base): game_id: Mapped[str] = mapped_column(ForeignKey("games.id"), index=True) ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True) state: Mapped[str | None] = mapped_column(String) - blue_kills: Mapped[int] = mapped_column(Integer, default=0) - blue_towers: Mapped[int] = mapped_column(Integer, default=0) - blue_barons: Mapped[int] = mapped_column(Integer, default=0) - blue_inhibitors: Mapped[int] = mapped_column(Integer, default=0) - blue_gold: Mapped[int] = mapped_column(Integer, default=0) + blue_kills: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) + blue_towers: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) + blue_barons: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) + blue_inhibitors: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) + blue_gold: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) blue_dragons: Mapped[list | None] = mapped_column(JSON) - red_kills: Mapped[int] = mapped_column(Integer, default=0) - red_towers: Mapped[int] = mapped_column(Integer, default=0) - red_barons: Mapped[int] = mapped_column(Integer, default=0) - red_inhibitors: Mapped[int] = mapped_column(Integer, default=0) - red_gold: Mapped[int] = mapped_column(Integer, default=0) + red_kills: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) + red_towers: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) + red_barons: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) + red_inhibitors: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) + red_gold: Mapped[int] = mapped_column(Integer, default=0, server_default=text("0")) red_dragons: Mapped[list | None] = mapped_column(JSON) diff --git a/app/main.py b/app/main.py index 5fbe3a1..1ace8ff 100644 --- a/app/main.py +++ b/app/main.py @@ -9,17 +9,36 @@ from app.api.routes import router from app.db.base import init_db +TAGS_METADATA = [ + {"name": "catalog", "description": "Leagues, teams and players."}, + {"name": "matches", "description": "Matches and their games."}, + {"name": "games", "description": "Per-game live data: derived events and raw frames."}, +] + @asynccontextmanager async def lifespan(app: FastAPI): + # Dev convenience: create tables on startup (SQLite). In production, use the + # Alembic migrations (see `alembic/`) as the source of truth instead. await init_db() yield -app = FastAPI(title="esport-api", version="0.1.0", lifespan=lifespan) +app = FastAPI( + title="esport-api", + version="0.1.0", + description=( + "Self-hosted, independent live esports data API. Multi-game by design, " + "League of Legends first." + ), + license_info={"name": "MIT", "url": "https://github.com/Kwickos/esport-api/blob/main/LICENSE"}, + contact={"name": "esport-api", "url": "https://github.com/Kwickos/esport-api"}, + openapi_tags=TAGS_METADATA, + lifespan=lifespan, +) app.include_router(router) -@app.get("/health") +@app.get("/health", tags=["catalog"], summary="Health check") async def health(): return {"status": "ok"} diff --git a/app/schemas/api.py b/app/schemas/api.py new file mode 100644 index 0000000..009d051 --- /dev/null +++ b/app/schemas/api.py @@ -0,0 +1,80 @@ +"""Pydantic response models for the public REST API. + +Kept separate from the internal domain types (`schemas/domain.py`): these define +the *wire* shape returned to clients, decoupled from both the ORM and the sources. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Generic, TypeVar + +from pydantic import BaseModel, ConfigDict + +T = TypeVar("T") + + +class Page(BaseModel, Generic[T]): + """Generic pagination envelope.""" + + items: list[T] + total: int + limit: int + offset: int + + +class _ORM(BaseModel): + """Base for models populated directly from ORM rows.""" + + model_config = ConfigDict(from_attributes=True) + + +class LeagueOut(_ORM): + id: str + slug: str | None = None + name: str | None = None + region: str | None = None + image: str | None = None + + +class GameOut(_ORM): + id: str + number: int | None = None + patch: str | None = None + state: str | None = None + + +class MatchOut(_ORM): + id: str + league_id: str | None = None + best_of: int | None = None + status: str | None = None + scheduled_at: datetime | None = None + teams: list[str | None] + + +class MatchDetailOut(MatchOut): + games: list[GameOut] = [] + + +class EventOut(_ORM): + ts: datetime + type: str + side: str + info: str | None = None + + +class TeamSideFrame(BaseModel): + kills: int + towers: int + barons: int + inhibitors: int + gold: int + dragons: list[str] + + +class FrameOut(BaseModel): + ts: datetime + state: str | None = None + blue: TeamSideFrame + red: TeamSideFrame diff --git a/pyproject.toml b/pyproject.toml index a318bfb..bd4618c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "sqlalchemy>=2.0", "aiosqlite>=0.20", # Local DB by default (dev) "asyncpg>=0.29", # Postgres (prod) + "alembic>=1.13", # Schema migrations "pydantic>=2.6", "pydantic-settings>=2.2", ] @@ -59,4 +60,5 @@ ignore = ["B008"] [tool.pytest.ini_options] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..753f380 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""Pytest configuration: isolate the test database from the dev one.""" + +import os +import pathlib + +# Must be set before `app.config` is imported (an env var beats .env and defaults). +os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test_esport.db" + +import pytest # noqa: E402 + +from app.config import settings # noqa: E402 + +# Safety: never run the suite against a real database. +assert "test_esport" in settings.database_url, settings.database_url + +_DB = pathlib.Path("test_esport.db") + + +@pytest.fixture(scope="session", autouse=True) +def _clean_db(): + if _DB.exists(): + _DB.unlink() + yield + if _DB.exists(): + _DB.unlink() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..e6bb7b3 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,43 @@ +"""API endpoint tests: Pydantic response shapes + pagination envelope.""" + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.db import models +from app.db.base import SessionLocal, engine, init_db +from app.main import app + + +@pytest.fixture +async def client(): + await init_db() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + await engine.dispose() # fresh connections per test (avoids cross-loop reuse) + + +async def test_health(client): + assert (await client.get("/health")).json() == {"status": "ok"} + + +async def test_matches_returns_pagination_envelope(client): + body = (await client.get("/matches")).json() + assert body == {"items": [], "total": 0, "limit": 50, "offset": 0} + + +async def test_match_not_found(client): + assert (await client.get("/matches/does-not-exist")).status_code == 404 + + +async def test_events_pagination_params(client): + body = (await client.get("/games/g1/events?limit=10&offset=5")).json() + assert body == {"items": [], "total": 0, "limit": 10, "offset": 5} + + +async def test_league_roundtrip(client): + async with SessionLocal() as session: + session.add(models.League(id="L1", slug="lck", name="LCK", region="KR")) + await session.commit() + data = (await client.get("/leagues")).json() + assert {"id": "L1", "slug": "lck", "name": "LCK", "region": "KR", "image": None} in data