package api import ( "log/slog" "net/http" "runtime/debug" "time" ) // middleware wraps an http.Handler. Keep middleware transport-level; // business concerns belong in services. type middleware func(http.Handler) http.Handler // chain composes middlewares so chain(h, a, b) == a(b(h)): the first // argument runs outermost. func chain(h http.Handler, mws ...middleware) http.Handler { for i := len(mws) - 1; i >= 0; i-- { h = mws[i](h) } return h } // logging records one line per request after it completes. func logging(logger *slog.Logger) middleware { 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) logger.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "duration_ms", time.Since(start).Milliseconds(), ) }) } } // recoverPanics turns panics into 500s instead of dropped connections. func recoverPanics(logger *slog.Logger) middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if v := recover(); v != nil { logger.Error("panic recovered", "panic", v, "stack", string(debug.Stack())) writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") } }() next.ServeHTTP(w, r) }) } } // cors lets the configured origins call the API from a browser (e.g. the // Vite dev server on :5173). A "*" entry allows every origin. func cors(allowed []string) middleware { set := make(map[string]struct{}, len(allowed)) allowAll := false for _, o := range allowed { if o == "*" { allowAll = true } set[o] = struct{}{} } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if _, ok := set[origin]; origin != "" && (allowAll || ok) { h := w.Header() h.Set("Access-Control-Allow-Origin", origin) h.Add("Vary", "Origin") h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS") h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") h.Set("Access-Control-Max-Age", "600") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } } next.ServeHTTP(w, r) }) } } // timeout bounds each request's handler time. The body is the raw timeout // response — http.TimeoutHandler does not set a content type for it. func timeout(d time.Duration) middleware { return func(next http.Handler) http.Handler { return http.TimeoutHandler(next, d, `{"error":{"code":"timeout","message":"request timed out"}}`) } } // statusRecorder captures the response status for logging. type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } // Unwrap lets http.ResponseController reach the underlying writer. func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }