From f6f3cca2df38a99123e46bfb6e48e7cd1e01fce5 Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 12:42:20 -0500 Subject: [PATCH 01/11] docs(api): spec + plan for warehouse read API (games slice) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-16-warehouse-api-games.md | 297 ++++++++++++++++++ .../2026-07-16-game-detail-api-design.md | 111 +++++++ ...-warehouse-services-architecture-design.md | 159 ++++++++++ 3 files changed, 567 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-warehouse-api-games.md create mode 100644 docs/superpowers/specs/2026-07-16-game-detail-api-design.md create mode 100644 docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md diff --git a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md new file mode 100644 index 0000000..8f96c47 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md @@ -0,0 +1,297 @@ +# Warehouse Read API — Skeleton + Games Router Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up `services/warehouse_api/` (modular-monolith FastAPI read API) with a +`/health` endpoint and a `games` router serving `GET /games/{game_id}` and its +per-block sub-resources, backed by a pure, tested reader layer in +`src/warehouse/readers/`. Deploy it authenticated to Cloud Run. + +**Architecture:** A reader layer (`src/warehouse/readers/games.py`, pure functions over +BigQuery, dependency-injected client for testability) holds the query logic; a thin +FastAPI app (`services/warehouse_api/`) mounts one router per resource and shapes +responses. This PR delivers the skeleton + the `games` router only; other resource +routers and the front-end repoint are separate slices. + +**Tech Stack:** Python 3.12, FastAPI + uvicorn, Google BigQuery, Cloud Run, Cloud +Build, GitHub Actions. + +**Specs:** +- `docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md` +- `docs/superpowers/specs/2026-07-16-game-detail-api-design.md` + +**Scope (this plan):** config datasets · BigQuery client helper · `games` reader +(get-by-id + blocks) · FastAPI skeleton + `/health` + auth · `games` router · Docker + +local run · Cloud Build + deploy workflow. + +**Out of scope (later slices):** `GET /games` list/search/new/summary; publishers / +designers / other resource routers; `clusterBy game_id` on serving tables; the +`bgg-dash-viewer` repoint (separate PR). + +--- + +## Branching & delivery + +**Never commit to `main`.** All work lands via a PR — repo convention is feature +branches squash-merged to `main` with the PR number in the subject (e.g. `… (#85)`), +conventional-commit style (`feat(api): …`). + +- **Create the branch before Task 1** (off latest `main`): + `git switch main && git pull && git switch -c feature/warehouse-api-games`. +- **Every task's commits land on this branch.** They accumulate as review history and + give per-step rollback; they collapse on squash-merge. +- **One PR for this whole increment** (skeleton + games router + deploy). It's one + coherent feature, and the deployed service is inert until the front-end repoint, so + there's no risk in shipping it together. Split into a skeleton PR + a deploy PR only + if review gets unwieldy. +- **Cross-repo:** the `bgg-dash-viewer` repoint (Follow-up 1) is a **separate branch and + PR in that repo** — it must not ride on this branch. + +--- + +## File Structure + +| Action | File | Responsibility | +|--------|------|----------------| +| Modify | `config/bigquery.yaml` | Add `predictions`, `analytics` to `datasets:` | +| Modify | `src/config.py` | Surface new dataset keys (unchanged signature) | +| Create | `src/warehouse/__init__.py` | Package marker | +| Create | `src/warehouse/bq.py` | `get_client()` + `dataset(name)` resolver helper | +| Create | `src/warehouse/readers/__init__.py` | Package marker | +| Create | `src/warehouse/readers/games.py` | Per-block reader fns + `get_game()` aggregator | +| Create | `tests/test_config_datasets.py` | Assert new datasets present in config | +| Create | `tests/test_games_reader.py` | Reader unit tests (mocked `bigquery.Client`) | +| Create | `services/warehouse_api/__init__.py` | Package marker | +| Create | `services/warehouse_api/main.py` | FastAPI app, `/health`, mounts routers | +| Create | `services/warehouse_api/auth.py` | `GCPAuthenticator` (copied pattern) | +| Create | `services/warehouse_api/routers/__init__.py` | Package marker | +| Create | `services/warehouse_api/routers/games.py` | `games` `APIRouter` | +| Create | `tests/test_games_router.py` | Router tests via FastAPI `TestClient` (reader mocked) | +| Create | `services/warehouse_api/Dockerfile` | uv-based image (model: `docker/Dockerfile.pipeline`) | +| Create | `services/warehouse_api/pyproject.toml` | Service-local deps (fastapi, uvicorn) | +| Modify | `pyproject.toml` | Add `api` optional-dependency extra (fastapi, uvicorn, httpx) | +| Modify | `config/cloudbuild.yaml` | Add `bgg-warehouse-api` Cloud Run service build/deploy | +| Create | `.github/workflows/deploy-warehouse-api.yml` | Deploy on changes to the service | + +--- + +### Task 1: Dataset config + +**Files:** Modify `config/bigquery.yaml`, `src/config.py`; Create `tests/test_config_datasets.py` + +- [ ] **Step 1: Write the failing test** — `tests/test_config_datasets.py`: + +```python +from src.config import get_bigquery_config + +def test_datasets_include_serving_layers(): + datasets = get_bigquery_config()["datasets"] + for name in ("core", "raw", "predictions", "analytics"): + assert name in datasets +``` + +- [ ] **Step 2: Run it, watch it fail** — `uv run --extra test python -m pytest tests/test_config_datasets.py -v` → FAIL (`predictions`/`analytics` missing). +- [ ] **Step 3: Add datasets** to `config/bigquery.yaml`: + +```yaml +datasets: + core: core + raw: raw + predictions: predictions + analytics: analytics +``` + +(`src/config.py` needs no change — `get_bigquery_config` already returns `config["datasets"]`; confirm.) + +- [ ] **Step 4: Run it, watch it pass.** +- [ ] **Step 5: Commit** — `feat(api): add predictions/analytics datasets to config` + +--- + +### Task 2: BigQuery client helper + +**Files:** Create `src/warehouse/__init__.py`, `src/warehouse/bq.py` + +- [ ] **Step 1: Write the helper** — `src/warehouse/bq.py`: + +```python +"""Shared BigQuery access for warehouse readers.""" +from functools import lru_cache +from google.cloud import bigquery +from src.config import get_bigquery_config + +@lru_cache(maxsize=1) +def _cfg(): + return get_bigquery_config() + +def get_client() -> bigquery.Client: + return bigquery.Client(project=_cfg()["project"]["id"]) + +def dataset(name: str) -> str: + """Fully-qualified `project.dataset` for a configured dataset key.""" + c = _cfg() + return f'{c["project"]["id"]}.{c["datasets"][name]}' +``` + +- [ ] **Step 2: Verify import** — `uv run python -c "from src.warehouse.bq import dataset; print('ok')"`. +- [ ] **Step 3: Commit** — `feat(api): add warehouse BigQuery client/dataset helper` + +--- + +### Task 3: Games reader (get-by-id) + +**Files:** Create `src/warehouse/readers/__init__.py`, `src/warehouse/readers/games.py`, `tests/test_games_reader.py` + +Reader functions take an injected `client` (default `get_client()`) so tests mock the +`bigquery.Client`. Blocks: `get_features`, `get_predictions`, `get_embedding`, +`get_similar`, `get_provenance`, and `get_game()` aggregating them (returns `None` if +the game has no features row). + +- [ ] **Step 1: Write failing tests** — `tests/test_games_reader.py`. Mock a + `bigquery.Client` whose `.query(...).result()` returns canned rows; assert each block + function issues a query referencing the expected table and returns the shaped dict, + and that `get_game(13)` composes `{game_id, features, predictions, embedding, similar, + provenance}`. Assert `get_game()` returns `None` when the features query is empty. + +- [ ] **Step 2: Run, watch fail** — `uv run --extra test python -m pytest tests/test_games_reader.py -v` → FAIL (module missing). + +- [ ] **Step 3: Implement** `src/warehouse/readers/games.py` — parameterized queries + (`@game_id`) against `dataset("analytics")` / `dataset("predictions")` tables per the + spec's data contract; `get_similar` uses `ML.DISTANCE` over + `analytics.game_similarity_search`. No f-string interpolation of `game_id` into SQL — + use `bigquery.ScalarQueryParameter`. + +- [ ] **Step 4: Run, watch pass.** +- [ ] **Step 5: Smoke against real BigQuery** (needs creds) — + `uv run python -c "from src.warehouse.readers.games import get_game; import json; print(json.dumps(get_game(13), default=str)[:400])"` + → a populated document for Catan. +- [ ] **Step 6: Commit** — `feat(api): add games reader (get_game by id)` + +--- + +### Task 4: FastAPI skeleton + health + auth + +**Files:** Create `services/warehouse_api/{__init__.py, main.py, auth.py}`; Modify root `pyproject.toml` + +- [ ] **Step 1: Add the `api` extra** to root `pyproject.toml`: + +```toml +[project.optional-dependencies] +api = ["fastapi>=0.115", "uvicorn>=0.30", "httpx>=0.27"] +``` + +Then `uv lock`. + +- [ ] **Step 2: Copy `auth.py`** — port `GCPAuthenticator` from + `bgg-predictive-models/services/scoring/auth.py` (strip bucket-specific bits not + needed for a read API). + +- [ ] **Step 3: Write `services/warehouse_api/main.py`** — `FastAPI(title="BGG + Warehouse API")`, `GET /health` returning `{"status": "ok"}`, and mount the games + router (Task 5) under `/games`. `uvicorn.run(app, host="0.0.0.0", port=8080)` in + `__main__`. + +- [ ] **Step 4: Write failing health test** — `tests/test_games_router.py` (health part): + +```python +from fastapi.testclient import TestClient +from services.warehouse_api.main import app + +def test_health(): + assert TestClient(app).get("/health").json() == {"status": "ok"} +``` + +- [ ] **Step 5: Run, watch pass** — `uv run --extra test --extra api python -m pytest tests/test_games_router.py -v`. +- [ ] **Step 6: Commit** — `feat(api): FastAPI warehouse-api skeleton with /health` + +--- + +### Task 5: Games router + +**Files:** Create `services/warehouse_api/routers/{__init__.py, games.py}`; extend `tests/test_games_router.py` + +- [ ] **Step 1: Write failing router tests** — mock + `services.warehouse_api.routers.games.get_game` to return a canned doc; assert + `GET /games/13` → 200 + doc, `GET /games/999999` (reader returns `None`) → 404, and one + sub-resource (`GET /games/13/predictions`) → the predictions block. + +- [ ] **Step 2: Run, watch fail.** + +- [ ] **Step 3: Implement** `routers/games.py` — `APIRouter`; `GET /{game_id}` calls + `get_game`, raises `HTTPException(404)` on `None`; sub-resource routes + (`/{game_id}/predictions|features|similar|embedding|players|provenance`) call the + matching block function. Mount it in `main.py`. + +- [ ] **Step 4: Run, watch pass.** +- [ ] **Step 5: Local run** — `uv run --extra api uvicorn services.warehouse_api.main:app --port 8080`, + then `curl localhost:8080/health` → ok and `curl localhost:8080/games/13` → populated (needs creds). +- [ ] **Step 6: Commit** — `feat(api): add games router (get-by-id + sub-resources)` + +--- + +### Task 6: Container + service pyproject + +**Files:** Create `services/warehouse_api/{Dockerfile, pyproject.toml, README.md}` + +- [ ] **Step 1: Write `services/warehouse_api/pyproject.toml`** — minimal project with + `fastapi`, `uvicorn`, `google-cloud-bigquery`, `pyyaml`, `google-auth`. +- [ ] **Step 2: Write `Dockerfile`** — model on `docker/Dockerfile.pipeline` (uv base, + copy repo, `uv sync`), `CMD ["uvicorn", "services.warehouse_api.main:app", "--host", "0.0.0.0", "--port", "8080"]`. +- [ ] **Step 3: Build locally** — `docker build -f services/warehouse_api/Dockerfile -t warehouse-api .` → succeeds; `docker run -p 8080:8080` + `curl /health` → ok. +- [ ] **Step 4: Commit** — `feat(api): containerize warehouse-api` + +--- + +### Task 7: Deploy (Cloud Build + workflow) + +**Files:** Modify `config/cloudbuild.yaml`; Create `.github/workflows/deploy-warehouse-api.yml` + +- [ ] **Step 1: Add a `bgg-warehouse-api` Cloud Run *service*** block to + `config/cloudbuild.yaml` — build/push the image, then `gcloud run deploy + bgg-warehouse-api --region us-central1 --no-allow-unauthenticated + --service-account=bgg-data-warehouse@$PROJECT_ID.iam.gserviceaccount.com` + (authenticated per spec). +- [ ] **Step 2: Validate YAML** — `python -c "import yaml; yaml.safe_load(open('config/cloudbuild.yaml'))" && echo valid`. +- [ ] **Step 3: Create `.github/workflows/deploy-warehouse-api.yml`** — mirror + `deploy.yml` (auth with `GCP_SA_KEY_BGG_DW`, `gcloud builds submit`), triggered on + `push` to `main` touching `services/warehouse_api/**`, `src/warehouse/**`, plus + `workflow_dispatch`. +- [ ] **Step 4: Validate YAML.** +- [ ] **Step 5: Commit** — `ci(api): deploy warehouse-api to Cloud Run` +- [ ] **Step 6: Post-deploy check** (after merge) — deployed `/health` → 200 (with an + ID token); unauthenticated → 403; `/games/13` → real data. + +--- + +### Task 8: Open the PR + +- [ ] **Step 1: Push the branch** — `git push -u origin feature/warehouse-api-games`. +- [ ] **Step 2: Open the PR** against `main` — + `gh pr create --base main --title "feat(api): warehouse read API skeleton + games router" --body ""`. +- [ ] **Step 3: CI green** — the existing test workflow passes on the PR. +- [ ] **Step 4: After merge**, run the post-deploy checks (Task 7 Step 6) against the + deployed service. + +--- + +## Follow-up (separate PRs / plans) + +1. **`bgg-dash-viewer` repoint** — `warehouse_api_client.py` + `WAREHOUSE_API_URL`; + rewrite `game_details.py` to call the API; delete the game SQL from + `bigquery_client.py`. Verify the game page renders identically. +2. **Games list/search slice** — `GET /games`, `/games/search`, `/games/new`, `/games/summary`. +3. **`clusterBy game_id`** on `games_features` / `bgg_predictions` / + `game_similarity_search` (needs full-refresh — see dataform-incremental-schema-drift). +4. **Remaining resource routers** — publishers/designers/…, predictions, collections, + similarity, experiments, monitoring. + +## Risks / rollback + +- **Additive & reversible:** the API is unused until the dash-viewer repoint (follow-up + 1); nothing here touches existing pipelines, Dataform, or schemas. Revert = delete the + service dir + workflow. +- **Cost:** unclustered serving tables mean each `/games/{id}` call full-scans + `games_features` etc. Acceptable at low traffic; the `clusterBy` follow-up removes it. + Watch bytes-scanned in the Task 3 smoke test. +- **Auth wiring** is the one deploy-time unknown — confirm the dash-viewer service + account can mint an ID token for the warehouse-api service before the repoint. diff --git a/docs/superpowers/specs/2026-07-16-game-detail-api-design.md b/docs/superpowers/specs/2026-07-16-game-detail-api-design.md new file mode 100644 index 0000000..b8717b9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-game-detail-api-design.md @@ -0,0 +1,111 @@ +# Games Resource — First Slice of the Warehouse Read APIs + +> Part of the warehouse services architecture — see +> [2026-07-16-warehouse-services-architecture-design.md](2026-07-16-warehouse-services-architecture-design.md). +> "Games" is the **`games` router inside the single `services/warehouse_api/` app**, +> not a standalone service. This is rollout step 2 (the first real resource slice). + +## Problem + +The front-end (`bgg-dash-viewer`) builds its `/app/game/` page — and its game +search, ratings, and new-games pages — by reaching directly into BigQuery through +hand-written SQL in `src/data/bigquery_client.py` and inline in Dash callbacks. This +entangles back-end data access with the front-end: the UI holds BigQuery credentials +and owns the query logic, and any other consumer wanting game data re-implements the +same joins. There is no reusable, decoupled way to ask the warehouse for a game. + +## Solution + +The `games` router of `services/warehouse_api/` (the modular-monolith read API defined +in the architecture spec), reading the warehouse's already-materialized per-game +tables. The query logic lives in `src/warehouse/readers/games.py` (pure, +unit-testable); the router is a thin FastAPI shell. The front-end deletes its game SQL +and becomes a pure HTTP consumer. + +This is the **live-query** approach: a handful of small point-lookups per request, +composed. It deliberately does *not* pre-materialize a `game_profile` table — see +*Deferred*. + +## Endpoints (games router) + +- `GET /games/{game_id}` — full profile: features + current/historical predictions + + embedding coordinates + provenance. +- `GET /games/{game_id}/predictions | /features | /similar | /embedding | /players | /provenance` + — per-block, so the front-end can lazy-load. +- `GET /games` — list / filter / sort / paginate. +- `GET /games/search?q=` , `GET /games/new` , `GET /games/summary`. + +## Components + +### 1. Reader — `src/warehouse/readers/games.py` + +One function per block plus a `get_game(game_id)` aggregator, reusing the shared +BigQuery client factory and dataset config: + +- `get_features(game_id)` — game record + player-count recommendations +- `get_predictions(game_id)` — current prediction + history + `first_prediction_ts` +- `get_embedding(game_id)` — embedding coordinates +- `get_similar(game_id, n)` — nearest neighbours computed **in-warehouse** from + `analytics.game_similarity_search` (`ML.DISTANCE`); no call-out to + `bgg-predictive-models` (the table already lives in this project) +- `get_provenance(game_id)` — fetch/load timestamps ("when we pulled this") +- `list_games(...)`, `search_games(q)`, `new_games(...)`, `summary()` — the list surface + +### 2. Router — `services/warehouse_api/routers/games.py` + +FastAPI `APIRouter` mounting the endpoints above; calls the reader, shapes pydantic +responses, `404` on missing id. Mounted by `services/warehouse_api/main.py`. + +### 3. Shared plumbing (from the architecture skeleton, not games-specific) + +Config extension (`predictions`/`analytics` datasets), `auth.py` (Cloud Run IAM + ID +token), `Dockerfile` (uv-based, modeled on `docker/Dockerfile.pipeline`), +`cloudbuild.yaml`, and `deploy-warehouse-api.yml`. + +### 4. Front-end consumer — separate PR in `bgg-dash-viewer` + +- `src/data/warehouse_api_client.py` — thin `requests` wrapper (same shape as the + existing `ServiceSimilarityClient`), attaches the ID token. +- `WAREHOUSE_API_URL` in `src/config.py`. +- Rewrite `src/layouts/game_details.py` and the game-search/new-games callbacks to + call the API; delete the game SQL from `src/data/bigquery_client.py` and the inline + callback SQL it replaces. + +## Data contract + +`GET /games/{game_id}` composes blocks sourced from: + +| Block | Source (dataset.table) | +|---|---| +| `features` | `analytics.games_features`, `analytics.player_count_recommendations`, `analytics.best_player_counts` | +| `predictions` (current) | `predictions.bgg_predictions` | +| `predictions.history` + `first_prediction_ts` | `predictions.game_first_prediction` | +| `embedding` / `coordinates` | `predictions.bgg_game_coordinates` | +| `similar` | `analytics.game_similarity_search` (`ML.DISTANCE`) | +| `provenance` | `raw.fetched_responses` / load-timestamp tables | + +## Validation + +- `tests/test_game_reader.py` — mocked `bigquery.Client`; each block function targets + the right table and shapes its payload. +- Local `uvicorn`: `curl /health` → 200; `curl /games/13` (Catan) → populated doc. +- Deployed: `/health` → 200; `/games/13` → real data; unauthenticated call → 403. +- Front-end: `/app/game/` renders identically off the API; no game SQL remains in + `bigquery_client.py`. + +## What this doesn't change + +- **No live inference** — serves already-materialized results only. +- **No new Dataform models, no schema migration, no backfill** — reads existing tables + (adding `clusterBy game_id` to serving tables is the architecture-level perf change, + tracked there, not here). +- **Other dash-viewer pages** (similarity, collections, experiments, monitoring) move + in later slices — out of scope here. +- **No write endpoints.** + +## Deferred + +If the per-request fan-out proves too costly/slow, introduce a Dataform incremental +`analytics.game_profile` model — one pre-joined row per game with top-N neighbours as +an array — and collapse `GET /games/{id}` to a single clustered lookup. Documented, +not built in this iteration. diff --git a/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md b/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md new file mode 100644 index 0000000..7befb2a --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md @@ -0,0 +1,159 @@ +# Warehouse Read APIs — Services Architecture + +## Problem + +`bgg-dash-viewer` reads warehouse data through hand-written BigQuery SQL — spread +across its `src/data/` layer *and* inline inside Dash callbacks (ratings scatter, +similarity dropdown/name-search/feature-cards, all monitoring/catalog queries). +Every consumer that wants warehouse data re-implements the joins and holds BigQuery +credentials. There is no coherent, reusable way to ask the warehouse for data. + +We want a set of **read APIs owned by `bgg-data-warehouse`** — the project that owns +the data — so front-ends and other consumers become pure HTTP clients. This document +defines the architecture for that services layer. Individual endpoints (games, +publishers, …) are *slices* of it, each specced and shipped separately. + +## The shape of the data (why the design is what it is) + +The warehouse is a **game-centric star schema**: + +- **`games` is the aggregate root.** Nearly every serving table is one row per + `game_id`: `analytics.games_active`, `analytics.games_features`, + `predictions.bgg_predictions`, `analytics.game_similarity_search`, + `predictions.bgg_game_coordinates`, … +- **Publishers / designers / artists / families / categories / mechanics** are + `core` **dimension** tables (`id` + `name`) plus `core` **bridge** tables + (`game_id` ↔ `entity_id`). There are **no independent per-entity fact tables**. + Their primary operation is therefore *"list the games for this entity"* (a bridge + join), plus a `game_count` facet. The only per-entity aggregates that exist are the + truncated `analytics.filter_*` tables — and those cover only publishers, designers, + categories, mechanics (**not** artists or families). +- **ML outputs** (predictions, embeddings, similarity, coordinates) are materialized + per-game in `predictions.*` / `analytics.*` — thin dedup wrappers over + `bgg-predictive-models`'s outputs. +- **Experiments** live in GCS (`bgg-predictive-models` bucket); **monitoring/catalog** + come from `INFORMATION_SCHEMA`. + +This shape dictates the resource model: `games` is primary; entity endpoints are +mostly `→ games` projections. + +## Design principles + +1. **Warehouse owns the API.** Data lives here; the front-end becomes a consumer. +2. **Readers over routers.** The reusable asset is a query/repository layer, not the + HTTP shell. +3. **One deployable, many routers** (modular monolith). Uniform light deps, one + auth/config/deploy. +4. **Serve materialized results only** — no live inference (that stays in + `bgg-predictive-models`). +5. **Consistent conventions** (URLs, pagination, auth, errors) so a new resource is + cheap to add. +6. **Read-only.** + +## Architecture + +### Layering + +- **`src/warehouse/readers/`** — one module per resource (`games.py`, + `publishers.py`, `designers.py`, `predictions.py`, `collections.py`, + `similarity.py`, `experiments.py`, `monitoring.py`). Pure functions: params in, + dicts / pydantic models out. **No FastAPI.** Reusable by the API, notebooks, and + pipelines. This is where dash-viewer's scattered SQL is consolidated and becomes the + single source of truth. +- **`services/warehouse_api/`** — the FastAPI app. `main.py` mounts one `APIRouter` + per resource; routers call readers and shape responses. Thin, disposable glue. + Also `auth.py`, `Dockerfile`, `cloudbuild.yaml`, `pyproject.toml`. +- **Shared** — BigQuery client factory + dataset config (`src/config.py` extended), + pydantic response models, pagination + error helpers. + +### Structure decision: modular monolith, not service-per-entity + +One `services/warehouse_api/` deployable with a router per resource — **not** +`services/games/`, `services/publishers/`, … The `bgg-predictive-models` repo split +into four services because each carried heavy, *different* ML dependencies (embedding +models, scoring models) with real isolation value. A read API has uniform, light +dependencies (BigQuery client + FastAPI) over one backend project; splitting would +multiply deploy / auth / cold-start / config for zero isolation benefit. Routers keep +it modular; if one resource ever needs isolation, it lifts out cleanly. + +### Resource model (URLs, `/v1` prefix) + +- **Games** (aggregate root): + - `GET /games` — list / filter / sort / paginate (rating, year, complexity, + publisher/designer/category/mechanic, player count) + - `GET /games/{id}` — full profile (features + predictions + embedding + provenance) + - `GET /games/{id}/predictions | /similar | /embedding | /players | /provenance` + - `GET /games/search?q=` , `GET /games/new` , `GET /games/summary` +- **Entity resources** (dimension + bridge): + - `GET /publishers` , `GET /publishers/{id}` , `GET /publishers/{id}/games` + - same for `/designers`, `/artists`, `/families`, `/categories`, `/mechanics` + - `GET /filters` — combined facet options +- **Predictions:** `GET /predictions` (filters) , `GET /predictions/summary` +- **Collections:** `GET /collections` (usernames) , `GET /collections/{username}` +- **Similarity / embeddings:** `GET /games/{id}/similar` , `POST /similar` (by set) , + `GET /embeddings/info` +- **Experiments** (GCS): `GET /experiments` , + `GET /experiments/{type}/{name}/{version}` + sub-resources +- **Meta:** `GET /health` , `GET /meta/monitoring` , `GET /meta/catalog` + +### Cross-cutting conventions + +- **Pagination:** `limit`/`offset` + total count, consistent across list endpoints. +- **Sorting:** `sort_by`/`sort_order` against a per-resource allowlist. +- **Responses:** `{ data, meta }` envelope with pydantic models; `404` on missing id. +- **Auth:** Cloud Run IAM + Google-signed ID token (**not** public); the dash-viewer + backend attaches the token. Predictions are proprietary. +- **Config:** add `predictions`, `analytics`, `monitoring`, `staging` to + `config/bigquery.yaml` + `src/config.py` (currently only `core`, `raw`). + +### Backends (the reader layer hides these) + +- **BigQuery:** games, entities, predictions, collections, similarity (`ML.DISTANCE` + over `analytics.game_similarity_search`), coordinates. +- **GCS:** experiments (bucket `bgg-predictive-models`, prefix + `prod/models/experiments`). +- **`INFORMATION_SCHEMA`:** monitoring / catalog. +- Similarity *may* optionally proxy to the predictive-models embeddings service + (the existing BQ-vs-service abstraction) — default is in-warehouse BigQuery. + +## Cost / performance + +The Dataform serving tables (`games_features`, `bgg_predictions`, +`game_similarity_search`, …) are **unclustered** — a per-id lookup scans the whole +table. Add `clusterBy: ["game_id"]` to those `.sqlx` models so point-lookups are +cheap. This is a low-risk Dataform change, but a cluster change to an existing table +requires a **full-refresh** (see the dataform-incremental-schema-drift spec). The +`core` base + bridge tables are already clustered by `game_id`, so entity→games joins +are fine; per-publisher/designer lookups scan bridge tables (small, acceptable). + +## What needs new modeling (honest gaps) + +- **No per-publisher/designer/artist/family fact table.** "Games for entity" is a + bridge join (fine); richer per-entity stats (avg rating, top games) need new + Dataform models. +- **`artists` and `families`** are absent from `filter_options_combined` and have no + list/search surface today — decide whether they're first-class resources. +- **`rankings`** table is declared but unused by any consumer; skip unless needed. + +## Rollout (incremental — one router per PR) + +1. **App skeleton** — `services/warehouse_api/` + `src/warehouse/readers/` + config + + auth + deploy + `/health`. +2. **`games` router** (get-by-id + list/search) — replaces dash-viewer game detail + + search. First real slice; detailed in the games-endpoint spec. +3. **Entity routers** — publishers/designers/categories/mechanics, then artists/families. +4. **`predictions` + `collections` routers.** +5. **`similarity` + coordinates router.** +6. **`experiments` + `monitoring` routers.** + +Each PR repoints the corresponding dash-viewer page(s) to the API and deletes the SQL +it replaced (including the inline-in-callback SQL). The front-end ends as a pure +consumer. + +## What this doesn't change + +- **No live inference** — `bgg-predictive-models` keeps that. +- **No write endpoints.** +- **No new datasets or backfills** in the skeleton; clustering and any per-entity stat + models are separate, opt-in changes. +- **Dashboard behavior is unchanged** — only each page's data *source* moves. From 2e574b0e1d0997eb38f7ff90c136a378c3173bde Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 12:42:43 -0500 Subject: [PATCH 02/11] feat(api): add predictions/analytics datasets to config Co-Authored-By: Claude Opus 4.8 (1M context) --- config/bigquery.yaml | 2 ++ tests/test_config_datasets.py | 9 +++++++++ 2 files changed, 11 insertions(+) create mode 100644 tests/test_config_datasets.py diff --git a/config/bigquery.yaml b/config/bigquery.yaml index d888eda..1a44353 100644 --- a/config/bigquery.yaml +++ b/config/bigquery.yaml @@ -4,6 +4,8 @@ location: US datasets: core: core raw: raw + predictions: predictions + analytics: analytics refresh_policy: batch_size: 1000 diff --git a/tests/test_config_datasets.py b/tests/test_config_datasets.py new file mode 100644 index 0000000..91b315e --- /dev/null +++ b/tests/test_config_datasets.py @@ -0,0 +1,9 @@ +"""Tests that the warehouse read API's serving datasets are configured.""" + +from src.config import get_bigquery_config + + +def test_datasets_include_serving_layers(): + datasets = get_bigquery_config()["datasets"] + for name in ("core", "raw", "predictions", "analytics"): + assert name in datasets, f"missing dataset config: {name}" From 4a0f734c83bd770492cb44f97b74841a151a674e Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 12:43:31 -0500 Subject: [PATCH 03/11] feat(api): add warehouse BigQuery client/dataset helper Co-Authored-By: Claude Opus 4.8 (1M context) --- src/warehouse/__init__.py | 0 src/warehouse/bq.py | 35 +++++++++++++++++++++++++++++++ src/warehouse/readers/__init__.py | 0 3 files changed, 35 insertions(+) create mode 100644 src/warehouse/__init__.py create mode 100644 src/warehouse/bq.py create mode 100644 src/warehouse/readers/__init__.py diff --git a/src/warehouse/__init__.py b/src/warehouse/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/warehouse/bq.py b/src/warehouse/bq.py new file mode 100644 index 0000000..35362af --- /dev/null +++ b/src/warehouse/bq.py @@ -0,0 +1,35 @@ +"""Shared BigQuery access for warehouse readers. + +A thin layer over ``google.cloud.bigquery`` that centralizes client creation and +resolves configured dataset keys to fully-qualified ``project.dataset`` names, so the +reader modules never hard-code project/dataset strings. +""" + +from functools import lru_cache + +from google.cloud import bigquery + +from src.config import get_bigquery_config + + +@lru_cache(maxsize=1) +def _cfg() -> dict: + return get_bigquery_config() + + +def get_client() -> bigquery.Client: + """Return a BigQuery client bound to the configured project.""" + return bigquery.Client(project=_cfg()["project"]["id"]) + + +def dataset(name: str) -> str: + """Return the fully-qualified ``project.dataset`` for a configured dataset key. + + Args: + name: A key from ``config/bigquery.yaml`` ``datasets`` (e.g. ``"analytics"``). + + Raises: + KeyError: If the dataset key is not configured. + """ + cfg = _cfg() + return f'{cfg["project"]["id"]}.{cfg["datasets"][name]}' diff --git a/src/warehouse/readers/__init__.py b/src/warehouse/readers/__init__.py new file mode 100644 index 0000000..e69de29 From 0dc0b571773c7ef85420cffe912c178d4664a56c Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 12:46:33 -0500 Subject: [PATCH 04/11] feat(api): add games reader (get_game by id + blocks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query SQL validated against real schemas via dry-run; a full game document scans ~360MB (serving tables unclustered) — clusterBy game_id follow-up tracked. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/warehouse/readers/games.py | 129 +++++++++++++++++++++++++++++++++ tests/test_games_reader.py | 103 ++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 src/warehouse/readers/games.py create mode 100644 tests/test_games_reader.py diff --git a/src/warehouse/readers/games.py b/src/warehouse/readers/games.py new file mode 100644 index 0000000..75f6c0d --- /dev/null +++ b/src/warehouse/readers/games.py @@ -0,0 +1,129 @@ +"""Reader for the ``games`` resource of the warehouse read API. + +Pure query functions over BigQuery — one per block plus a ``get_game`` aggregator. +Every function accepts an optional ``client`` for dependency injection (tests pass a +fake), and parameterizes ``game_id`` via ``ScalarQueryParameter`` (never string +interpolation). Tables are resolved through ``src.warehouse.bq.dataset`` so no +project/dataset is hard-coded. +""" + +from typing import Any, Optional + +from google.cloud import bigquery + +from src.warehouse.bq import dataset, get_client + + +def _rows(client: bigquery.Client, sql: str, game_id: int, extra_params=None) -> list[dict]: + params = [bigquery.ScalarQueryParameter("game_id", "INT64", game_id)] + if extra_params: + params.extend(extra_params) + job_config = bigquery.QueryJobConfig(query_parameters=params) + return [dict(row) for row in client.query(sql, job_config=job_config).result()] + + +def get_features(game_id: int, client: Optional[bigquery.Client] = None) -> Optional[dict[str, Any]]: + """Game record + per-player-count recommendations. ``None`` if the game is unknown.""" + client = client or get_client() + rows = _rows( + client, + f"SELECT * FROM `{dataset('analytics')}.games_features` " + "WHERE game_id = @game_id LIMIT 1", + game_id, + ) + if not rows: + return None + features = rows[0] + features["player_counts"] = _rows( + client, + f"SELECT * FROM `{dataset('analytics')}.player_count_recommendations` " + "WHERE game_id = @game_id ORDER BY player_count", + game_id, + ) + return features + + +def get_predictions(game_id: int, client: Optional[bigquery.Client] = None) -> Optional[dict[str, Any]]: + """Latest prediction row plus ``first_prediction_ts``. + + ``bgg_predictions`` holds one (latest) row per game; full time-series history would + read ``ml_predictions_landing`` and is deferred to a later slice. + """ + client = client or get_client() + rows = _rows( + client, + f""" + SELECT p.*, f.first_prediction_ts + FROM `{dataset('predictions')}.bgg_predictions` p + LEFT JOIN `{dataset('predictions')}.game_first_prediction` f USING (game_id) + WHERE p.game_id = @game_id + """, + game_id, + ) + return rows[0] if rows else None + + +def get_embedding(game_id: int, client: Optional[bigquery.Client] = None) -> Optional[dict[str, Any]]: + """UMAP/PCA coordinates for the game. ``None`` if not embedded.""" + client = client or get_client() + rows = _rows( + client, + f"SELECT * FROM `{dataset('predictions')}.bgg_game_coordinates` " + "WHERE game_id = @game_id", + game_id, + ) + return rows[0] if rows else None + + +def get_similar(game_id: int, n: int = 10, client: Optional[bigquery.Client] = None) -> list[dict[str, Any]]: + """Nearest neighbours by cosine distance over the game's embedding.""" + client = client or get_client() + sql = f""" + WITH target AS ( + SELECT embedding + FROM `{dataset('analytics')}.game_similarity_search` + WHERE game_id = @game_id + ) + SELECT + s.game_id, + s.name, + s.year_published, + ML.DISTANCE(s.embedding, (SELECT embedding FROM target), 'COSINE') AS distance + FROM `{dataset('analytics')}.game_similarity_search` s + WHERE s.game_id != @game_id + AND (SELECT embedding FROM target) IS NOT NULL + ORDER BY distance ASC + LIMIT @n + """ + return _rows( + client, sql, game_id, + extra_params=[bigquery.ScalarQueryParameter("n", "INT64", n)], + ) + + +def get_provenance(game_id: int, client: Optional[bigquery.Client] = None) -> Optional[dict[str, Any]]: + """Fetch/load metadata — when the warehouse last pulled this game from BGG.""" + client = client or get_client() + rows = _rows( + client, + f"SELECT * FROM `{dataset('raw')}.fetched_responses` " + "WHERE game_id = @game_id LIMIT 1", + game_id, + ) + return rows[0] if rows else None + + +def get_game(game_id: int, client: Optional[bigquery.Client] = None) -> Optional[dict[str, Any]]: + """Compose the full game document. ``None`` when the game has no features row.""" + client = client or get_client() + features = get_features(game_id, client=client) + if features is None: + return None + return { + "game_id": game_id, + "features": features, + "predictions": get_predictions(game_id, client=client), + "embedding": get_embedding(game_id, client=client), + "similar": get_similar(game_id, client=client), + "provenance": get_provenance(game_id, client=client), + } diff --git a/tests/test_games_reader.py b/tests/test_games_reader.py new file mode 100644 index 0000000..def6dd0 --- /dev/null +++ b/tests/test_games_reader.py @@ -0,0 +1,103 @@ +"""Unit tests for the games reader (BigQuery mocked — no network).""" + +from src.warehouse.readers import games + + +class _Result: + def __init__(self, rows): + self._rows = rows + + def result(self): + return self._rows + + +class RoutingClient: + """Fake BigQuery client that returns canned rows based on the table named in the SQL. + + Records each (sql, job_config) so tests can assert parameterization. + """ + + def __init__(self, tables): + self.tables = tables + self.calls = [] + + def query(self, sql, job_config=None): + self.calls.append((sql, job_config)) + for table, rows in self.tables.items(): + if table in sql: + return _Result(list(rows)) + return _Result([]) + + +FEATURES_ROW = {"game_id": 13, "name": "Catan", "year_published": 1995, "publishers": ["KOSMOS"]} +PLAYER_COUNTS = [{"player_count": "3", "best_percentage": 60.0}, {"player_count": "4", "best_percentage": 55.0}] +PREDICTION_ROW = {"game_id": 13, "predicted_rating": 7.1, "first_prediction_ts": "2026-01-01T00:00:00Z"} +COORD_ROW = {"game_id": 13, "umap_1": 1.2, "umap_2": 3.4} +SIMILAR_ROWS = [{"game_id": 21, "name": "Carcassonne", "distance": 0.11}] +PROVENANCE_ROW = {"game_id": 13, "fetch_timestamp": "2026-07-10T00:00:00Z"} + + +def _full_client(): + return RoutingClient({ + "games_features": [FEATURES_ROW], + "player_count_recommendations": PLAYER_COUNTS, + "bgg_predictions": [PREDICTION_ROW], + "bgg_game_coordinates": [COORD_ROW], + "game_similarity_search": SIMILAR_ROWS, + "fetched_responses": [PROVENANCE_ROW], + }) + + +class TestBlocks: + def test_features_includes_player_counts(self): + client = _full_client() + result = games.get_features(13, client=client) + assert result["name"] == "Catan" + assert result["player_counts"] == PLAYER_COUNTS + + def test_features_missing_returns_none(self): + client = RoutingClient({"games_features": []}) + assert games.get_features(999999, client=client) is None + + def test_predictions_joins_first_ts(self): + client = _full_client() + result = games.get_predictions(13, client=client) + assert result["predicted_rating"] == 7.1 + assert "first_prediction_ts" in result + + def test_embedding(self): + assert games.get_embedding(13, client=_full_client())["umap_1"] == 1.2 + + def test_similar_uses_ml_distance(self): + client = _full_client() + result = games.get_similar(13, n=5, client=client) + assert result == SIMILAR_ROWS + assert "ML.DISTANCE" in client.calls[-1][0] + + def test_provenance(self): + assert games.get_provenance(13, client=_full_client())["fetch_timestamp"] + + +class TestGetGame: + def test_composes_all_blocks(self): + result = games.get_game(13, client=_full_client()) + assert set(result) == {"game_id", "features", "predictions", "embedding", "similar", "provenance"} + assert result["game_id"] == 13 + assert result["features"]["name"] == "Catan" + assert result["similar"] == SIMILAR_ROWS + + def test_missing_game_returns_none(self): + client = RoutingClient({"games_features": []}) + assert games.get_game(999999, client=client) is None + + +class TestSafety: + def test_queries_are_parameterized(self): + """game_id is bound as a query parameter, never interpolated into SQL.""" + client = _full_client() + games.get_game(13, client=client) + for sql, job_config in client.calls: + assert "@game_id" in sql + assert "13" not in sql # the literal id never appears in the SQL text + names = {p.name for p in job_config.query_parameters} + assert "game_id" in names From 105d9077b7be0355907214887867d565ce4b4711 Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 12:49:10 -0500 Subject: [PATCH 05/11] feat(api): FastAPI warehouse-api skeleton + games router /health plus games get-by-id and per-block sub-resources, over the games reader. Cloud Run IAM handles caller auth; app uses ADC for BigQuery. Tests mock the reader. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 5 + services/warehouse_api/__init__.py | 0 services/warehouse_api/auth.py | 51 +++++ services/warehouse_api/main.py | 31 +++ services/warehouse_api/routers/__init__.py | 0 services/warehouse_api/routers/games.py | 55 +++++ tests/test_games_router.py | 57 +++++ uv.lock | 232 ++++++++++++++++++++- 8 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 services/warehouse_api/__init__.py create mode 100644 services/warehouse_api/auth.py create mode 100644 services/warehouse_api/main.py create mode 100644 services/warehouse_api/routers/__init__.py create mode 100644 services/warehouse_api/routers/games.py create mode 100644 tests/test_games_router.py diff --git a/pyproject.toml b/pyproject.toml index 5edc413..c9dfc88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,11 @@ dev = [ test = [ "pytest>=8.0.0", "pytest-cov>=7.0.0", + "httpx>=0.27.0", +] +api = [ + "fastapi>=0.115.0", + "uvicorn>=0.30.0", ] [tool.pytest.ini_options] diff --git a/services/warehouse_api/__init__.py b/services/warehouse_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/warehouse_api/auth.py b/services/warehouse_api/auth.py new file mode 100644 index 0000000..032e33d --- /dev/null +++ b/services/warehouse_api/auth.py @@ -0,0 +1,51 @@ +"""Authentication helpers for the warehouse read API. + +Caller authorization is enforced by **Cloud Run IAM** (the service is deployed +``--no-allow-unauthenticated``), so the app does not verify inbound tokens itself. This +module only resolves the GCP project and reports the credential source used for the +BigQuery client (Application Default Credentials). +""" + +import logging +import os +from typing import Optional + +from google.auth import default +from google.auth.exceptions import DefaultCredentialsError + +logger = logging.getLogger(__name__) + + +class AuthenticationError(Exception): + """Raised when the GCP project cannot be determined.""" + + +class GCPAuthenticator: + """Resolves the GCP project for the service's BigQuery client via ADC.""" + + def __init__(self, project_id: Optional[str] = None): + self.project_id = project_id or self._resolve_project_id() + + @staticmethod + def _resolve_project_id() -> str: + project_id = os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT") + if project_id: + return project_id + try: + _, project_id = default() + if project_id: + return project_id + except DefaultCredentialsError: + pass + raise AuthenticationError( + "Could not determine GCP project. Set GCP_PROJECT_ID or provide GCP credentials." + ) + + def info(self) -> dict: + """Credential-source summary for the health endpoint.""" + source = "application_default" + if os.getenv("GOOGLE_APPLICATION_CREDENTIALS"): + source = "service_account_key" + elif os.getenv("K_SERVICE"): # set inside Cloud Run + source = "cloud_run_runtime" + return {"project_id": self.project_id, "credentials_source": source} diff --git a/services/warehouse_api/main.py b/services/warehouse_api/main.py new file mode 100644 index 0000000..63edd93 --- /dev/null +++ b/services/warehouse_api/main.py @@ -0,0 +1,31 @@ +"""BGG Warehouse read API. + +A modular-monolith FastAPI service over the warehouse's materialized data. One router +per resource; this build ships ``/health`` and the ``games`` router. See +docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md. +""" + +import logging + +from dotenv import load_dotenv +from fastapi import FastAPI + +from services.warehouse_api.routers import games + +load_dotenv() +logging.basicConfig(level=logging.INFO) + +app = FastAPI(title="BGG Warehouse API", version="0.1.0") + +app.include_router(games.router) + + +@app.get("/health") +def health() -> dict: + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8080) diff --git a/services/warehouse_api/routers/__init__.py b/services/warehouse_api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/warehouse_api/routers/games.py b/services/warehouse_api/routers/games.py new file mode 100644 index 0000000..262d99f --- /dev/null +++ b/services/warehouse_api/routers/games.py @@ -0,0 +1,55 @@ +"""Games resource router. + +Thin HTTP shell over ``src.warehouse.readers.games``. Existence is defined by the +features row: endpoints that require the game to exist return 404 when it doesn't; +optional blocks (predictions, embedding, provenance) return 200 with a possibly-null +body since a real game may simply not have that block yet. +""" + +from fastapi import APIRouter, HTTPException + +from src.warehouse.readers import games as reader + +router = APIRouter(prefix="/games", tags=["games"]) + + +def _require(value, game_id: int): + if value is None: + raise HTTPException(status_code=404, detail=f"game {game_id} not found") + return value + + +@router.get("/{game_id}") +def get_game(game_id: int): + """Full game document (features + predictions + embedding + similar + provenance).""" + return _require(reader.get_game(game_id), game_id) + + +@router.get("/{game_id}/features") +def get_features(game_id: int): + return _require(reader.get_features(game_id), game_id) + + +@router.get("/{game_id}/players") +def get_players(game_id: int): + return _require(reader.get_features(game_id), game_id)["player_counts"] + + +@router.get("/{game_id}/predictions") +def get_predictions(game_id: int): + return reader.get_predictions(game_id) + + +@router.get("/{game_id}/embedding") +def get_embedding(game_id: int): + return reader.get_embedding(game_id) + + +@router.get("/{game_id}/similar") +def get_similar(game_id: int, n: int = 10): + return reader.get_similar(game_id, n=n) + + +@router.get("/{game_id}/provenance") +def get_provenance(game_id: int): + return reader.get_provenance(game_id) diff --git a/tests/test_games_router.py b/tests/test_games_router.py new file mode 100644 index 0000000..25adbde --- /dev/null +++ b/tests/test_games_router.py @@ -0,0 +1,57 @@ +"""Router tests for the warehouse API (reader mocked — no BigQuery).""" + +from fastapi.testclient import TestClient + +from services.warehouse_api.main import app +from services.warehouse_api.routers import games as games_router + +client = TestClient(app) + + +def test_health(): + assert client.get("/health").json() == {"status": "ok"} + + +def test_get_game_ok(monkeypatch): + monkeypatch.setattr( + games_router.reader, "get_game", + lambda game_id, client=None: {"game_id": game_id, "features": {"name": "Catan"}}, + ) + r = client.get("/games/13") + assert r.status_code == 200 + assert r.json()["features"]["name"] == "Catan" + + +def test_get_game_missing_is_404(monkeypatch): + monkeypatch.setattr(games_router.reader, "get_game", lambda game_id, client=None: None) + assert client.get("/games/999999").status_code == 404 + + +def test_predictions_sub_resource(monkeypatch): + monkeypatch.setattr( + games_router.reader, "get_predictions", + lambda game_id, client=None: {"predicted_rating": 7.1}, + ) + r = client.get("/games/13/predictions") + assert r.status_code == 200 + assert r.json()["predicted_rating"] == 7.1 + + +def test_players_sub_resource(monkeypatch): + monkeypatch.setattr( + games_router.reader, "get_features", + lambda game_id, client=None: {"name": "Catan", "player_counts": [{"player_count": "4"}]}, + ) + r = client.get("/games/13/players") + assert r.status_code == 200 + assert r.json() == [{"player_count": "4"}] + + +def test_similar_sub_resource(monkeypatch): + monkeypatch.setattr( + games_router.reader, "get_similar", + lambda game_id, n=10, client=None: [{"game_id": 21, "distance": 0.1}], + ) + r = client.get("/games/13/similar?n=5") + assert r.status_code == 200 + assert r.json()[0]["game_id"] == 21 diff --git a/uv.lock b/uv.lock index bb2c58a..584191f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -7,6 +7,37 @@ resolution-markers = [ "python_full_version < '3.13'", ] +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "appnope" version = "0.1.4" @@ -27,7 +58,7 @@ wheels = [ [[package]] name = "bgg-data-warehouse" -version = "0.6.4" +version = "0.6.6" source = { virtual = "." } dependencies = [ { name = "db-dtypes" }, @@ -49,6 +80,10 @@ dependencies = [ ] [package.optional-dependencies] +api = [ + { name = "fastapi" }, + { name = "uvicorn" }, +] dev = [ { name = "black" }, { name = "ipykernel" }, @@ -56,6 +91,7 @@ dev = [ { name = "ruff" }, ] test = [ + { name = "httpx" }, { name = "pytest" }, { name = "pytest-cov" }, ] @@ -64,11 +100,13 @@ test = [ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=24.1.0" }, { name = "db-dtypes", specifier = ">=1.2.0" }, + { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" }, { name = "google-auth", specifier = ">=2.40.3" }, { name = "google-cloud-bigquery", specifier = ">=3.14.0" }, { name = "google-cloud-bigquery-datatransfer", specifier = ">=3.20.0" }, { name = "google-cloud-bigquery-storage", specifier = ">=2.32.0" }, { name = "google-cloud-storage", specifier = ">=2.14.0" }, + { name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" }, { name = "ipykernel", marker = "extra == 'dev'", specifier = ">=6.29.5" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8.0" }, { name = "pandas", specifier = ">=2.1.0" }, @@ -83,9 +121,10 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.1" }, { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.2.0" }, + { name = "uvicorn", marker = "extra == 'api'", specifier = ">=0.30.0" }, { name = "xmltodict", specifier = ">=0.13.0" }, ] -provides-extras = ["dev", "test"] +provides-extras = ["dev", "test", "api"] [package.metadata.requires-dev] dev = [] @@ -357,6 +396,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702, upload-time = "2025-01-22T15:41:25.929Z" }, ] +[[package]] +name = "fastapi" +version = "0.139.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/95/d3f0ae10836324a2eab98a52b61210ac609f08200bf4bb0dc8132d32f78a/fastapi-0.139.2.tar.gz", hash = "sha256:333145a6891e9b5b3cfceb69baf817e8240cde4d4588ae5a10bf56ffacb6255e", size = 423428, upload-time = "2026-07-16T15:06:17.912Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c", size = 130234, upload-time = "2026-07-16T15:06:19.557Z" }, +] + [[package]] name = "google-api-core" version = "2.25.0" @@ -665,6 +720,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/95/e4b963a8730e04fae0e98cdd12212a9ffb318daf8687ea3220b78b34f8fa/grpcio_status-1.73.0-py3-none-any.whl", hash = "sha256:a3f3a9994b44c364f014e806114ba44cc52e50c426779f958c8b22f14ff0d892", size = 14423, upload-time = "2025-06-09T10:06:14.624Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -1182,6 +1274,96 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + [[package]] name = "pydata-google-auth" version = "1.9.1" @@ -1454,6 +1636,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, ] +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + [[package]] name = "tornado" version = "6.5.1" @@ -1484,11 +1679,23 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.14.0" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] [[package]] @@ -1509,6 +1716,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" }, ] +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] + [[package]] name = "wcwidth" version = "0.2.13" From 64a83cc598294ad10bb818d1f228c4b19b39e809 Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 13:07:44 -0500 Subject: [PATCH 06/11] docs(api): add service auth pattern spec; gate warehouse-api via IAM IAM + ID-token gating with an invoker Google Group as the grant surface; extensible to the predictive-models services (all currently run.invoker: allUsers). Threads the decision into the games plan (Task 7 + security follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-16-warehouse-api-games.md | 54 ++++++++--- .../2026-07-16-game-detail-api-design.md | 8 +- .../2026-07-16-service-auth-pattern-design.md | 95 +++++++++++++++++++ ...-warehouse-services-architecture-design.md | 6 +- 4 files changed, 143 insertions(+), 20 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md diff --git a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md index 8f96c47..d585ca9 100644 --- a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md +++ b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md @@ -19,6 +19,7 @@ Build, GitHub Actions. **Specs:** - `docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md` - `docs/superpowers/specs/2026-07-16-game-detail-api-design.md` +- `docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md` (gating) **Scope (this plan):** config datasets · BigQuery client helper · `games` reader (get-by-id + blocks) · FastAPI skeleton + `/health` + auth · `games` router · Docker + @@ -242,24 +243,38 @@ def test_health(): --- -### Task 7: Deploy (Cloud Build + workflow) +### Task 7: Deploy (Cloud Build + workflow) — gated per the auth-pattern spec **Files:** Modify `config/cloudbuild.yaml`; Create `.github/workflows/deploy-warehouse-api.yml` +Gating follows `docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md`: +deploy authenticated, then grant `run.invoker` via the invoker group (or, day one, +direct member bindings). + - [ ] **Step 1: Add a `bgg-warehouse-api` Cloud Run *service*** block to `config/cloudbuild.yaml` — build/push the image, then `gcloud run deploy bgg-warehouse-api --region us-central1 --no-allow-unauthenticated - --service-account=bgg-data-warehouse@$PROJECT_ID.iam.gserviceaccount.com` - (authenticated per spec). -- [ ] **Step 2: Validate YAML** — `python -c "import yaml; yaml.safe_load(open('config/cloudbuild.yaml'))" && echo valid`. -- [ ] **Step 3: Create `.github/workflows/deploy-warehouse-api.yml`** — mirror + --service-account=bgg-data-warehouse@$PROJECT_ID.iam.gserviceaccount.com`. +- [ ] **Step 2: Grant invoker access.** Preferred (group): + `gcloud run services add-iam-policy-binding bgg-warehouse-api --region us-central1 + --member="group:bgg-api-invokers@googlegroups.com" --role=roles/run.invoker`. + Day-one fallback without a group — bind the two identities that need it now: the + dash-viewer runtime SA + (`serviceAccount:bgg-data-warehouse@bgg-data-warehouse.iam.gserviceaccount.com`) and + your own `user:phil.henrickson@gmail.com`. Confirm `allUsers` is **absent** from the + policy. +- [ ] **Step 3: Validate YAML** — `python -c "import yaml; yaml.safe_load(open('config/cloudbuild.yaml'))" && echo valid`. +- [ ] **Step 4: Create `.github/workflows/deploy-warehouse-api.yml`** — mirror `deploy.yml` (auth with `GCP_SA_KEY_BGG_DW`, `gcloud builds submit`), triggered on `push` to `main` touching `services/warehouse_api/**`, `src/warehouse/**`, plus - `workflow_dispatch`. -- [ ] **Step 4: Validate YAML.** -- [ ] **Step 5: Commit** — `ci(api): deploy warehouse-api to Cloud Run` -- [ ] **Step 6: Post-deploy check** (after merge) — deployed `/health` → 200 (with an - ID token); unauthenticated → 403; `/games/13` → real data. + `workflow_dispatch`. Validate its YAML too. +- [ ] **Step 5: Commit** — `ci(api): deploy gated warehouse-api to Cloud Run` +- [ ] **Step 6: Post-deploy check** (after merge): + - `curl /health` with no token → **403** (gate works). + - `curl -H "Authorization: Bearer $(gcloud auth print-identity-token)" /health` → **200**. + - `gcloud run services proxy bgg-warehouse-api --region us-central1` → hit `/games/13` + → real, populated document. + - Re-confirm the IAM policy has **no `allUsers`**. --- @@ -276,14 +291,20 @@ def test_health(): ## Follow-up (separate PRs / plans) -1. **`bgg-dash-viewer` repoint** — `warehouse_api_client.py` + `WAREHOUSE_API_URL`; - rewrite `game_details.py` to call the API; delete the game SQL from - `bigquery_client.py`. Verify the game page renders identically. +1. **`bgg-dash-viewer` repoint** — `warehouse_api_client.py` (carrying the reusable + `id_token_headers(WAREHOUSE_API_URL)` helper from the auth-pattern spec) + + `WAREHOUSE_API_URL`; rewrite `game_details.py` to call the API; delete the game SQL + from `bigquery_client.py`. Verify the game page renders identically, authenticated. 2. **Games list/search slice** — `GET /games`, `/games/search`, `/games/new`, `/games/summary`. 3. **`clusterBy game_id`** on `games_features` / `bgg_predictions` / `game_similarity_search` (needs full-refresh — see dataform-incremental-schema-drift). 4. **Remaining resource routers** — publishers/designers/…, predictions, collections, similarity, experiments, monitoring. +5. **SECURITY — gate the predictive-models services.** All five are currently + `run.invoker: allUsers` (confirmed live). Apply the same auth pattern per the + auth-pattern spec (identify callers → add to invoker group → redeploy authenticated). + `bgg-streamlit-prod` is a browser UI → needs IAP/app-login, handled separately. Track + as its own security spec + plan. ## Risks / rollback @@ -293,5 +314,8 @@ def test_health(): - **Cost:** unclustered serving tables mean each `/games/{id}` call full-scans `games_features` etc. Acceptable at low traffic; the `clusterBy` follow-up removes it. Watch bytes-scanned in the Task 3 smoke test. -- **Auth wiring** is the one deploy-time unknown — confirm the dash-viewer service - account can mint an ID token for the warehouse-api service before the repoint. +- **Auth wiring** (resolved to IAM + ID token, see auth-pattern spec): the dash-viewer + runtime SA already runs on Cloud Run via ADC, so it *can* mint an ID token — but the + consumer code to attach it doesn't exist yet, so it's built in the repoint follow-up. + Until then the deployed API is gated and reachable only by your own identity (via + `gcloud`) — inert for the front-end, which is fine (nothing consumes it yet). diff --git a/docs/superpowers/specs/2026-07-16-game-detail-api-design.md b/docs/superpowers/specs/2026-07-16-game-detail-api-design.md index b8717b9..c8ae136 100644 --- a/docs/superpowers/specs/2026-07-16-game-detail-api-design.md +++ b/docs/superpowers/specs/2026-07-16-game-detail-api-design.md @@ -58,9 +58,11 @@ responses, `404` on missing id. Mounted by `services/warehouse_api/main.py`. ### 3. Shared plumbing (from the architecture skeleton, not games-specific) -Config extension (`predictions`/`analytics` datasets), `auth.py` (Cloud Run IAM + ID -token), `Dockerfile` (uv-based, modeled on `docker/Dockerfile.pipeline`), -`cloudbuild.yaml`, and `deploy-warehouse-api.yml`. +Config extension (`predictions`/`analytics` datasets), `auth.py`, `Dockerfile` +(uv-based, modeled on `docker/Dockerfile.pipeline`), `cloudbuild.yaml`, and +`deploy-warehouse-api.yml`. Inbound gating follows the **service auth pattern** +(`2026-07-16-service-auth-pattern-design.md`) — deployed authenticated, invoker access +granted via IAM. ### 4. Front-end consumer — separate PR in `bgg-dash-viewer` diff --git a/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md b/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md new file mode 100644 index 0000000..c89f1de --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md @@ -0,0 +1,95 @@ +# Cloud Run Service Auth Pattern (reusable) + +## Problem + +Cloud Run services in this ecosystem are deployed `--allow-unauthenticated` with +`roles/run.invoker` granted to **`allUsers`** — confirmed live for all five +`bgg-predictive-models` services (`bgg-model-scoring`, `bgg-embeddings-service`, +`bgg-collection-scoring`, `bgg-text-embeddings-service`, `bgg-streamlit-prod`). Anyone +who discovers a `.run.app` URL can invoke them: pull proprietary model outputs, burn +compute, and open a public dashboard. We need a gating pattern that (a) lets an +authorized human access a service easily, (b) makes *granting* access a simple, +revocable operation, and (c) applies unchanged to every service so we can extend it to +the predictive-models services. + +## Pattern + +**Gate — Cloud Run IAM.** Deploy every gated service `--no-allow-unauthenticated` and +ensure `allUsers` is **not** in its IAM policy. The service is then invocable only by +identities holding `roles/run.invoker` on it. + +**Grant surface — one invoker Google Group.** Create a single group, e.g. +`bgg-api-invokers@googlegroups.com` (or a Cloud Identity group), and grant it +`roles/run.invoker` on each gated service **once**. Thereafter "provide access" is a +group-membership change, never a per-service IAM edit: + +- Authorized **person** → add `user:someone@example.com` to the group. +- **Service** caller (the dash-viewer runtime SA; later, predictive-models callers) → + add `serviceAccount:…@….iam.gserviceaccount.com` to the group. (Google Groups accept + service accounts as members.) +- **Revoke** → remove the member. + +Per-service `add-iam-policy-binding` of an individual member is the fallback when a +group isn't set up yet — but the group is what makes "I can provide access" a one-step +operation, so prefer it. + +**Human access (easy).** Either: + +- Ad-hoc: `curl -H "Authorization: Bearer $(gcloud auth print-identity-token)" /health` +- Local dev (nicest): `gcloud run services proxy bgg-warehouse-api --region us-central1` + then hit `http://localhost:8080` — the proxy injects your identity, no header needed. + +**Service-to-service access.** The caller mints a Google-signed **ID token** whose +audience is the target service URL and attaches it as `Authorization: Bearer`. Reusable +helper (copy into each caller repo; ~15 lines, no new heavy deps — `google-auth` is +already present): + +```python +import google.auth.transport.requests +import google.oauth2.id_token + +def id_token_headers(audience_url: str) -> dict: + """Bearer header for calling an authenticated Cloud Run service. + + Works off ADC: the Cloud Run metadata server in prod, a service-account context + locally. `audience_url` is the target service's base URL. + """ + req = google.auth.transport.requests.Request() + token = google.oauth2.id_token.fetch_id_token(req, audience_url) + return {"Authorization": f"Bearer {token}"} +``` + +The callee needs **no application code** to verify the token — Cloud Run IAM validates +it at the edge before the request reaches the app. + +## Applying it here (warehouse read API) + +1. Deploy `bgg-warehouse-api` `--no-allow-unauthenticated`. +2. Grant the invoker group `roles/run.invoker` on it (or, day one, bind your own + `user:` and the dash-viewer `serviceAccount:` directly). +3. The dash-viewer consumer (follow-up PR) builds its `requests` calls with + `id_token_headers(WAREHOUSE_API_URL)`. + +## Extending to the predictive-models services (follow-up) + +The same three moves per service, one service at a time to avoid breakage: + +1. Identify every current caller of the service (scoring pipeline, Streamlit, dash-viewer) + and confirm each can mint an ID token (runs as a service account). +2. Add each caller's identity to the invoker group; grant the group `run.invoker` on the + service. +3. Redeploy the service `--no-allow-unauthenticated`, remove `allUsers`, and update each + caller to attach `id_token_headers(url)`. + +**Special case — `bgg-streamlit-prod`:** it's a *browser-facing* dashboard, so +ID-token/`run.invoker` gating doesn't apply (browsers don't send ID tokens). That one +needs **Identity-Aware Proxy (IAP)** or in-app login, and is a separate decision from +the machine-to-machine services. + +## What this doesn't change + +- No change to how services authenticate to BigQuery/GCS (still ADC / runtime SA). +- The pattern gates *inbound* invocation only. +- Remediating the existing `allUsers` exposure is tracked as its own security follow-up + (see the plan) — this spec defines the target pattern, it does not itself flip the + live services. diff --git a/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md b/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md index 7befb2a..7469de7 100644 --- a/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md +++ b/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md @@ -101,8 +101,10 @@ it modular; if one resource ever needs isolation, it lifts out cleanly. - **Pagination:** `limit`/`offset` + total count, consistent across list endpoints. - **Sorting:** `sort_by`/`sort_order` against a per-resource allowlist. - **Responses:** `{ data, meta }` envelope with pydantic models; `404` on missing id. -- **Auth:** Cloud Run IAM + Google-signed ID token (**not** public); the dash-viewer - backend attaches the token. Predictions are proprietary. +- **Auth:** gated per the **service auth pattern** + (`2026-07-16-service-auth-pattern-design.md`) — Cloud Run IAM + Google-signed ID + token, **not** public. Access is granted via an invoker Google Group; the same pattern + is the one we extend to the (currently `allUsers`) predictive-models services. - **Config:** add `predictions`, `analytics`, `monitoring`, `staging` to `config/bigquery.yaml` + `src/config.py` (currently only `core`, `raw`). From 89290287621556d07408fefe992baee54d40f749 Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 13:17:28 -0500 Subject: [PATCH 07/11] docs(api): frame the read API as consumer-agnostic (accommodate a new front-end) Grant surface is the invoker group; day-one grant the owner's identity, not any front-end SA. API knows nothing about its consumers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-16-warehouse-api-games.md | 15 +++++++-------- .../2026-07-16-service-auth-pattern-design.md | 14 ++++++++++---- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md index d585ca9..337d744 100644 --- a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md +++ b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md @@ -255,14 +255,13 @@ direct member bindings). `config/cloudbuild.yaml` — build/push the image, then `gcloud run deploy bgg-warehouse-api --region us-central1 --no-allow-unauthenticated --service-account=bgg-data-warehouse@$PROJECT_ID.iam.gserviceaccount.com`. -- [ ] **Step 2: Grant invoker access.** Preferred (group): - `gcloud run services add-iam-policy-binding bgg-warehouse-api --region us-central1 - --member="group:bgg-api-invokers@googlegroups.com" --role=roles/run.invoker`. - Day-one fallback without a group — bind the two identities that need it now: the - dash-viewer runtime SA - (`serviceAccount:bgg-data-warehouse@bgg-data-warehouse.iam.gserviceaccount.com`) and - your own `user:phil.henrickson@gmail.com`. Confirm `allUsers` is **absent** from the - policy. +- [ ] **Step 2: Grant invoker access** (consumer-agnostic — see auth-pattern spec). + Preferred (group): `gcloud run services add-iam-policy-binding bgg-warehouse-api + --region us-central1 --member="group:bgg-api-invokers@googlegroups.com" + --role=roles/run.invoker`. **Day one, grant your own identity** so the API is usable + without tying it to any front-end: + `--member="user:phil.henrickson@gmail.com"`. Add each consumer's SA (a new front-end, + dash-viewer, …) to the group as it comes online. Confirm `allUsers` is **absent**. - [ ] **Step 3: Validate YAML** — `python -c "import yaml; yaml.safe_load(open('config/cloudbuild.yaml'))" && echo valid`. - [ ] **Step 4: Create `.github/workflows/deploy-warehouse-api.yml`** — mirror `deploy.yml` (auth with `GCP_SA_KEY_BGG_DW`, `gcloud builds submit`), triggered on diff --git a/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md b/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md index c89f1de..16f88a4 100644 --- a/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md +++ b/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md @@ -64,11 +64,17 @@ it at the edge before the request reaches the app. ## Applying it here (warehouse read API) +The API is **consumer-agnostic** — it exists to serve any front-end (a new front-end is +planned; dash-viewer is merely the first existing consumer). The group is what keeps it +that way: a new consumer gets access by being added to the group, and the API knows +nothing about who calls it. + 1. Deploy `bgg-warehouse-api` `--no-allow-unauthenticated`. -2. Grant the invoker group `roles/run.invoker` on it (or, day one, bind your own - `user:` and the dash-viewer `serviceAccount:` directly). -3. The dash-viewer consumer (follow-up PR) builds its `requests` calls with - `id_token_headers(WAREHOUSE_API_URL)`. +2. Grant the invoker group `roles/run.invoker` on it. **Day one, grant your own + `user:` identity** so the API is usable immediately without coupling to any + front-end. Each consumer (a new front-end's runtime SA, dash-viewer's SA, another + service) is then added to the group as it comes online — no per-consumer API change. +3. Any consumer builds its authenticated calls with `id_token_headers(WAREHOUSE_API_URL)`. ## Extending to the predictive-models services (follow-up) From 0d79068412e24a3ab96e61d1e3fa444b8ce1755b Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 13:25:35 -0500 Subject: [PATCH 08/11] feat(api): containerize warehouse-api (uv image + .dockerignore) Image builds from repo root via uv sync --extra api; verified locally (docker run -> /health 200). .dockerignore keeps credentials/.venv/.git out of the image. Deps come from the root pyproject api extra; no service-local pyproject. Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 19 ++++++++++ .../plans/2026-07-16-warehouse-api-games.md | 5 ++- services/warehouse_api/Dockerfile | 22 +++++++++++ services/warehouse_api/README.md | 37 +++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 .dockerignore create mode 100644 services/warehouse_api/Dockerfile create mode 100644 services/warehouse_api/README.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1aa17f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Keep secrets, local envs, caches, and dev-only trees out of built images. +.git +.github +.venv +credentials/ +.env +.env.* +!.env.example +**/__pycache__/ +*.pyc +.pytest_cache/ +.ruff_cache/ +.coverage +logs/ +temp/ +tests/ +docs/ +*.egg-info/ +node_modules/ diff --git a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md index 337d744..63cb813 100644 --- a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md +++ b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md @@ -69,8 +69,9 @@ conventional-commit style (`feat(api): …`). | Create | `services/warehouse_api/routers/games.py` | `games` `APIRouter` | | Create | `tests/test_games_router.py` | Router tests via FastAPI `TestClient` (reader mocked) | | Create | `services/warehouse_api/Dockerfile` | uv-based image (model: `docker/Dockerfile.pipeline`) | -| Create | `services/warehouse_api/pyproject.toml` | Service-local deps (fastapi, uvicorn) | -| Modify | `pyproject.toml` | Add `api` optional-dependency extra (fastapi, uvicorn, httpx) | +| Create | `services/warehouse_api/README.md` | Service overview + run/deploy notes | +| Create | `.dockerignore` | Keep secrets/.venv/.git out of images | +| Modify | `pyproject.toml` | Add `api` extra (fastapi, uvicorn) + httpx to `test`; single dep source (no service-local pyproject) | | Modify | `config/cloudbuild.yaml` | Add `bgg-warehouse-api` Cloud Run service build/deploy | | Create | `.github/workflows/deploy-warehouse-api.yml` | Deploy on changes to the service | diff --git a/services/warehouse_api/Dockerfile b/services/warehouse_api/Dockerfile new file mode 100644 index 0000000..45649b3 --- /dev/null +++ b/services/warehouse_api/Dockerfile @@ -0,0 +1,22 @@ +# Warehouse read API image. Built from the repo root: +# docker build -f services/warehouse_api/Dockerfile -t warehouse-api . +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install uv + +# Dependencies from the monorepo root pyproject + the `api` extra (fastapi/uvicorn). +COPY . . +RUN uv sync --extra api + +ENV PYTHONUNBUFFERED=1 +EXPOSE 8080 + +# Cloud Run sends traffic to $PORT (default 8080). Caller auth is enforced by Cloud Run +# IAM before requests reach the app. +CMD ["uv", "run", "uvicorn", "services.warehouse_api.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/services/warehouse_api/README.md b/services/warehouse_api/README.md new file mode 100644 index 0000000..fd42d68 --- /dev/null +++ b/services/warehouse_api/README.md @@ -0,0 +1,37 @@ +# Warehouse Read API (`bgg-warehouse-api`) + +A modular-monolith FastAPI read API over the warehouse's materialized data. One router +per resource; ships `/health` and the `games` router today. + +- **Design:** `docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md` +- **Games slice:** `docs/superpowers/specs/2026-07-16-game-detail-api-design.md` +- **Auth/gating:** `docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md` + +## Layout + +- `main.py` — FastAPI app; mounts routers, exposes `/health`. +- `routers/` — one `APIRouter` per resource (`games.py`). +- `auth.py` — resolves the GCP project for the BigQuery client (ADC). Inbound caller + auth is enforced by Cloud Run IAM, not the app. +- Query logic lives in `src/warehouse/readers/` (pure, testable), not here. + +## Run locally + +```bash +uv run --extra api uvicorn services.warehouse_api.main:app --port 8080 +curl localhost:8080/health # {"status":"ok"} +curl localhost:8080/games/13 # full game document (needs GCP creds for BigQuery) +``` + +## Container + +```bash +docker build -f services/warehouse_api/Dockerfile -t warehouse-api . +docker run -p 8080:8080 warehouse-api +``` + +## Deploy + +Gated Cloud Run service (`--no-allow-unauthenticated`), deployed via +`config/cloudbuild.yaml` + `.github/workflows/deploy-warehouse-api.yml`. Access is +granted through the invoker group per the auth-pattern spec. From 5f52b9159238b3ce1b9e0348cea9cff2161df17d Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 13:45:04 -0500 Subject: [PATCH 09/11] docs(api): deploy via Actions only; gate via Terraform authoritative binding Task 7 reworked: compute deploy through a Cloud Build Actions workflow; run.invoker gating as an authoritative google_cloud_run_v2_service_iam_binding applied by terraform.yml (PR=plan, merge=apply). Two-merge ordering (service, then binding). No local gcloud/terraform. Auth-pattern spec updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-16-warehouse-api-games.md | 68 +++++++++++-------- .../2026-07-16-service-auth-pattern-design.md | 47 +++++++------ 2 files changed, 69 insertions(+), 46 deletions(-) diff --git a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md index 63cb813..eb8e795 100644 --- a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md +++ b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md @@ -244,37 +244,51 @@ def test_health(): --- -### Task 7: Deploy (Cloud Build + workflow) — gated per the auth-pattern spec +### Task 7: Deploy — everything via GitHub Actions (no local gcloud/terraform) -**Files:** Modify `config/cloudbuild.yaml`; Create `.github/workflows/deploy-warehouse-api.yml` +Compute deploy runs through a Cloud Build workflow; gating is a Terraform resource +applied by `terraform.yml`. **Nothing is run from the terminal.** An authoritative +invoker binding requires the service to already exist, so this lands in **two merges** +(7a then 7b). Local terminal is used only for parse-checks and read-only verification. -Gating follows `docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md`: -deploy authenticated, then grant `run.invoker` via the invoker group (or, day one, -direct member bindings). +**7a — Service compute (Cloud Build via Actions).** +Files: Create `config/cloudbuild.warehouse-api.yaml` (separate from the pipeline +cloudbuild — isolated blast radius); Create `.github/workflows/deploy-warehouse-api.yml`. -- [ ] **Step 1: Add a `bgg-warehouse-api` Cloud Run *service*** block to - `config/cloudbuild.yaml` — build/push the image, then `gcloud run deploy - bgg-warehouse-api --region us-central1 --no-allow-unauthenticated +- [ ] **Step 1:** Write `config/cloudbuild.warehouse-api.yaml` — `docker build -f + services/warehouse_api/Dockerfile`, push to the `bgg-data-warehouse` Artifact Registry + repo, then `gcloud run deploy bgg-warehouse-api --region us-central1 + --no-allow-unauthenticated --service-account=bgg-data-warehouse@$PROJECT_ID.iam.gserviceaccount.com`. -- [ ] **Step 2: Grant invoker access** (consumer-agnostic — see auth-pattern spec). - Preferred (group): `gcloud run services add-iam-policy-binding bgg-warehouse-api - --region us-central1 --member="group:bgg-api-invokers@googlegroups.com" - --role=roles/run.invoker`. **Day one, grant your own identity** so the API is usable - without tying it to any front-end: - `--member="user:phil.henrickson@gmail.com"`. Add each consumer's SA (a new front-end, - dash-viewer, …) to the group as it comes online. Confirm `allUsers` is **absent**. -- [ ] **Step 3: Validate YAML** — `python -c "import yaml; yaml.safe_load(open('config/cloudbuild.yaml'))" && echo valid`. -- [ ] **Step 4: Create `.github/workflows/deploy-warehouse-api.yml`** — mirror - `deploy.yml` (auth with `GCP_SA_KEY_BGG_DW`, `gcloud builds submit`), triggered on - `push` to `main` touching `services/warehouse_api/**`, `src/warehouse/**`, plus - `workflow_dispatch`. Validate its YAML too. -- [ ] **Step 5: Commit** — `ci(api): deploy gated warehouse-api to Cloud Run` -- [ ] **Step 6: Post-deploy check** (after merge): - - `curl /health` with no token → **403** (gate works). - - `curl -H "Authorization: Bearer $(gcloud auth print-identity-token)" /health` → **200**. - - `gcloud run services proxy bgg-warehouse-api --region us-central1` → hit `/games/13` - → real, populated document. - - Re-confirm the IAM policy has **no `allUsers`**. +- [ ] **Step 2:** Write `.github/workflows/deploy-warehouse-api.yml` — mirror `deploy.yml` + (auth `GCP_SA_KEY_BGG_DW`, `gcloud builds submit --config + config/cloudbuild.warehouse-api.yaml`), triggered on push to `main` touching + `services/warehouse_api/**`, `src/warehouse/**`, `config/cloudbuild.warehouse-api.yaml`, + plus `workflow_dispatch`. +- [ ] **Step 3:** Parse-check both YAMLs locally (`yaml.safe_load`) — a lint, not a deploy. +- [ ] **Step 4: Commit.** On **merge to `main`**, the workflow deploys the gated service. + With no invoker binding yet, only `run.admin`/owner (you) can invoke it — safe interim. + +**7b — Gating (Terraform via `terraform.yml`), after 7a is live.** +Files: Create `terraform/warehouse_api.tf`. + +- [ ] **Step 5:** Add an **authoritative** invoker binding — + `google_cloud_run_v2_service_iam_binding` for `roles/run.invoker` on + `bgg-warehouse-api` (`us-central1`), `members = [` the invoker group (consumer-agnostic; + a new front-end's SA joins the group later) `, your own user ]`. Authoritative binding + ⇒ **guarantees no `allUsers`** and corrects drift on every apply. (Same pattern + remediates the predictive-models services — follow-up 5.) +- [ ] **Step 6:** `terraform fmt` + open the PR → `terraform.yml` runs `terraform plan` + on the PR (review the diff). **Merge to `main`** → `terraform apply -auto-approve`. + Must merge **after** 7a, or the binding applies against a nonexistent service. + +**7c — Verification (read-only; no cloud mutation).** + +- [ ] **Step 7:** `curl /health` no token → **403**; with `Authorization: Bearer + $(gcloud auth print-identity-token)` → **200**; `gcloud run services proxy + bgg-warehouse-api` → `/games/13` → real document; confirm IAM has **no `allUsers`**. + (These read/verify only. If you want zero local `gcloud` even here, we add a smoke-test + step to the deploy workflow instead.) --- diff --git a/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md b/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md index 16f88a4..a68451f 100644 --- a/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md +++ b/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md @@ -18,20 +18,26 @@ the predictive-models services. ensure `allUsers` is **not** in its IAM policy. The service is then invocable only by identities holding `roles/run.invoker` on it. -**Grant surface — one invoker Google Group.** Create a single group, e.g. -`bgg-api-invokers@googlegroups.com` (or a Cloud Identity group), and grant it -`roles/run.invoker` on each gated service **once**. Thereafter "provide access" is a -group-membership change, never a per-service IAM edit: +**Grant surface — one invoker Google Group, bound via Terraform.** Create a single +group, e.g. `bgg-api-invokers@googlegroups.com` (or a Cloud Identity group). Grant it +`roles/run.invoker` on each gated service through **Terraform** — an *authoritative* +`google_cloud_run_v2_service_iam_binding` on the `run.invoker` role listing the group +(and, day one, your own `user:`). Authoritative binding ⇒ Terraform **guarantees no +`allUsers`** and corrects drift on every apply. The binding is applied by the +`terraform.yml` Actions workflow (PR = plan, merge = apply) — **never `gcloud +add-iam-policy-binding` from the terminal.** + +Thereafter "provide access" is a **group-membership change** (Workspace admin), never an +IAM/Terraform edit: - Authorized **person** → add `user:someone@example.com` to the group. -- **Service** caller (the dash-viewer runtime SA; later, predictive-models callers) → - add `serviceAccount:…@….iam.gserviceaccount.com` to the group. (Google Groups accept - service accounts as members.) +- **Service** caller (a new front-end's runtime SA; dash-viewer; later, predictive-models + callers) → add `serviceAccount:…@….iam.gserviceaccount.com` to the group. (Google + Groups accept service accounts as members.) - **Revoke** → remove the member. -Per-service `add-iam-policy-binding` of an individual member is the fallback when a -group isn't set up yet — but the group is what makes "I can provide access" a one-step -operation, so prefer it. +The group is what makes "I can provide access" a one-step operation without touching +infra code; Terraform pins *the group* as the sole invoker principal once. **Human access (easy).** Either: @@ -69,11 +75,13 @@ planned; dash-viewer is merely the first existing consumer). The group is what k that way: a new consumer gets access by being added to the group, and the API knows nothing about who calls it. -1. Deploy `bgg-warehouse-api` `--no-allow-unauthenticated`. -2. Grant the invoker group `roles/run.invoker` on it. **Day one, grant your own - `user:` identity** so the API is usable immediately without coupling to any - front-end. Each consumer (a new front-end's runtime SA, dash-viewer's SA, another - service) is then added to the group as it comes online — no per-consumer API change. +1. Deploy `bgg-warehouse-api` `--no-allow-unauthenticated` via the Cloud Build **Actions** + workflow (not the terminal). +2. Terraform (applied by `terraform.yml`) authoritatively binds `run.invoker` to the + invoker group **and, day one, your own `user:`** — so the API is usable immediately + without coupling to any front-end. Each consumer (a new front-end's SA, dash-viewer's + SA, another service) is then added to the **group** as it comes online — no + Terraform/API change per consumer. 3. Any consumer builds its authenticated calls with `id_token_headers(WAREHOUSE_API_URL)`. ## Extending to the predictive-models services (follow-up) @@ -82,10 +90,11 @@ The same three moves per service, one service at a time to avoid breakage: 1. Identify every current caller of the service (scoring pipeline, Streamlit, dash-viewer) and confirm each can mint an ID token (runs as a service account). -2. Add each caller's identity to the invoker group; grant the group `run.invoker` on the - service. -3. Redeploy the service `--no-allow-unauthenticated`, remove `allUsers`, and update each - caller to attach `id_token_headers(url)`. +2. Add each caller's identity to the invoker group; add an authoritative Terraform + `run.invoker` binding for the group on that service (applied via `terraform.yml`). +3. Redeploy the service `--no-allow-unauthenticated` via its Actions workflow (the + authoritative binding removes `allUsers`), and update each caller to attach + `id_token_headers(url)`. **Special case — `bgg-streamlit-prod`:** it's a *browser-facing* dashboard, so ID-token/`run.invoker` gating doesn't apply (browsers don't send ID tokens). That one From f6bfe14943567f8d084f781d8a1627127f03c9fc Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 13:49:23 -0500 Subject: [PATCH 10/11] ci(api): deploy bgg-warehouse-api via Cloud Build Actions workflow (7a) Gated Cloud Run service (--no-allow-unauthenticated) built/deployed by config/cloudbuild.warehouse-api.yaml, triggered by deploy-warehouse-api.yml on push to main. Invoker gating lives in Terraform (separate PR). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/deploy-warehouse-api.yml | 39 ++++++++++++++++++++++ config/cloudbuild.warehouse-api.yaml | 38 +++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 .github/workflows/deploy-warehouse-api.yml create mode 100644 config/cloudbuild.warehouse-api.yaml diff --git a/.github/workflows/deploy-warehouse-api.yml b/.github/workflows/deploy-warehouse-api.yml new file mode 100644 index 0000000..d3d6c9f --- /dev/null +++ b/.github/workflows/deploy-warehouse-api.yml @@ -0,0 +1,39 @@ +name: Deploy Warehouse API + +on: + push: + branches: [ main ] + paths: + - 'services/warehouse_api/**' + - 'src/warehouse/**' + - 'config/cloudbuild.warehouse-api.yaml' + - 'config/bigquery.yaml' + - 'pyproject.toml' + - 'uv.lock' + workflow_dispatch: + +env: + GCP_PROJECT_ID: bgg-data-warehouse + GCP_REGION: us-central1 + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Google Cloud Auth + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY_BGG_DW }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@v2 + + - name: Build and deploy (Cloud Build) + run: | + gcloud builds submit --config config/cloudbuild.warehouse-api.yaml \ + --project=${GCP_PROJECT_ID} diff --git a/config/cloudbuild.warehouse-api.yaml b/config/cloudbuild.warehouse-api.yaml new file mode 100644 index 0000000..cc6f805 --- /dev/null +++ b/config/cloudbuild.warehouse-api.yaml @@ -0,0 +1,38 @@ +# Cloud Build config for the warehouse read API (bgg-warehouse-api Cloud Run *service*). +# Invoked by .github/workflows/deploy-warehouse-api.yml via `gcloud builds submit`. +# `gcloud run deploy` is create-or-update, so no describe/if branching is needed. +# Gating (run.invoker) is NOT set here — it's an authoritative Terraform binding +# (terraform/warehouse_api.tf), applied by the terraform.yml workflow. +steps: + # Build the API image. + - name: 'gcr.io/cloud-builders/docker' + args: + - 'build' + - '-f' + - 'services/warehouse_api/Dockerfile' + - '-t' + - 'gcr.io/$PROJECT_ID/bgg-warehouse-api:latest' + - '.' + + # Push it. + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'gcr.io/$PROJECT_ID/bgg-warehouse-api:latest'] + + # Deploy the Cloud Run service — gated (--no-allow-unauthenticated). + - name: 'gcr.io/cloud-builders/gcloud' + args: + - 'run' + - 'deploy' + - 'bgg-warehouse-api' + - '--image=gcr.io/$PROJECT_ID/bgg-warehouse-api:latest' + - '--region=us-central1' + - '--platform=managed' + - '--no-allow-unauthenticated' + - '--service-account=bgg-data-warehouse@$PROJECT_ID.iam.gserviceaccount.com' + - '--memory=1Gi' + - '--cpu=1' + - '--max-instances=5' + - '--port=8080' + +images: + - 'gcr.io/$PROJECT_ID/bgg-warehouse-api:latest' From 30e6affec179a22063f8ad1236932543540680bd Mon Sep 17 00:00:00 2001 From: phenrickson Date: Thu, 16 Jul 2026 14:06:53 -0500 Subject: [PATCH 11/11] docs(api): make Terraform members list the canonical invoker grant surface Access grants become a members-list edit -> PR -> apply via Actions (git-audited), instead of manual Google Group membership (which is outside Actions). Group demoted to an optional footnote. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-16-warehouse-api-games.md | 10 +++-- .../2026-07-16-service-auth-pattern-design.md | 41 ++++++++++--------- ...-warehouse-services-architecture-design.md | 5 ++- 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md index eb8e795..742ec0f 100644 --- a/docs/superpowers/plans/2026-07-16-warehouse-api-games.md +++ b/docs/superpowers/plans/2026-07-16-warehouse-api-games.md @@ -274,10 +274,12 @@ Files: Create `terraform/warehouse_api.tf`. - [ ] **Step 5:** Add an **authoritative** invoker binding — `google_cloud_run_v2_service_iam_binding` for `roles/run.invoker` on - `bgg-warehouse-api` (`us-central1`), `members = [` the invoker group (consumer-agnostic; - a new front-end's SA joins the group later) `, your own user ]`. Authoritative binding - ⇒ **guarantees no `allUsers`** and corrects drift on every apply. (Same pattern - remediates the predictive-models services — follow-up 5.) + `bgg-warehouse-api` (`us-central1`), with a Terraform `members` list = **[ your own + `user:` ]** day one. Adding a consumer later = add its `serviceAccount:` to the list + via a PR (grant surface is the Terraform list, not a Google Group — keeps grants in + code/Actions and git-audited). Authoritative binding ⇒ **guarantees no `allUsers`** and + corrects drift on every apply. (Same pattern remediates the predictive-models + services — follow-up 5.) - [ ] **Step 6:** `terraform fmt` + open the PR → `terraform.yml` runs `terraform plan` on the PR (review the diff). **Merge to `main`** → `terraform apply -auto-approve`. Must merge **after** 7a, or the binding applies against a nonexistent service. diff --git a/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md b/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md index a68451f..cea062c 100644 --- a/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md +++ b/docs/superpowers/specs/2026-07-16-service-auth-pattern-design.md @@ -18,26 +18,28 @@ the predictive-models services. ensure `allUsers` is **not** in its IAM policy. The service is then invocable only by identities holding `roles/run.invoker` on it. -**Grant surface — one invoker Google Group, bound via Terraform.** Create a single -group, e.g. `bgg-api-invokers@googlegroups.com` (or a Cloud Identity group). Grant it -`roles/run.invoker` on each gated service through **Terraform** — an *authoritative* -`google_cloud_run_v2_service_iam_binding` on the `run.invoker` role listing the group -(and, day one, your own `user:`). Authoritative binding ⇒ Terraform **guarantees no -`allUsers`** and corrects drift on every apply. The binding is applied by the +**Grant surface — an authoritative Terraform `members` list.** The allow-list of who may +invoke each gated service is the `members` of an *authoritative* +`google_cloud_run_v2_service_iam_binding` (role `roles/run.invoker`), applied by the `terraform.yml` Actions workflow (PR = plan, merge = apply) — **never `gcloud -add-iam-policy-binding` from the terminal.** +add-iam-policy-binding` from the terminal.** Authoritative ⇒ the list is the *complete* +allow-list: anything not on it, including `allUsers`, cannot invoke, and drift is +corrected on every apply. -Thereafter "provide access" is a **group-membership change** (Workspace admin), never an -IAM/Terraform edit: +"Provide access" = **edit the `members` list → PR → merge.** Every grant is therefore +in code, reviewed on a PR, **git-audited** (who, when, which commit), and applied by +Actions — consistent with the "everything via Actions" rule: -- Authorized **person** → add `user:someone@example.com` to the group. +- Authorized **person** → add `user:someone@example.com`. - **Service** caller (a new front-end's runtime SA; dash-viewer; later, predictive-models - callers) → add `serviceAccount:…@….iam.gserviceaccount.com` to the group. (Google - Groups accept service accounts as members.) -- **Revoke** → remove the member. + callers) → add `serviceAccount:…@….iam.gserviceaccount.com`. +- **Revoke** → remove the entry. -The group is what makes "I can provide access" a one-step operation without touching -infra code; Terraform pins *the group* as the sole invoker principal once. +*Optional convenience:* a Google Group may be listed as a single `group:` member if you +later want self-serve access for non-technical teammates — but a group's membership is +managed in the Workspace/Groups console, i.e. **outside** Terraform/Actions, trading +IaC-auditability for convenience (and on a personal-Gmail project Terraform can't manage +the group itself). **Prefer the direct `members` list.** **Human access (easy).** Either: @@ -78,10 +80,9 @@ nothing about who calls it. 1. Deploy `bgg-warehouse-api` `--no-allow-unauthenticated` via the Cloud Build **Actions** workflow (not the terminal). 2. Terraform (applied by `terraform.yml`) authoritatively binds `run.invoker` to the - invoker group **and, day one, your own `user:`** — so the API is usable immediately + `members` list — **day one, just your own `user:`**, so the API is usable immediately without coupling to any front-end. Each consumer (a new front-end's SA, dash-viewer's - SA, another service) is then added to the **group** as it comes online — no - Terraform/API change per consumer. + SA, another service) is added to the list **via a PR** as it comes online. 3. Any consumer builds its authenticated calls with `id_token_headers(WAREHOUSE_API_URL)`. ## Extending to the predictive-models services (follow-up) @@ -90,8 +91,8 @@ The same three moves per service, one service at a time to avoid breakage: 1. Identify every current caller of the service (scoring pipeline, Streamlit, dash-viewer) and confirm each can mint an ID token (runs as a service account). -2. Add each caller's identity to the invoker group; add an authoritative Terraform - `run.invoker` binding for the group on that service (applied via `terraform.yml`). +2. Add each caller's identity to that service's authoritative Terraform `run.invoker` + `members` list (applied via `terraform.yml`). 3. Redeploy the service `--no-allow-unauthenticated` via its Actions workflow (the authoritative binding removes `allUsers`), and update each caller to attach `id_token_headers(url)`. diff --git a/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md b/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md index 7469de7..8b0f14e 100644 --- a/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md +++ b/docs/superpowers/specs/2026-07-16-warehouse-services-architecture-design.md @@ -103,8 +103,9 @@ it modular; if one resource ever needs isolation, it lifts out cleanly. - **Responses:** `{ data, meta }` envelope with pydantic models; `404` on missing id. - **Auth:** gated per the **service auth pattern** (`2026-07-16-service-auth-pattern-design.md`) — Cloud Run IAM + Google-signed ID - token, **not** public. Access is granted via an invoker Google Group; the same pattern - is the one we extend to the (currently `allUsers`) predictive-models services. + token, **not** public. Access is granted via an authoritative Terraform `run.invoker` + `members` list (edit → PR → apply via Actions); the same pattern is the one we extend + to the (currently `allUsers`) predictive-models services. - **Config:** add `predictions`, `analytics`, `monitoring`, `staging` to `config/bigquery.yaml` + `src/config.py` (currently only `core`, `raw`).