thermograph/frontend/server/internal/contentapi/client.go
emi 92e74c585a
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
frontend: rewrite the SSR content service in Go (#28)
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No
climate math, no DB, no auth -- every route fetches from the backend's
/content/* API.

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
and every route compared byte-for-byte, confirmed programmatically. That
process caught defects unit tests alone missed, since map[string]any has
no compile-time field check:

- Render-context keys were snake_case throughout while the templates read
  PascalCase fields. A missing map key doesn't error, it silently renders
  empty -- title, meta description, canonical URL, OpenGraph tags, and the
  homepage's entire ranked list were blank on every page despite every
  route returning 200. Fixed by renaming every key to match each
  template's own documented field contract, and passing API structs
  straight through wherever their fields already matched (removes a whole
  layer of future drift risk).
- Three pages 500'd: ToolHref needed a composed href, not a bare
  "lat,lon" fragment; the records table needed the raw API struct.
- JSON-LD was double-encoded: <script type="application/ld+json"> is
  JAVASCRIPT context to html/template's escaper regardless of the
  script's type attribute, so template.HTML gets re-escaped as a quoted
  JS string. Needed template.JS. The glossary term page's JSON-LD was
  never built at all -- added.
- html/template silently strips literal HTML and JS comments from parsed
  output (verified in isolation) -- both need a FuncMap function
  returning template.HTML/template.JS to survive.

Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the
Swarm stack's entrypoint override with no explicit command drops the
image's CMD entirely (every deploy would have exited 127), and
COPY --chown by name fails under the classic Docker builder on Alpine.
Both fixed.

go build/vet/test -race clean; docker build passes its embedded test step
under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00

329 lines
11 KiB
Go

// 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
}