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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -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
61 changes: 61 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -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())
27 changes: 27 additions & 0 deletions alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -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"}
109 changes: 109 additions & 0 deletions alembic/versions/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading