thermograph/frontend/server/internal/contentapi/client.go

330 lines
11 KiB
Go
Raw Normal View History

frontend: rewrite the SSR content service in Go Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No climate math, no DB, no auth here -- every route fetches from the backend's /content/* API, so this is I/O-bound glue with no hard-porting wall; the risk was always in reproducing the rendering exactly, not the language. Verified with a golden-HTML diff, not just unit tests: both the Python original and the Go rewrite were run against the same committed fixtures (frontend/tests/fixtures/) and every one of the 11 routes compared byte-for-byte. The only surviving differences after that process are insignificant inter-tag whitespace and one attribute where Go's stricter escaper HTML-encodes an apostrophe Jinja left literal (functionally identical in every browser) -- confirmed programmatically by normalizing whitespace and unescaping before diffing, not by eyeballing. That process caught defects unit tests alone would have missed, because map[string]any has no compile-time field check: - Render-context keys were snake_case throughout (content.py's Jinja convention, ported verbatim) while the templates -- written independently -- read PascalCase fields. A missing map key doesn't error in html/template, it silently renders empty, so this was invisible in every status code and every "it built" signal: title, meta description, canonical URL, OpenGraph tags, the homepage's entire ranked list, and the brand-tag/nav-active state were all blank across every page. Fixed by renaming every key to match each template's own header comment (the authoritative per-page field contract) and, where an API struct's exported fields already matched what a template needed (contentapi.CityInfo, Crumb, HomeRanked, HubCountry, ...), passing the struct straight through instead of hand-rewrapping it in a map -- removes a whole layer of future drift risk, not just this instance of it. - Three pages 500'd outright: `.ToolHref` needed a fully-composed href string, not the bare "lat,lon" fragment the handlers were building; the all-time-records table needed the raw contentapi.AllTimeRecords struct, not a re-wrapped map. - JSON-LD was being double-encoded: `<script type="application/ld+json">` is JAVASCRIPT context to html/template's contextual escaper regardless of the script's `type` attribute, so a template.HTML-typed value placed there gets re-escaped as a quoted JS string instead of emitted raw -- the entire structured-data payload shipped as a JSON string containing JSON, which no crawler would parse as the intended object. Needed template.JS instead, the type that actually means "trusted JS source." The glossary term page's JSON-LD was simply never built at all (the Jinja original assembled it inline in the template rather than through content.py's context dict, and that got lost in translation) -- added. - html/template silently strips literal HTML comments AND JavaScript comments from the parsed output (verified in isolation, zero template actions involved) -- confirmed as real engine behavior, not a bug in either port, so both need a FuncMap function returning template.HTML / template.JS respectively to survive parsing rather than a literal `<!-- -->` or `//` in the template source. Packaging: multi-stage Go build, final image alpine (not distroless -- the Swarm stack's env-entrypoint.sh shim needs bash), 187MB -> 22.6MB. Two defects caught before they reached a host: - The Swarm stack overrides `entrypoint:` with no `command:`, which drops the image's own CMD entirely (Docker/Swarm semantics, not merged) -- env-entrypoint.sh then fell through to its hardcoded `exec uvicorn app:app` fallback, which doesn't exist in this image. Every deploy would have exited 127. Fixed with an explicit `command:` on the stack's frontend service, and corrected the shim's stale comment claiming CMD passes through automatically. - `COPY --chown=thermograph` resolves the group by NAME at copy time; Alpine's `adduser -S` with no `-G` doesn't create a same-named group, so the classic (non-BuildKit) Docker builder -- which this CI runner falls back to, since it installs plain `docker.io` with no buildx plugin -- failed outright. Fixed with an explicit group and numeric --chown. Verification: go build/vet/test -race clean across all packages; the Docker image builds and passes its embedded go test step under both BuildKit and the classic builder; shellcheck 0 findings on the one script touched; rebased onto current main (the ERA5 lake stack landed on both main and dev during this work -- confirmed additive, no overlap with frontend/daemon).
2026-07-24 00:49:49 +00:00
// Package contentapi is the HTTP client for the backend's SSR content API
// (backend/api/content_routes.py) — the Go port of api_client.py, mirroring
// its behavior exactly:
//
// - 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.
// - Bounded LRU: City()/CityRecords() fold the browser-facing origin
// (client-controlled via Host/X-Forwarded-Host) into the cache key, so an
// unbounded map is a cheap memory-exhaustion vector — spoof enough
// distinct Host values and the cache grows forever.
// - Per-key single-flight: without it, N concurrent requests that miss the
// same cache key (cold start, or right after TTL expiry on a hot city)
// each fire their own backend call — a thundering herd. One lock per
// in-flight key makes every caller but the first block on, then reuse,
// that first caller's result instead of duplicating the request.
//
// Note on ETag / If-None-Match: this internal client never sends conditional
// requests — the Python client didn't either. The ETag half of the caching
// contract lives on the SERVING side (the W/"sha1…" ETags this service stamps
// on rendered HTML, see internal/render) and in the browser (static/cache.js's
// If-None-Match refetch of /api/v2/cell). Between this process and the backend
// the freshness mechanism is purely the TTL cache below.
package contentapi
import (
"container/list"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// DefaultCacheMaxEntries mirrors api_client.py's _CACHE_MAX_ENTRIES.
const DefaultCacheMaxEntries = 2048
// StatusError is a non-2xx backend response — the analogue of
// httpx.HTTPStatusError. Callers map StatusCode straight through (the
// backend's 404 unknown-city and 503 warming become the page's own status);
// any other error from a Client method never got a response at all (a backend
// blip or restart) and maps to a 503, same as content.py's _raise_api_error.
type StatusError struct {
StatusCode int
Body string // response body text, forwarded as the error detail
URL string
}
func (e *StatusError) Error() string {
return fmt.Sprintf("backend returned %d for %s", e.StatusCode, e.URL)
}
// Options configures New. BaseURL is required; zero values elsewhere take the
// documented defaults.
type Options struct {
// BaseURL is THERMOGRAPH_API_BASE_INTERNAL (e.g. http://backend:8137).
BaseURL string
// BasePrefix is the backend's own THERMOGRAPH_BASE, normalized like
// config.Config.Base ("" or "/x"). Both processes are always configured
// with the same value in every real deployment, so reusing frontend's
// keeps this client correct under a sub-path deployment instead of only
// working when BASE is empty/root.
BasePrefix string
// APIVersion is the pinned content-API version (default "v2"). See the
// API-version pinning contract in frontend CLAUDE.md before changing.
APIVersion string
// TTL is the response-cache lifetime (THERMOGRAPH_SSR_CACHE_TTL);
// default 600s.
TTL time.Duration
// MaxCacheEntries caps the LRU (default DefaultCacheMaxEntries).
MaxCacheEntries int
// HTTPClient overrides the default pooled client (tests). The default
// mirrors api_client.py's httpx.Client: 10s total timeout, up to 100
// connections with 20 kept alive.
HTTPClient *http.Client
}
// Client is safe for concurrent use.
type Client struct {
baseURL string
basePrefix string
apiVersion string
ttl time.Duration
maxEntries int
http *http.Client
mu sync.Mutex
cache map[string]*list.Element
order *list.List // front = most recently used
inflightMu sync.Mutex
inflight map[string]*sync.Mutex
}
type cacheEntry struct {
key string
ts time.Time
data any
}
// New builds a Client. It does not touch the network.
func New(o Options) *Client {
if o.APIVersion == "" {
o.APIVersion = "v2"
}
if o.TTL == 0 {
o.TTL = 600 * time.Second
}
if o.MaxCacheEntries == 0 {
o.MaxCacheEntries = DefaultCacheMaxEntries
}
hc := o.HTTPClient
if hc == nil {
hc = &http.Client{
Timeout: 10 * time.Second, // httpx.Client(timeout=10.0)
Transport: &http.Transport{
MaxConnsPerHost: 100, // httpx.Limits(max_connections=100, …)
MaxIdleConnsPerHost: 20, // …max_keepalive_connections=20
},
}
}
return &Client{
baseURL: strings.TrimRight(o.BaseURL, "/"),
basePrefix: o.BasePrefix,
apiVersion: o.APIVersion,
ttl: o.TTL,
maxEntries: o.MaxCacheEntries,
http: hc,
cache: make(map[string]*list.Element),
order: list.New(),
inflight: make(map[string]*sync.Mutex),
}
}
func (c *Client) cacheGet(key string) any {
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.cache[key]
if !ok {
return nil
}
ent := el.Value.(*cacheEntry)
if time.Since(ent.ts) >= c.ttl {
c.order.Remove(el)
delete(c.cache, key)
return nil
}
c.order.MoveToFront(el)
return ent.data
}
func (c *Client) cachePut(key string, data any) {
c.mu.Lock()
defer c.mu.Unlock()
if el, ok := c.cache[key]; ok {
ent := el.Value.(*cacheEntry)
ent.ts, ent.data = time.Now(), data
c.order.MoveToFront(el)
} else {
c.cache[key] = c.order.PushFront(&cacheEntry{key: key, ts: time.Now(), data: data})
}
for len(c.cache) > c.maxEntries {
oldest := c.order.Back() // evict least-recently-used
c.order.Remove(oldest)
delete(c.cache, oldest.Value.(*cacheEntry).key)
}
}
// get GETs {BaseURL}{BasePrefix}{path}, cached for TTL. decode turns the
// response body into the typed payload that gets cached and returned.
//
// 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. Empty
// string means "no origin to forward".
func (c *Client) get(path, origin string, decode func([]byte) (any, error)) (any, error) {
cacheKey := path
if origin != "" {
cacheKey = origin + "|" + path
}
if hit := c.cacheGet(cacheKey); hit != nil {
return hit, nil
}
c.inflightMu.Lock()
lock, ok := c.inflight[cacheKey]
if !ok {
lock = &sync.Mutex{}
c.inflight[cacheKey] = lock
}
c.inflightMu.Unlock()
lock.Lock()
defer lock.Unlock()
// Someone else may have filled it while we waited on the flight lock.
if hit := c.cacheGet(cacheKey); hit != nil {
return hit, nil
}
// Mirror the Python's finally: the in-flight entry goes away whether the
// fetch succeeded or not — errors are never cached.
defer func() {
c.inflightMu.Lock()
delete(c.inflight, cacheKey)
c.inflightMu.Unlock()
}()
body, err := c.fetch(path, origin)
if err != nil {
return nil, err
}
data, err := decode(body)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
c.cachePut(cacheKey, data)
return data, nil
}
func (c *Client) fetch(path, origin string) ([]byte, error) {
url := c.baseURL + c.basePrefix + path
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if origin != "" {
// origin.partition("://"): everything after the separator is the host
// (empty if the separator is missing, same as the Python).
proto, host, _ := strings.Cut(origin, "://")
req.Host = host
req.Header.Set("X-Forwarded-Proto", proto)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, err // transport-level: callers map to 503
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return nil, &StatusError{StatusCode: resp.StatusCode, Body: string(body), URL: url}
}
return body, nil
}
// decodeInto adapts a typed unmarshal to get's decode callback.
func decodeInto[T any](body []byte) (any, error) {
v := new(T)
if err := json.Unmarshal(body, v); err != nil {
return nil, err
}
return v, nil
}
// Hub returns the climate-hub payload (/content/hub).
func (c *Client) Hub() (*HubPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/hub", c.apiVersion), "", decodeInto[HubPayload])
if err != nil {
return nil, err
}
return v.(*HubPayload), nil
}
// Sitemap returns every sitemap entry (/content/sitemap).
func (c *Client) Sitemap() ([]SitemapEntry, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/sitemap", c.apiVersion), "", decodeInto[[]SitemapEntry])
if err != nil {
return nil, err
}
return *v.(*[]SitemapEntry), nil
}
// IndexNowKey returns the IndexNow verification key (/content/indexnow-key).
func (c *Client) IndexNowKey() (string, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/indexnow-key", c.apiVersion), "", decodeInto[indexNowPayload])
if err != nil {
return "", err
}
return v.(*indexNowPayload).Key, nil
}
// Home returns the homepage payload (/content/home).
func (c *Client) Home() (*HomePayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/home", c.apiVersion), "", decodeInto[HomePayload])
if err != nil {
return nil, err
}
return v.(*HomePayload), nil
}
// City returns one city's climate-page payload. A *StatusError with
// StatusCode 404 (unknown city) or 503 (warming) maps straight through to the
// page response, same status codes the old in-process code raised. origin ""
// omits the Host/X-Forwarded-Proto forwarding.
func (c *Client) City(slug, origin string) (*CityPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s", c.apiVersion, slug), origin, decodeInto[CityPayload])
if err != nil {
return nil, err
}
return v.(*CityPayload), nil
}
// CityMonth returns one city-month payload.
func (c *Client) CityMonth(slug, month string) (*MonthPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s/month/%s", c.apiVersion, slug, month), "", decodeInto[MonthPayload])
if err != nil {
return nil, err
}
return v.(*MonthPayload), nil
}
// CityRecords returns one city's records-page payload.
func (c *Client) CityRecords(slug, origin string) (*RecordsPayload, error) {
v, err := c.get(fmt.Sprintf("/api/%s/content/city/%s/records", c.apiVersion, slug), origin, decodeInto[RecordsPayload])
if err != nil {
return nil, err
}
return v.(*RecordsPayload), nil
}