api calls
This commit is contained in:
@@ -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"})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user