"""SSR content JSON API — the data a frontend SSR layer needs to render the climate hub / per-city / month / records / homepage surfaces, without importing backend code in-process. See backend/api/content_payloads.py for the builders; this module is just FastAPI route wiring + the same derived-store/ETag caching pattern web/app.py's own endpoints use. """ import hashlib import os from fastapi import APIRouter, HTTPException, Request, Response from api import content_payloads as payloads from api import sitemap as sitemap_mod from api.payloads import content_token from data import climate from data import cities from data import grid from data import store router = APIRouter(tags=["content"]) # Same convention as web/app.py and web/content.py: the deployment's base path, # used only for the breadcrumb hrefs and jsonld URL this endpoint returns. _BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/") BASE = f"/{_BASE}" if _BASE else "" def _etag_for(kind: str, cell_id: str, key: str, token: str) -> str: h = hashlib.sha1(f"{kind}:{cell_id}:{key}:{token}".encode()).hexdigest()[:20] return f'W/"{h}"' def _not_modified(request: Request, etag: str) -> bool: inm = request.headers.get("if-none-match") if not inm: return False tags = {t.strip() for t in inm.split(",")} return "*" in tags or etag in tags or etag.removeprefix("W/") in tags def _json_response(body: bytes, etag: str) -> Response: return Response(content=body, media_type="application/json", headers={"ETag": etag}) def _cached(request: Request, kind: str, cell_id: str, key: str, token: str, build) -> Response: """Same shape as web/app.py's _cached_response: If-None-Match -> empty 304; a token-valid store row -> replay it; otherwise build, persist, serve. store.get_payload/put_payload both deal in already-encoded JSON bytes, not the payload dict -- put_payload returns the exact bytes it persisted so the caller can serve them without re-serializing.""" etag = _etag_for(kind, cell_id, key, token) if _not_modified(request, etag): return Response(status_code=304, headers={"ETag": etag}) body = store.get_payload(kind, cell_id, key, token) if body is not None: return _json_response(body, etag) payload = build() return _json_response(store.put_payload(kind, cell_id, key, token, payload), etag) def _city_cell(slug: str): """(city, cell) for a slug, or raise 404 — both steps are cheap (a dict lookup and a pure grid snap) and, crucially, load no history, so the derived-store token can be computed and a cache hit served without ever touching the ~45-year archive.""" city = cities.get(slug) if city is None: raise HTTPException(status_code=404, detail="Unknown city.") return city, grid.snap(city["lat"], city["lon"]) def _load_history(cell): """The cell's ~45-year archive, or raise 503 while it's still warming -- the expensive load that used to run on every request. Now called only from the content handlers' build() closures, i.e. only on a derived-store miss.""" history = climate.load_cached_history(cell) if history is None or history.is_empty(): try: history, _ = climate.get_history(cell) except Exception: # noqa: BLE001 history = None if history is None or history.is_empty(): raise HTTPException(status_code=503, detail="Climate data is warming up; please retry shortly.") return history def _origin(request: Request) -> str: proto = request.headers.get("x-forwarded-proto") or request.url.scheme host = request.headers.get("host") or request.url.netloc return f"{proto}://{host}" @router.get("/content/hub") def content_hub(request: Request): return payloads.hub_payload() @router.get("/content/sitemap") def content_sitemap(request: Request): return [{"path": p, "changefreq": cf, "priority": pr} for p, cf, pr in sitemap_mod.sitemap_entries()] @router.get("/content/indexnow-key") def content_indexnow_key(request: Request): import indexnow return {"key": indexnow.key()} @router.get("/content/home") def content_home(request: Request): return payloads.home_payload() @router.get("/content/city/{slug}") def content_city(request: Request, slug: str): city, cell = _city_cell(slug) token = content_token(cell["id"]) origin = _origin(request) def build(): history = _load_history(cell) recent = None try: recent = climate.get_recent_forecast(cell) except Exception: # noqa: BLE001 - today_vs_normal degrades to None pass return payloads.city_payload(origin, BASE, city, history, recent) # origin is folded into the cache key (not just slug) because the payload's # jsonld.url is origin-qualified -- without this, whichever origin's request # happens to populate the cache first would be served to every other origin # too (and the ETag, derived from this same key, wouldn't vary either, so a # different origin's client could get a false 304). A no-op in the default # single-canonical-origin prod topology; load-bearing once something is # genuinely served cross-origin (see Stage 5). return _cached(request, "content-city", cell["id"], f"{slug}:{origin}", token, build) @router.get("/content/city/{slug}/month/{month}") def content_city_month(request: Request, slug: str, month: str): if month not in payloads.MONTH_INDEX: raise HTTPException(status_code=404, detail="Unknown month.") city, cell = _city_cell(slug) token = content_token(cell["id"]) def build(): history = _load_history(cell) return payloads.month_payload(BASE, city, history, payloads.MONTH_INDEX[month]) return _cached(request, "content-month", cell["id"], f"{slug}:{month}", token, build) @router.get("/content/city/{slug}/records") def content_city_records(request: Request, slug: str): city, cell = _city_cell(slug) token = content_token(cell["id"]) origin = _origin(request) def build(): history = _load_history(cell) return payloads.records_payload(origin, BASE, city, history) # See content_city's comment: origin folded into the cache key for the # same jsonld.url / ETag-correctness reason. return _cached(request, "content-records", cell["id"], f"{slug}:{origin}", token, build)