package httpapi import ( "context" "crypto/rand" "encoding/hex" "log/slog" "net/http" "time" ) type ctxKey int const requestIDKey ctxKey = 0 // requestIDMW assigns a request id (for correlation in logs) and echoes it. func requestIDMW(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := r.Header.Get("X-Request-Id") if id == "" { id = newRequestID() } w.Header().Set("X-Request-Id", id) ctx := context.WithValue(r.Context(), requestIDKey, id) next.ServeHTTP(w, r.WithContext(ctx)) }) } // loggingMW logs one line per request with method, path, status and duration. // It records no addresses (PII) — only routing metadata. func loggingMW(log *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, status: http.StatusOK} next.ServeHTTP(rec, r) log.Info("http", "method", r.Method, "path", r.URL.Path, "status", rec.status, "duration_ms", time.Since(start).Milliseconds(), "request_id", requestIDFrom(r.Context()), ) }) } } // timeoutMW bounds the whole request, so a slow portal cannot tie up a // goroutine indefinitely. func timeoutMW(d time.Duration) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), d) defer cancel() next.ServeHTTP(w, r.WithContext(ctx)) }) } } // recoverMW turns a panic into a 500 instead of crashing the process. func recoverMW(log *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() { if rec := recover(); rec != nil { log.Error("panic", "value", rec, "request_id", requestIDFrom(r.Context())) writeError(w, http.StatusInternalServerError, "internal error") } }() next.ServeHTTP(w, r) }) } } func requestIDFrom(ctx context.Context) string { if v, ok := ctx.Value(requestIDKey).(string); ok { return v } return "" } type statusRecorder struct { http.ResponseWriter status int } func (s *statusRecorder) WriteHeader(code int) { s.status = code s.ResponseWriter.WriteHeader(code) } // newRequestID returns a short random hex id. crypto/rand never fails in // practice; on the off chance it does we fall back to a constant so the // request still completes. func newRequestID() string { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "unknown" } return hex.EncodeToString(b[:]) }