Some checks failed
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 13s
secrets-guard / encrypted (pull_request) Successful in 14s
PR build (required check) / changes (pull_request) Successful in 16s
PR build (required check) / validate-observability (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 28s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 1m58s
Deploy frontend to LAN dev server / build (push) Successful in 1m59s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 2m5s
Deploy backend to LAN dev server / build (push) Successful in 2m20s
PR build (required check) / build-frontend (pull_request) Successful in 1m44s
Deploy frontend to LAN dev server / deploy (push) Successful in 30s
PR build (required check) / build-backend (pull_request) Successful in 2m0s
PR build (required check) / gate (pull_request) Successful in 2s
Deploy backend to LAN dev server / deploy (push) Failing after 1m58s
181 lines
7.2 KiB
Python
181 lines
7.2 KiB
Python
"""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 urllib.parse import urlsplit
|
|
|
|
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
|
|
|
|
|
|
# The configured public origin is the source of truth for the scheme. Caddy
|
|
# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and
|
|
# request.url.scheme) read "http" even though the public site is HTTPS-only --
|
|
# and this origin is folded into the jsonld "url" the payloads carry. Trusting
|
|
# the forwarded scheme makes that url (and the canonical/og the frontend builds
|
|
# from the same origin it forwards here) emit http://, which 308-redirects to
|
|
# https://. So a request that arrives on the configured public host is answered
|
|
# with the configured public scheme; localhost/LAN dev never matches that host
|
|
# and keeps the observed scheme, leaving plain-HTTP development unaffected.
|
|
_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org"))
|
|
|
|
|
|
def _origin(request: Request) -> str:
|
|
host = request.headers.get("host") or request.url.netloc
|
|
if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc:
|
|
proto = _PUBLIC.scheme
|
|
else:
|
|
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
|
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)
|