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
+149
View File
@@ -0,0 +1,149 @@
package api
import (
"context"
"crypto/rand"
"encoding/hex"
"log/slog"
"net/http"
"runtime/debug"
"time"
)
type requestIDKey struct{}
// RequestIDFrom returns the id assigned by WithRequestID, or "".
func RequestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(requestIDKey{}).(string)
return id
}
// WithRequestID tags each request so a log line can be tied to a client report.
// Honours an inbound X-Request-Id so a self-hosted bot can correlate its own
// logs with the backend's.
func WithRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" || len(id) > 64 {
var b [8]byte
if _, err := rand.Read(b[:]); err == nil {
id = hex.EncodeToString(b[:])
}
}
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id)))
})
}
// statusRecorder captures the status code, which http.ResponseWriter does not
// expose after the fact.
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if s.status == 0 {
s.status = http.StatusOK
}
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
// WithLogging records one line per request.
//
// Deliberately does NOT log the Authorization header, the query string or the
// body. Logs get shipped, grepped and retained far longer than anyone intends,
// and the query string is where callsigns and grid squares live.
func WithLogging(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
if rec.status == 0 {
rec.status = http.StatusOK
}
attrs := []any{
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", RequestIDFrom(r.Context()),
}
// Key id rather than the key, and only once authentication has
// resolved one.
if p := PrincipalFrom(r.Context()); p != nil {
attrs = append(attrs, "key_id", p.KeyID, "owner_id", p.OwnerID)
}
level := slog.LevelInfo
if rec.status >= 500 {
level = slog.LevelError
}
logger.Log(r.Context(), level, "request", attrs...)
})
}
}
// WithRecovery turns a panic in a handler into a 500 rather than a dead
// process. Go's default is to kill the whole server on an unrecovered panic in
// a handler goroutine.
func WithRecovery(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
// A closed connection surfaces as a panic with this value and
// is not a bug; re-panic so net/http handles it as it expects.
if rec == http.ErrAbortHandler {
panic(rec)
}
logger.Error("panic in handler",
"err", rec,
"path", r.URL.Path,
"request_id", RequestIDFrom(r.Context()),
"stack", string(debug.Stack()))
WriteError(w, http.StatusInternalServerError, CodeInternal,
"Something went wrong. The incident has been logged.")
}()
next.ServeHTTP(w, r)
})
}
}
// Chain applies middleware so that the first argument is the outermost, which
// reads in the order requests actually traverse them.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}