package api import ( "log/slog" "net/http" "strings" ) // Router builds the HTTP surface. Kept out of main.go so it can be exercised in // tests with a fake KeyStore, which is where the auth wiring actually gets // verified. type Router struct { Auth *Authenticator Logger *slog.Logger Version string // Ready reports whether dependencies are healthy. Nil means always ready. Ready func() error } func (rt *Router) Handler() http.Handler { mux := http.NewServeMux() // Unauthenticated. Liveness must not touch the database: a health check // that fails when Postgres blips will get the process killed by whatever // supervises it, which turns a database wobble into an outage. mux.Handle("GET /healthz", rt.healthz()) // Readiness DOES check dependencies. That is the difference. mux.Handle("GET /readyz", rt.readyz()) // Everything below needs a key. authed := func(scope string, h http.Handler) http.Handler { return rt.Auth.Authenticate(RequireScope(scope, h)) } mux.Handle("GET /v1/whoami", rt.Auth.Authenticate(Whoami())) mux.Handle("GET /v1/reference/ping", authed(ScopeReference, rt.stub("reference"))) // Deliberately NO catch-all mux.Handle("/", ...) here. // // A "/" pattern matches every path, so it would also swallow method // mismatches: POST /healthz would match "/" and return 404 instead of the // 405 that Go's ServeMux produces on its own. jsonifyErrors below rewrites // ServeMux's plain-text 404 and 405 bodies into the standard envelope, // which keeps both correct status codes and uniform JSON errors. return Chain(mux, WithRequestID, WithRecovery(rt.Logger), WithLogging(rt.Logger), jsonifyErrors, ) } func (rt *Router) healthz() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { WriteJSON(w, http.StatusOK, map[string]string{ "status": "ok", "version": rt.Version, }) }) } func (rt *Router) readyz() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if rt.Ready != nil { if err := rt.Ready(); err != nil { rt.Logger.Warn("readiness check failed", "err", err) WriteError(w, http.StatusServiceUnavailable, "not_ready", "Dependencies are unavailable.") return } } WriteJSON(w, http.StatusOK, map[string]string{"status": "ready"}) }) } // jsonifyErrors converts net/http's built-in plain-text 404 and 405 responses // into the same JSON envelope every other error uses, so a client never has to // branch on Content-Type to read an error. func jsonifyErrors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rw := &errorRewriter{ResponseWriter: w} next.ServeHTTP(rw, r) rw.finish() }) } type errorRewriter struct { http.ResponseWriter intercepted bool wroteHeader bool status int } func (e *errorRewriter) WriteHeader(code int) { if e.wroteHeader { return } e.wroteHeader = true e.status = code // Only touch responses net/http generated itself. // // Checking for an EMPTY Content-Type does not work: http.NotFound sets // "text/plain; charset=utf-8" before calling WriteHeader, so by the time we // see it the header is already populated. Test for "not already JSON" // instead - every handler in this package writes JSON, so anything else at // a 404 or 405 came from net/http. if code == http.StatusNotFound || code == http.StatusMethodNotAllowed { if !strings.HasPrefix(e.Header().Get("Content-Type"), "application/json") { e.intercepted = true e.Header().Set("Content-Type", "application/json; charset=utf-8") } } e.ResponseWriter.WriteHeader(code) } func (e *errorRewriter) Write(b []byte) (int, error) { if !e.wroteHeader { e.WriteHeader(http.StatusOK) } // Swallow the plain-text body; finish writes the JSON one. if e.intercepted { return len(b), nil } return e.ResponseWriter.Write(b) } func (e *errorRewriter) finish() { if !e.intercepted { return } code, msg := "not_found", "No such endpoint." if e.status == http.StatusMethodNotAllowed { code, msg = "method_not_allowed", "That method is not allowed on this endpoint." } _ = writeErrorBody(e.ResponseWriter, code, msg) } func (rt *Router) stub(name string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { WriteJSON(w, http.StatusOK, map[string]string{"endpoint": name, "status": "not implemented yet"}) }) }