textmachine/platform/internal/httpapi/server.go

88 lines
3.4 KiB
Go

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"))
})
}