Documentation
¶
Overview ¶
Package rest is the stdlib net/http stack that replaced the gRPC server + grpc-gateway pair (PRD #112, Phase 3); the #134 cutover removed those and made this the single public production listener. The single public listener serves every route through New/routes.
Most of this package is HTTP plumbing the gateway used to provide for free: gzip (gzip.go), query binding (query.go), the error-code→HTTP-status mapping (write.go), clean-path redirects (redirect.go), client-IP resolution (clientip.go), Cache-Control grouping (cachecontrol.go) and Sentry (sentry.go, sentry_boot.go). The request-handling business logic is the per-resource files — milpacs.go, rosters.go, positions.go, awol.go, tickets.go — and that is where new work usually lands.
Middleware chain (PRD order — assembled in chain, New's composition) ¶
sentry → metrics → auth (→ sentryLabel) → gzip → clean-path 307 → mux
Extension points, outermost first:
- sentryMiddleware (#132, sentry.go): panic recovery at the front of the chain — catches the metrics layer's re-panic, reports the event, and writes the contract 500; 5xx Sentry reports hook the writeError choke point. Env-gated by SENTRY_DSN (rest.SetupSentry): without a client it is a complete no-op. Its route/key-id tags reach this OUTER layer via the sentryLabels context holder, filled by the sentryLabel wrapper inside auth (the same mechanism as metricLabels below).
- metricsMiddleware (#130, metrics.go): Prometheus request counter (route/method/status/key_id) and latency histogram (route/method). Outside auth, so rejected requests are counted. The route and key_id labels reach this OUTER layer via the context label-holder (metricLabels): AuthMiddleware fills the key-id slot, the routeLabel wrapper inside the mux fills the route slot from r.Pattern. The exposition is never served through this chain; MetricsHandler is served on its own INTERNAL-ONLY listener (:9090, servers/server.go).
- AuthMiddleware: bearer-key validation with the golden-pinned two-tier plain-text 401s. Runs BEFORE routing, so an unknown path without credentials is a 401, not a 404 (golden-pinned). Scope checks are per-route, not here — see requireScope.
- GzipMiddleware: response compression. Inside auth (401s are never gzipped), outside the mux (every routed response, including the JSON 404, compresses).
- cleanPathRedirect (redirect.go): the mux's clean-path 307 answered in front of the mux with the contract JSON body and the bounded catch-all metering label (ruled, #128 round 3 — enumerated deliberate break). Inside gzip, so the redirect body compresses like every routed response; clean paths pass through untouched.
- mux: the Go 1.22+ pattern-routing http.ServeMux.
Adding a route (the fan-out recipe, #126–#129) ¶
- Wire types in types/ (follow the conventions in the package doc).
- Handler in this package: map the datastore result to the wire types (allocate empty collections!), writeJSON on success, writeError with the frozen message string on failure. Query parameters go through newQueryBinder: bind EVERY field first, then check b.Err() exactly once and 400 its text verbatim — a forgotten Err() check silently drops a frozen 400. Routes whose old generated handler called req.ParseForm() parse strictly via bindListQuery instead of r.URL.Query().
- Register in routes(): handle(mux, "GET /api/v1/...", "<scope>", <max-age>, handler) — the scope gate and the route group's Cache-Control max-age (#131, cachecontrol.go) are required arguments, not wrapping conventions. Path parameters via r.PathValue. Wrong-method and unknown paths are already covered by the mux fallback (405+Allow / JSON 404).
- Spec operation block in openapi/openapi.yaml (CI-enforced two-way coverage, contract/spec_test.go) — its 200 response must declare the Cache-Control const matching the registered max-age (structural guard in contract/spec_test.go, observed-equals-declared in rest/spec_test.go).
- Goldens green: add the route's battery case names to implementedCases in rest_test.go — the replay harness does the rest.
- Classify every new case's request path in specRoutes (rest/spec_test.go) so the new-stack spec validation covers the route's observed responses — an unclassified path fails that suite, it never skips.
Index ¶
- Variables
- func AuthMiddleware(ds datastores.Datastore, next http.Handler) http.Handler
- func ContextWithKey(ctx context.Context, key *datastores.ApiKeyResult) context.Context
- func DocsHandler(version string) http.Handler
- func FlushSentryOnShutdown()
- func GzipMiddleware(next http.Handler) http.Handler
- func InitTrustedProxies() error
- func KeyFromContext(ctx context.Context) *datastores.ApiKeyResult
- func MetricsHandler() http.Handler
- func New(ds datastores.Datastore, rc datastores.TicketReferenceCache) http.Handler
- func SetupSentry(release string) bool
Constants ¶
This section is empty.
Variables ¶
Functions ¶
func AuthMiddleware ¶
AuthMiddleware authenticates every request against the upstream key tables (ADR 0004) and attaches the validated key to the request context. It runs BEFORE routing — an unknown path without credentials is a 401, not a 404 (golden-pinned). Authorization (scope membership) is per-route: see requireScope.
The public middleware constructor the chain composition uses (rest.New).
func ContextWithKey ¶
func ContextWithKey(ctx context.Context, key *datastores.ApiKeyResult) context.Context
ContextWithKey returns a new context carrying the given API key result.
func DocsHandler ¶
DocsHandler serves the embedded Scalar docs UI and the OpenAPI 3.1 spec at the same URLs the old gateway used (everything outside /api). The spec has its info.version stamped from version; all other assets (the Scalar shell, its vendored bundle, and the theme) are served verbatim from the embedded filesystem.
version is the build-time var (servers.version via -ldflags, "dev" locally), threaded in by the composition root.
func FlushSentryOnShutdown ¶
func FlushSentryOnShutdown()
FlushSentryOnShutdown installs a signal handler that flushes buffered Sentry events before the process exits. The stack has no graceful shutdown path (Start blocks on Serve and the process dies by signal); this is the minimal hook so error events from the final moments are not lost. The composition root calls it only when SetupSentry returned true, so the no-DSN path keeps the default signal behavior exactly — guarded here as well as at the call site, because installing the handler without a client would silently rewrite the process's signal semantics for nothing.
func GzipMiddleware ¶
GzipMiddleware is the compression layer. It sits inside auth (401s are never gzipped) and outside the mux, so every routed response — including the JSON 404 — compresses when the client asks.
A handler-sent 1xx on a gzip-negotiated request carries Content-Encoding: gzip in the interim response — the stdlib sends the live header map per RFC 8297 — pre-existing, adjacent to the stale-Content-Length holes #175 closed.
func InitTrustedProxies ¶
func InitTrustedProxies() error
InitTrustedProxies reads/parses/caches TRUSTED_PROXIES once at startup. Returns an error on a non-empty-but-malformed value (the caller makes it fatal). Empty/unset trusts nothing.
func KeyFromContext ¶
func KeyFromContext(ctx context.Context) *datastores.ApiKeyResult
KeyFromContext returns the *ApiKeyResult attached to ctx, or nil if none.
func MetricsHandler ¶
MetricsHandler serves the Prometheus exposition for this process. It must be mounted on the INTERNAL-ONLY listener, never in the public chain — per-key traffic counts are operational data.
Deploy-config assertion (for the cutover slice #134, which mounts this on its own listener): the metrics port is internal-only, meaning
- docker-compose must NOT publish it (no entry under the api service's `ports:`), and
- the reverse proxy must NOT route it (no location/upstream forwarding to the metrics port).
Verify at cutover, from outside the host:
curl https://<public-host>/metrics → 404 (falls through to the docs
file server; the metrics handler
is not mounted on the public
listener)
curl http://<public-host>:<metrics-port>/ → connection refused/timeout
(port unpublished + unrouted)
and from inside the host network: curl localhost:<metrics-port>/metrics serves this exposition.
func New ¶
func New(ds datastores.Datastore, rc datastores.TicketReferenceCache) http.Handler
New assembles the new stack: the route mux wrapped in the PRD middleware chain (sentry → metrics → auth (→ sentryLabel) → gzip → clean-path 307 → mux; the one definition lives on chain below). The returned handler serves the /api surface; non-API paths (the docs UI) are served by rest.DocsHandler on the same listener (composed in servers.servPublic).
rc is the tickets reference cache (status/priority/prefix names, the category tree) the tickets datastore methods consume — at cutover (#134) the caller passes the refreshed referencecache.Cache the old stack already maintains (servers.Start wires it today). It must be WARMED (Refresh run and the poller keeping it fresh), not merely non-nil: a cold cache degrades silently — empty categories, blank status/priority/prefix names, and category filters collapsing from subtree to exact-match. New refuses nil outright: a nil cache is a guaranteed panic on the first tickets request against the real datastore (recovery exists only when SENTRY_DSN is set — and a panic-per-request service is broken either way).
func SetupSentry ¶
SetupSentry initialises Sentry error capture (errors only, no tracing) when SENTRY_DSN is present in the environment — the same gate as Phase 0's servers.setupSentry, which this replaces at cutover (#134). Without a DSN nothing is initialised — no client, no capture, local/dev unaffected; the only side effect is one Info line making the disabled state visible at boot. Returns whether capture was enabled.
release is the build-time version (the -X servers.version injection today; the cutover slice passes it through) — it stamps every event's Release tag.
Types ¶
This section is empty.