package httpapi import ( "context" "errors" "log/slog" "net/http" "textmachine/platform/internal/auth" ) // Prober is what readiness needs from the database. type Prober interface { Ping(ctx context.Context) error } // Deps is everything the HTTP surface is built from. type Deps struct { Log *slog.Logger // DB is nil when no database is configured: the service still starts and still answers // liveness, and readiness reports why it is not ready. DB Prober Auth *auth.Authenticator // TrustedOrigins are origins besides our own allowed to make unsafe requests. TrustedOrigins []string // APIPrefix is the contract's base path ("/v0"). Ops endpoints live outside it: a health check // is not part of the versioned surface and must not move when the surface does. APIPrefix string } // New builds the handler. // // Contract routes are registered on the SAME mux as the ops ones, with the version prefix written // into the pattern, and each is wrapped in guard(). One mux is what keeps Request.Pattern // meaningful all the way out to the access log — a nested mux behind http.StripPrefix hands the // inner handler a copy, and the pattern the copy learns never comes back. func New(d Deps) (http.Handler, error) { if d.APIPrefix == "" { d.APIPrefix = "/v0" } if d.Auth == nil { return nil, errors.New("httpapi: no authenticator: the API subtree may not be served unguarded") } csrf, err := auth.CSRF(d.TrustedOrigins, ProblemHandler(http.StatusForbidden, "Cross-origin request rejected")) if err != nil { return nil, err } // Every API route goes through this. An anonymous caller therefore gets 401 before 404, which // is deliberate: the shape of the surface is not public information. guard := func(h http.Handler) http.Handler { return csrf(d.Auth.Require(h)) } mux := http.NewServeMux() mux.Handle("GET /healthz", http.HandlerFunc(healthz)) mux.Handle("GET /readyz", readyz(d.DB)) // The contract's routes land here (P-1), as mux.Handle("GET "+d.APIPrefix+"/books", guard(…)). // Until then everything under the prefix is a guarded 404 in the shape the contract mandates. mux.Handle(d.APIPrefix+"/", guard(ProblemHandler(http.StatusNotFound, "Object not found"))) // Recover sits INSIDE AccessLog: a panic converted to a 500 still produces a log line, whereas // a panic unwinding past the logger produces none. return RequestID(SecurityHeaders(AccessLog(d.Log)(Recover(d.Log)(mux)))), nil } // healthz is liveness: the process is up and serving. It touches nothing, so a database outage // cannot make a supervisor kill a healthy process. func healthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok\n")) } // readyz is readiness: this instance can serve traffic, which means the database answers. func readyz(db Prober) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if db == nil { WriteProblem(w, http.StatusServiceUnavailable, "Not ready", "no database configured") return } if err := db.Ping(r.Context()); err != nil { // The reason stays in the log; the body says only that we are not ready. WriteProblem(w, http.StatusServiceUnavailable, "Not ready", "database unreachable") return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ready\n")) }) }