# noqa: E501 """API handlers producing the frozen API_CONTRACT.md shapes (spec §5). Handler(req) -> (status, body). Driven by the stdlib HTTP server and by unit tests with no sockets. A Services bundle carries collaborators so handlers stay pure of instantiation. Errors per §6; empty results are 200 with empty lists. """ from __future__ import annotations from typing import Any, Dict, List, Optional, Tuple from . import data as _data from . import ranking from . import resolve as _resolve from . import bill_status from .data import (load_tag_cloud, list_events, load_legislators, committee_roster_for) from .ranking import hearing_sort, full_queue, personal_band from .context import from_prefs from .prefs import PrefsStore, LocalAuth # error model (§6) def api_error(code, message, status=400, field=None): err = {"error": {"code": code, "message": message}} if field: err["error"]["field"] = field return status, err # service bundle class Services: def __init__(self, db_path, prefs, auth): self.db_path = db_path self.prefs = prefs self.auth = auth @classmethod def default(cls, db_path=None, prefs_path="out/prefs.json", auth_path="out/auth.json"): from .. import relational db = db_path or relational.DEFAULT_DB return cls(db_path=db, prefs=PrefsStore(prefs_path), auth=LocalAuth(auth_path)) def prefs_for_user(self, user_id): if not user_id: return {} return self.prefs.get(user_id) def user_from_token(self, token): if not token: return None return self.auth.verify_token(token) # request class Request: def __init__(self, method, path, query=None, body=None, token=None, services=None, user_id=None): self.method = str(method).upper() self.path = str(path).lstrip("/") self.query = query or {} self.body = body or {} self.token = token self.services = services self.user_id = user_id @property def svc(self): if self.services is None: raise RuntimeError("Request built without a Services bundle.") return self.services def require_auth(self): uid = self.user_id or self.svc.user_from_token(self.token) if not uid: return None, api_error("not_authenticated", "A bearer token is required for this endpoint.", 401) self.user_id = uid return uid, None # query helpers def q_list(key, query): v = query.get(key) if v is None: return [] if isinstance(v, list): return [str(x).strip() for x in v if str(x).strip()] return [s.strip() for s in str(v).split(",") if s.strip()] def _now_iso(): import datetime return datetime.datetime.now(datetime.timezone.utc).strftime( "%Y-%m-%dT%H:%M:%SZ") def _truthy(v): if v is None: return False if isinstance(v, str): return v.strip().lower() in ("1", "true", "yes", "on") return bool(v) # GET /api/v1/bills (§1/§2) def handle_bills(req): q = req.query events = list_events( db_path=req.svc.db_path, upcoming=_truthy(q.get("upcoming")), from_date=q.get("from") or q.get("date_from"), to_date=q.get("to") or q.get("date_to"), chamber=q.get("chamber"), tags=q_list("tags", q), q=q.get("q"), author=q.get("author"), as_of=q.get("as_of"), ) if _truthy(q.get("personal")): uid, err = req.require_auth() if err: return err prefs = req.svc.prefs_for_user(uid) roster = committee_roster_for( req.svc.db_path, set(prefs.get("watched_legislators") or [])) ctx = from_prefs(prefs, committee_roster=roster) for ev in events: ev["relevance"] = ctx.score(ev) if _truthy(q.get("full")): events = full_queue(events) else: events = personal_band( events, int(q.get("band_n") or ranking.PERSONAL_BAND_N)) session = _detect_session(events, req.svc.db_path) items = [_client_bill(e) for e in events] groups = {} for e in items: key = e.get("date") or "TBD" groups[key] = groups.get(key, 0) + 1 chamber_avail = _chamber_availability(items, req.svc.db_path) body = { "session": session, "as_of": q.get("as_of") or _now_iso(), "chamber_availability": chamber_avail, "items": items, "groups": groups, } return 200, body def _client_bill(ev): out = {k: ev.get(k) for k in _data._EVENT_FIELDS} out["hearing_sort"] = hearing_sort(ev.get("hearing_time")) out["tags"] = ev.get("tags") # API_CONTRACT §5 "upcoming" item 2: label the bill's current state (a # past/terminal floor action shouldn't read as a vague "pending"), and # flag possibly_closed when the source can't disambiguate. st = bill_status.derive_status(ev.get("description"), ev.get("bill_number")) out["status"] = st["status"] out["reason"] = st["reason"] out["possibly_closed"] = st["possibly_closed"] if "relevance" in ev: out["relevance"] = ev["relevance"] return out def _detect_session(events, db_path): for e in events: if e.get("session"): return str(e["session"]) return "2025-2026" def _chamber_availability(items, db_path): have = {str(e.get("chamber_origin") or "ASSEMBLY").upper() for e in items} if not have: for e in list_events(db_path=db_path): have.add(str(e.get("chamber_origin") or "ASSEMBLY").upper()) out = {} for ch in ("ASSEMBLY", "SENATE"): present = ch in have or any(h == ch for h in have) out[ch] = "available" if present else "pending" return out # GET /api/v1/tags (§4.1) def handle_tags(req): top = req.query.get("top") top = int(top) if top and str(top).isdigit() else None return 200, load_tag_cloud(req.svc.db_path, top=top) # GET /api/v1/legislators (§3.2 / §4.2) def handle_legislators(req): q = req.query.get("q") if isinstance(q, list): q = q[0] return 200, {"items": load_legislators(req.svc.db_path, q=q)} # GET /api/v1/districts (§4.2) def handle_districts(req): seen = {} for entry in _resolve.DEMO_ZIPS.values(): for d in entry: seen[d["district_id"]] = d items = [ {"district_id": d["district_id"], "type": d["type"], "number": d.get("number"), "assembly_member": d.get("assembly_member"), "senate_member": d.get("senate_member")} for d in seen.values() ] return 200, {"items": sorted(items, key=lambda x: x["district_id"])} # POST /api/v1/districts/resolve (§3.1 — proposes, never persists) def handle_resolve(req): b = req.body or {} zip_ = b.get("zip") address = b.get("address") district = b.get("district") if not zip_ and not address and not district: return api_error("invalid_zip", "Provide one of: zip, address, or district.", 400, field="zip") result = _resolve.resolve(zip=zip_, address=address, district=district) conf = result["confidence"] if conf == "out-of-state": return api_error("out_of_state", "That input is not in California.", 400, field="zip") if conf == "zero": return api_error("district_not_found", "Could not match the input to a California district.", 404, field="zip" if zip_ else "address") if conf == "ambiguous": out = dict(result) out["note"] = ("Multiple districts match; confirm one before " "watching (never auto-picked).") return 200, out return 200, result # /api/v1/prefs (§3.3) def handle_get_prefs(req): uid, err = req.require_auth() if err: return err prefs = req.svc.prefs_for_user(uid) if not prefs: return api_error("prefs_not_set", "No preferences stored; set a ZIP / watch / topics.", 404) prefs = dict(prefs) prefs["user_id"] = uid prefs["updated_at"] = prefs.get("updated_at") or _now_iso() return 200, prefs def handle_put_prefs(req): uid, err = req.require_auth() if err: return err b = dict(req.body or {}) allowed = {"zip", "address", "home_chamber", "districts", "watched_legislators", "opted_in_tags"} valid_tags = _valid_tag_set(req) known_legs = {x["legislator_id"] for x in load_legislators(req.svc.db_path)} for k, v in b.items(): if k not in allowed: return api_error("invalid_config", "Unknown prefs field: %s" % k, 400, field=k) if k == "opted_in_tags": for t in v: if str(t).lower() not in valid_tags: return api_error("tag_unknown", "Unknown topic: %s" % t, 400, field="opted_in_tags") if k == "watched_legislators": for w in v: if known_legs and w not in known_legs: return api_error("legislator_unknown", "Unknown legislator: %s" % w, 400, field="watched_legislators") clean = {k: b[k] for k in b if k in allowed} clean["updated_at"] = _now_iso() updated = req.svc.prefs.put(uid, clean) updated["user_id"] = uid return 200, updated def _valid_tag_set(req): tags = load_tag_cloud(req.svc.db_path)["tags"] return {t["tag_id"].split(":", 1)[-1].lower() for t in tags} # watch / topic toggles (§3.4 / §3.5 — idempotent) def handle_add_watch(req): uid, err = req.require_auth() if err: return err b = req.body or {} lid = b.get("legislator_id") or b.get("id") if not lid: return api_error("invalid_legislator", "legislator_id is required.", 400, field="legislator_id") if not _known_legislator(req, lid): return api_error("legislator_unknown", "Unknown legislator: %s" % lid, 400, field="id") prefs = req.svc.prefs.add_watch(uid, lid) prefs["user_id"] = uid return 201, prefs def handle_remove_watch(req, legislator_id): uid, err = req.require_auth() if err: return err prefs = req.svc.prefs.remove_watch(uid, legislator_id) prefs["user_id"] = uid return 200, prefs def handle_add_topic(req): uid, err = req.require_auth() if err: return err b = req.body or {} tag = b.get("tag") or b.get("tag_id") or b.get("id") if not tag: return api_error("invalid_tag", "tag is required.", 400, field="tag") if str(tag).lower() not in _valid_tag_set(req): return api_error("tag_unknown", "Unknown topic: %s" % tag, 400, field="tag") prefs = req.svc.prefs.add_topic(uid, str(tag).lower()) prefs["user_id"] = uid return 201, prefs def handle_remove_topic(req, tag): uid, err = req.require_auth() if err: return err prefs = req.svc.prefs.remove_topic(uid, tag) prefs["user_id"] = uid return 200, prefs def _known_legislator(req, lid): return any(x.get("legislator_id") == lid for x in load_legislators(req.svc.db_path)) # Minimal local-account auth (D1a default — dev placeholder) def handle_register(req): email = (req.body or {}).get("email", "").strip().lower() if not email or "@" not in email: return api_error("invalid_email", "A valid email is required.", 400, field="email") rec = req.svc.auth.register(email) req.svc.prefs.put(rec["user_id"], {"email": rec["email"]}) return 201, rec def handle_whoami(req): uid = req.user_id or req.svc.user_from_token(req.token) if not uid: return {"user_id": None} return {"user_id": uid, "prefs": req.svc.prefs_for_user(uid)} # router def _strip(req): p = req.path base = "api/v1/" return p[len(base):] if p.startswith(base) else p def run(req): m = req.method p2 = _strip(req) if m == "POST" and p2 == "auth/register": return handle_register(req) if m == "GET" and p2 == "whoami": return 200, handle_whoami(req) if m == "GET" and p2 == "bills": return handle_bills(req) if m == "GET" and p2 == "tags": return handle_tags(req) if m == "GET" and p2 == "legislators": return handle_legislators(req) if m == "GET" and p2 == "districts": return handle_districts(req) if m == "POST" and p2 == "districts/resolve": return handle_resolve(req) if m == "GET" and p2 == "prefs": return handle_get_prefs(req) if m == "PUT" and p2 == "prefs": return handle_put_prefs(req) if m == "POST" and p2 == "prefs/watch": return handle_add_watch(req) if m == "DELETE" and p2.startswith("prefs/watch/"): return handle_remove_watch(req, p2.split("/", 3)[2]) if m == "POST" and p2 == "prefs/topics": return handle_add_topic(req) if m == "DELETE" and p2.startswith("prefs/topics/"): return handle_remove_topic(req, p2.split("/", 3)[2]) if m == "GET" and p2 == "": return 200, {"endpoints": [ "GET /api/v1/bills?upcoming=1&personal=1", "GET /api/v1/tags", "GET /api/v1/legislators?q=", "GET /api/v1/districts", "POST /api/v1/districts/resolve", "GET/PUT /api/v1/prefs", "POST/DELETE /api/v1/prefs/watch[/:id]", "POST/DELETE /api/v1/prefs/topics[/:id]", "POST /api/v1/auth/register", "GET /api/v1/whoami", ]} return api_error("not_found", "No such endpoint: %s %s" % (m, req.path), 404)