thermograph/frontend/server/internal/config/config.go

128 lines
4.9 KiB
Go
Raw Normal View History

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
// Package config reads every environment variable the SSR frontend consumes.
//
// The names, defaults and required/optional split mirror the Python service
// exactly (app.py, api_client.py, content.py, paths.py, and the Dockerfile /
// infra/docker-compose.yml PORT wiring). Do not invent new variable names —
// the deploy path (compose + /etc/thermograph.env) sets these and only these.
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
)
// Config is the fully-resolved runtime configuration.
type Config struct {
// APIBaseInternal is THERMOGRAPH_API_BASE_INTERNAL — the backend's URL on
// the compose-internal network (e.g. http://backend:8137). REQUIRED: the
// Python client raised RuntimeError at import when unset ("a missing
// backend URL should break the boot, not silently 500 on the first
// request") — Load returns an error and main exits, same philosophy.
APIBaseInternal string
// APIVersion is THERMOGRAPH_API_VERSION (default "v2") — the single pin
// point for the backend content-API version this service speaks. Bump only
// in lockstep with a verified backend /api/version check (see
// frontend CLAUDE.md, "API-version pinning contract").
APIVersion string
// Base is THERMOGRAPH_BASE normalized the way content.py normalized it:
// strip "/" from both ends, then "/"+rest, or "" when the variable is set
// to "/" (the deployed clean-root topology — the Dockerfile sets "/").
// Default when unset: "/thermograph" (LAN dev under a sub-path).
Base string
// AssetBase is THERMOGRAPH_API_BASE_PUBLIC with any trailing "/" removed,
// falling back to Base when empty — the browser-facing base for static
// asset / SPA-shell URLs in templates. Empty var = today's same-origin
// default, where those references stay relative (ASSET_BASE == BASE).
AssetBase string
// SSRCacheTTL is THERMOGRAPH_SSR_CACHE_TTL in seconds (default 600) — the
// content-API response-cache TTL in the backend client. The Python parsed
// it with float(env or 600): an empty string means the default, a present
// but unparsable value crashed the boot — Load mirrors both.
SSRCacheTTL time.Duration
// GoogleVerify / BingVerify are THERMOGRAPH_GOOGLE_VERIFY /
// THERMOGRAPH_BING_VERIFY, whitespace-trimmed; empty = no verification
// <meta> tag emitted.
GoogleVerify string
BingVerify string
// Port is PORT (default "8080"). The Python process itself never read it —
// the Dockerfile's `uvicorn --port ${PORT}` did — but it is the one knob
// infra uses to move the listen port, so the Go binary reads the same name.
Port string
// StaticDir / ContentDir are where static assets and the structured SSR
// copy (glossary.yaml / pages.yaml) live. The Python resolved these from
// its own source location (paths.py); a compiled binary has no source
// directory, so these default to "static" and "content" relative to the
// working directory — run from frontend/ locally, /app in the image (the
// Dockerfile must COPY static/ and content/ there and keep WORKDIR /app).
StaticDir string
ContentDir string
}
// Load resolves the configuration from the process environment.
func Load() (Config, error) {
return load(os.LookupEnv)
}
// load is Load with an injectable environment, for tests.
func load(getenv func(string) (string, bool)) (Config, error) {
get := func(name, dflt string) string {
if v, ok := getenv(name); ok {
return v
}
return dflt
}
var cfg Config
cfg.APIBaseInternal, _ = getenv("THERMOGRAPH_API_BASE_INTERNAL")
if cfg.APIBaseInternal == "" {
return cfg, fmt.Errorf("THERMOGRAPH_API_BASE_INTERNAL must be set (e.g. http://backend:8137)")
}
cfg.APIVersion = get("THERMOGRAPH_API_VERSION", "v2")
// content.py / api_client.py: os.environ.get("THERMOGRAPH_BASE",
// "/thermograph").strip("/"), then "/"+base if base else "".
base := strings.Trim(get("THERMOGRAPH_BASE", "/thermograph"), "/")
if base != "" {
cfg.Base = "/" + base
}
// content.py: os.environ.get("THERMOGRAPH_API_BASE_PUBLIC", "").rstrip("/") or BASE.
cfg.AssetBase = strings.TrimRight(get("THERMOGRAPH_API_BASE_PUBLIC", ""), "/")
if cfg.AssetBase == "" {
cfg.AssetBase = cfg.Base
}
// api_client.py: float(os.environ.get("THERMOGRAPH_SSR_CACHE_TTL", "600") or 600).
ttlRaw := get("THERMOGRAPH_SSR_CACHE_TTL", "600")
if ttlRaw == "" {
ttlRaw = "600"
}
ttlSecs, err := strconv.ParseFloat(ttlRaw, 64)
if err != nil {
// The Python raised ValueError at import for a garbage value — fail
// loud here too rather than silently running with a wrong TTL.
return cfg, fmt.Errorf("THERMOGRAPH_SSR_CACHE_TTL: %w", err)
}
cfg.SSRCacheTTL = time.Duration(ttlSecs * float64(time.Second))
cfg.GoogleVerify = strings.TrimSpace(get("THERMOGRAPH_GOOGLE_VERIFY", ""))
cfg.BingVerify = strings.TrimSpace(get("THERMOGRAPH_BING_VERIFY", ""))
cfg.Port = get("PORT", "8080")
cfg.StaticDir = "static"
cfg.ContentDir = "content"
return cfg, nil
}