Add FastAPI catalog backend (games/snapshots/diff) + tests

Modular-monolith backend over SQLAlchemy (SQLite by default, Postgres-ready
via DATABASE_URL). The full snapshot.json is stored verbatim; diffing reads it
back through the ams.diff engine, so the DB never mirrors the snapshot schema.

- ams.api.db/models/schemas/service : Game 1-N Snapshot, sha256-deduped upsert
- routes: POST/GET /games, POST/GET /snapshots (import, deduped), GET /diff
          (?old&new[&owner]) running compute_diff on stored snapshots, /health
- ams.api.importer : bulk CLI loader (python -m ams.api.importer --game ...)
- run: uvicorn ams.api.app:create_app --factory

11 tests pass (6 diff + 5 API via TestClient over the golden pair). Smoke-tested
live on uvicorn: import -> /snapshots -> /diff returns the BlooMoo deltas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Patryk Gensch
2026-05-30 22:27:24 +02:00
parent 6885bbee3d
commit 8386196653
14 changed files with 481 additions and 2 deletions

View File

@@ -0,0 +1,41 @@
from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.orm import Session
from .. import models, schemas, service
from ..db import get_db
router = APIRouter(prefix="/snapshots", tags=["snapshots"])
@router.post("", response_model=schemas.SnapshotOut, status_code=201)
def create_snapshot(
data: dict[str, Any] = Body(..., description="a full engine-surface snapshot.json"),
game: str | None = Query(None, description="link to / create this game by name"),
db: Session = Depends(get_db),
) -> models.Snapshot:
if not service.looks_like_snapshot(data):
raise HTTPException(422, "body is not an engine-surface snapshot (missing binary/types)")
return service.import_snapshot(db, data, game)
@router.get("", response_model=list[schemas.SnapshotOut])
def list_snapshots(
game_id: int | None = Query(None), db: Session = Depends(get_db)
) -> list[models.Snapshot]:
q = select(models.Snapshot)
if game_id is not None:
q = q.where(models.Snapshot.game_id == game_id)
return list(db.scalars(q.order_by(models.Snapshot.id)))
@router.get("/{snapshot_id}", response_model=schemas.SnapshotDetail)
def get_snapshot(snapshot_id: int, db: Session = Depends(get_db)) -> models.Snapshot:
snap = db.get(models.Snapshot, snapshot_id)
if snap is None:
raise HTTPException(404, "snapshot not found")
return snap