// Package gateway holds the reverse-proxy glue that fronts all four // backing services on a single bind. The /v1 prefix lives at the // edge — internal services route on `/storage`, `/catalog`, `/ingest`, // `/sql`. Per D1-plan Kimi K2 finding: httputil.NewSingleHostReverseProxy // preserves the inbound path by default, so the Director must strip // `/v1` before forwarding or the backend gets a 404. package gateway import ( "net/http" "net/http/httputil" "net/url" "strings" ) // V1Prefix is the public API version namespace. Stripped before // requests are forwarded to backing services. const V1Prefix = "/v1" // NewProxyHandler builds an http.Handler that reverse-proxies every // request to upstream, with the V1Prefix stripped from req.URL.Path // (and RawPath, if present). Query string is preserved. Host header // is rewritten so the backing service's chi router sees its expected // host string, not the gateway's. // // The returned handler is intentionally minimal — connection pooling // (via http.DefaultTransport), error responses (502 on upstream // unreachable), and request logging (via shared.Run middleware) are // all inherited from sane defaults. func NewProxyHandler(upstream *url.URL) http.Handler { p := httputil.NewSingleHostReverseProxy(upstream) origDirector := p.Director p.Director = func(req *http.Request) { // Per scrum O-BLOCK (Opus): strip /v1 BEFORE origDirector // runs. The default Director joins target.Path + req.URL.Path // via singleJoiningSlash, so an upstream like // "http://host/api" produces "/api/v1/storage/..." after the // join — then TrimPrefix("/v1") is a no-op because the string // starts with "/api". Stripping first means the join sees the // already-clean path and produces "/api/storage/...". req.URL.Path = strings.TrimPrefix(req.URL.Path, V1Prefix) if req.URL.RawPath != "" { req.URL.RawPath = strings.TrimPrefix(req.URL.RawPath, V1Prefix) } origDirector(req) req.Host = upstream.Host } return p }