api calls

This commit is contained in:
2026-09-06 19:14:59 +02:00
parent 13627a369a
commit 2dc9a30644
9 changed files with 1096 additions and 31 deletions
+154
View File
@@ -0,0 +1,154 @@
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"})
})
}