# FastAPI Backend Patterns — Auth, Leagues, Snake Draft, Scoring Built Jul 8, 2026 for the Shark Attack Fantasy Game. Monolithic `server.py` (no routers/blueprints), SQLite, bcrypt+JWT, league system with snake draft, and event-based scoring with auto-recalculation. ## Architecture Pattern A single-file FastAPI app (`server.py`) containing everything: ``` server.py ← All models, routes, DB setup, auth, helpers seed.py ← Standalone region/schema seeder (optional, built-in init also works) requirements.txt ← fastapi, uvicorn, bcrypt, pyjwt venv/ ← Python virtualenv game.db ← SQLite database (created on first startup) ``` **Rationale for single-file:** For services with < 20 endpoints, a monolithic file avoids import overhead, is easier to scroll/debug, and can be split later when it crosses ~800 lines. ## Database Pattern (SQLite + sqlite3) Raw `sqlite3` — no ORM. Schema created via `executescript()` at startup: ```python def get_db() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH) conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = sqlite3.Row return conn ``` **Key choices:** - `sqlite3.Row` row factory — dict-compatible access, `dict(row)` for serialization - WAL mode for concurrent reads - Foreign keys enforced at DB level - `executescript()` for multi-statement DDL at init - New `get_db()` connection per request (cheap with SQLite + WAL) - Always `conn.close()` after each request in sync handlers ## Auth Pattern (bcrypt + PyJWT) ```python import bcrypt, jwt def hash_password(password: str) -> str: return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() def verify_password(password: str, password_hash: str) -> bool: return bcrypt.checkpw(password.encode(), password_hash.encode()) def create_token(user_id: int) -> str: payload = { "user_id": user_id, "exp": datetime.utcnow() + timedelta(days=30), "iat": datetime.utcnow(), } return jwt.encode(payload, JWT_SECRET, algorithm="HS256") async def get_current_user(authorization: Optional[str] = Header(None)) -> dict: if not authorization or not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing or invalid authorization header") token = authorization.split(" ", 1)[1] payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) user = get_db().execute("SELECT id, email, display_name FROM users WHERE id = ?", (payload["user_id"],)).fetchone() if not user: raise HTTPException(status_code=401, detail="User not found") return dict(user) ``` **Pattern notes:** - `Depends(get_current_user)` on any protected route — returns a `dict` of db row - Token expiry set at 30 days, tracked as `exp` claim - JWT_SECRET should come from env var; dev fallback is fine - No session refresh endpoint (can add later if needed) ## League + Membership Pattern (join tables) Three tables for the league system: ```sql leagues (id, name, invite_code, status, season_start, season_end, created_by, created_at) league_members (id, league_id, user_id, draft_order, is_commissioner) draft_picks (id, league_id, user_id, region_id, round, pick_number) ``` **Invite code generation:** ```python import secrets def generate_invite_code() -> str: return secrets.token_hex(4).upper() # 8-char hex, retry on collision ``` **Commissioner check pattern:** ```python def get_commissioner_membership(conn, league_id, user_id): member = conn.execute( "SELECT * FROM league_members WHERE league_id = ? AND user_id = ? AND is_commissioner = 1", (league_id, user_id), ).fetchone() if not member: raise HTTPException(status_code=403, detail="Only the commissioner can perform this action") ``` ## Snake Draft Logic ```python def get_current_round_and_pick(conn, league_id, member_count): picks_made = conn.execute( "SELECT COUNT(*) FROM draft_picks WHERE league_id = ?", (league_id,) ).fetchone()[0] if picks_made >= member_count * 12: # max rounds return 13, None, True # draft complete current_round = (picks_made // member_count) + 1 pick_in_round = picks_made % member_count members = conn.execute( """SELECT lm.user_id, u.display_name FROM league_members lm JOIN users u ON u.id = lm.user_id WHERE lm.league_id = ? ORDER BY lm.draft_order ASC""", (league_id,), ).fetchall() if current_round % 2 == 1: next_user = members[pick_in_round] # forward order (odd rounds) else: next_user = members[member_count - 1 - pick_in_round] # reverse (even rounds) return current_round, dict(next_user) if next_user else None, False ``` **Snake reversal:** Odd rounds = natural `draft_order ASC`, even rounds = iterate backwards. Member count must be known to compute the reverse index. **Turn enforcement:** Before allowing a pick, compare `next_pick["user_id"]` against the requesting user's ID. Return 403 if mismatch. **Duplicate protection:** `UNIQUE(league_id, region_id)` constraint on `draft_picks` table, with a 409 check before insertion. **Draft status flow:** `draft → active → closed`. Commissioner-only start transition. Draft start requires ≥2 members. ## Score Event System Events table plus materialized user scores: ```sql scores (id, region_id, event_type, description, source_url, points, event_date, verified, created_at) user_scores (user_id, league_id, total_points) -- PRIMARY KEY (user_id, league_id) ``` **Point values:** sighting=2 (changed from 1), bite=5, fatality=10 **Auto-recalculation:** Every time a score event is ingested, recalculate for all leagues that drafted that region: ```python def recalculate_user_scores(conn, league_id): conn.execute("DELETE FROM user_scores WHERE league_id = ?", (league_id,)) users = conn.execute("SELECT DISTINCT user_id FROM draft_picks WHERE league_id = ?", (league_id,)).fetchall() for user in users: total = conn.execute( """SELECT COALESCE(SUM(s.points), 0) FROM scores s JOIN draft_picks dp ON s.region_id = dp.region_id WHERE dp.league_id = ? AND dp.user_id = ?""", (league_id, user["user_id"]), ).fetchone()[0] conn.execute("INSERT INTO user_scores (...) VALUES (?, ?, ?)", (user_id, league_id, total)) ``` **Important:** Recalculation happens on every score ingestion for every affected league. With small datasets (dozens of users, hundreds of events) this is fine — the full table DELETE + INSERT loop is sub-100ms. For larger scale, switch to incremental updates or a background task. ## Verification / Testing Pattern Standalone test scripts (no pytest) are preferred for fast iteration: ```python def api(method, path, data=None, token=None): url = f"{BASE}{path}" body = json.dumps(data).encode() if data else None req = urllib.request.Request(url, data=body, method=method) req.add_header("Content-Type", "application/json") if token: req.add_header("Authorization", f"Bearer {token}") try: with urllib.request.urlopen(req) as resp: return json.loads(resp.read()) except urllib.error.HTTPError as e: return {"error": e.code, "detail": json.loads(e.read())} ``` This avoids pytest dependency and works with any Python. The `error` / `detail` pattern in the return value mirrors the FastAPI error shape and lets checks be uniform: `check(result.get("error") == 403, "non-commissioner blocked")`. ## Startup Pattern ```python @app.on_event("startup") def on_startup(): init_db() seed_regions() ``` Deprecated in newer FastAPI but works fine. Use `lifespan` context manager for new projects: ```python from contextlib import asynccontextmanager @asynccontextmanager async def lifespan(app: FastAPI): init_db() seed_regions() yield app = FastAPI(lifespan=lifespan) ``` ## Pitfalls - **`sqlite3` doesn't autocommit DML** — always call `conn.commit()` after INSERT/UPDATE/DELETE. - **`PRAGMA foreign_keys=ON` must be set per connection**, not just at schema creation. - **JWT secret in code is a dev pattern** — always override via `JWT_SECRET` env var in production. - **Auto-recalculation costs scale with number of leagues × drafted regions** — for 100+ simultaneous leagues, move to an event queue + batch recalculation. - **FastAPI sync vs async** — all handlers above use sync DB calls. FastAPI runs sync `def` handlers in a thread pool, which is fine for SQLite. Use `async def` only when the endpoint does IO (HTTP calls, SSE streaming). - **`sqlite3.Row` objects are not JSON-serializable** — call `dict(row)` before returning. - **`Pydantic v1 vs v2`** — modern FastAPI uses Pydantic v2. Use `BaseModel` from `pydantic` (not `pydantic.v1`). String `Field(min_length=...)` works in both.