99 lines
4.2 KiB
Python
99 lines
4.2 KiB
Python
"""HTTP client for the backend's SSR content API (backend/api/content_routes.py).
|
|
|
|
Fails loud at import if THERMOGRAPH_API_BASE_INTERNAL is unset -- same
|
|
philosophy as content_loader.py's fail-loud content validation: a missing
|
|
backend URL should break the boot, not silently 500 on the first request.
|
|
|
|
A short TTL cache sits in front of every call: climate data changes at most
|
|
hourly (the archive top-up + hourly recent-forecast refresh), so without this
|
|
a page-view burst on one city would cost one backend round trip per request
|
|
instead of one per cache window. This is in addition to (not instead of) the
|
|
backend's own derived-store/ETag caching -- this cache saves the network hop
|
|
entirely; the backend's saves the recompute.
|
|
"""
|
|
import os
|
|
import time
|
|
|
|
import httpx
|
|
|
|
API_BASE = os.environ.get("THERMOGRAPH_API_BASE_INTERNAL")
|
|
if not API_BASE:
|
|
raise RuntimeError("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)")
|
|
|
|
# Single pin point for the backend content-API version this client speaks.
|
|
# Every path builder below reads this instead of hardcoding "v2" so a backend
|
|
# version bump is a one-line change here (plus the analogous JS pin in
|
|
# static/account.js) instead of a hunt-and-replace across ~10 files.
|
|
API_VERSION = os.environ.get("THERMOGRAPH_API_VERSION", "v2")
|
|
|
|
# Backend's own routes (including the content API this client calls) sit
|
|
# under ITS OWN THERMOGRAPH_BASE, exactly like content.py computes for
|
|
# frontend's own routes -- both processes are always configured with the same
|
|
# value in every real deployment (compose, run.sh, CI all set it identically
|
|
# for both), so reading it here too keeps this client correct under a
|
|
# sub-path deployment instead of only working when BASE is empty/root.
|
|
_BASE = os.environ.get("THERMOGRAPH_BASE", "/thermograph").strip("/")
|
|
_BASE_PREFIX = f"/{_BASE}" if _BASE else ""
|
|
|
|
_TTL = float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600) # 10 min default
|
|
_cache: dict[str, tuple[float, object]] = {}
|
|
|
|
|
|
def _get(path: str, *, origin: str | None = None) -> object:
|
|
"""GET {API_BASE}{path}, cached for _TTL seconds. Raises httpx.HTTPStatusError
|
|
on a non-2xx response (including 404/503) so callers can map it the same
|
|
way the old in-process code raised HTTPException.
|
|
|
|
`origin` (e.g. "https://thermograph.org") is the ORIGINAL browser-facing
|
|
request's origin, not this internal call's -- forwarded as Host/
|
|
X-Forwarded-Proto so the backend's own _origin(request) (which already
|
|
prefers those headers) builds jsonld "url" fields against the public
|
|
origin instead of this internal http://backend:8137-style address. Folded
|
|
into the cache key: harmless when there's one canonical origin (the
|
|
default same-domain prod topology), and correct when there's more than
|
|
one (the genuinely-cross-origin capability Stage 5 proves out)."""
|
|
cache_key = f"{origin}|{path}" if origin else path
|
|
now = time.time()
|
|
hit = _cache.get(cache_key)
|
|
if hit and now - hit[0] < _TTL:
|
|
return hit[1]
|
|
headers = {}
|
|
if origin:
|
|
proto, _, host = origin.partition("://")
|
|
headers = {"host": host, "x-forwarded-proto": proto}
|
|
resp = httpx.get(f"{API_BASE}{_BASE_PREFIX}{path}", timeout=10.0, headers=headers)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
_cache[cache_key] = (now, data)
|
|
return data
|
|
|
|
|
|
def hub() -> dict:
|
|
return _get(f"/api/{API_VERSION}/content/hub")
|
|
|
|
|
|
def sitemap() -> list[dict]:
|
|
return _get(f"/api/{API_VERSION}/content/sitemap")
|
|
|
|
|
|
def indexnow_key() -> str:
|
|
return _get(f"/api/{API_VERSION}/content/indexnow-key")["key"]
|
|
|
|
|
|
def home() -> dict:
|
|
return _get(f"/api/{API_VERSION}/content/home")
|
|
|
|
|
|
def city(slug: str, *, origin: str | None = None) -> dict:
|
|
"""Raises httpx.HTTPStatusError with .response.status_code 404 (unknown
|
|
city) or 503 (warming) -- callers translate these to FastAPI HTTPException,
|
|
same status codes content.py's _resolve_city raised in-process."""
|
|
return _get(f"/api/{API_VERSION}/content/city/{slug}", origin=origin)
|
|
|
|
|
|
def city_month(slug: str, month: str) -> dict:
|
|
return _get(f"/api/{API_VERSION}/content/city/{slug}/month/{month}")
|
|
|
|
|
|
def city_records(slug: str, *, origin: str | None = None) -> dict:
|
|
return _get(f"/api/{API_VERSION}/content/city/{slug}/records", origin=origin)
|