// Package httpapi is the HTTP layer: routing, middleware, and handlers. // // In Phase 0 it serves only health and version. Feature modules (auth, library, // ebooks, comics, ...) mount their own routes onto the same mux via // Server.Handle, so this package never imports a feature module. package httpapi import ( "context" "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "log/slog" "net/http" "time" "github.com/mithral/vellum/internal/auth" ) // Server holds the dependencies every handler needs. type Server struct { db *sql.DB log *slog.Logger version string started time.Time mux *http.ServeMux auth *auth.Authenticator } // New builds a Server and registers the core routes. func New(db *sql.DB, log *slog.Logger, version string) *Server { s := &Server{ db: db, log: log, version: version, started: time.Now(), mux: http.NewServeMux(), } s.routes() s.mountWeb() return s } // Mux exposes the root mux so feature modules can mount their own routes. func (s *Server) Mux() *http.ServeMux { return s.mux } // SetAuth installs the session middleware. Until it is set, no request carries // a current user. func (s *Server) SetAuth(a *auth.Authenticator) { s.auth = a } // Handle lets a module mount routes on the shared mux. func (s *Server) Handle(pattern string, h http.Handler) { s.mux.Handle(pattern, h) } func (s *Server) routes() { s.mux.HandleFunc("GET /healthz", s.handleHealth) s.mux.HandleFunc("GET /api/v1/version", s.handleVersion) } // Handler returns the fully wrapped root handler. // // Order matters: the request ID must be attached before the logging // middleware runs, so the log line can include it. Session loading sits // innermost so it runs after logging has started timing. func (s *Server) Handler() http.Handler { var h http.Handler = s.mux if s.auth != nil { h = s.auth.Middleware(h) } return s.withRecovery(s.withRequestID(s.withRequestLogging(h))) } // ── Middleware ───────────────────────────────────────────────────────── type ctxKey string const ctxRequestID ctxKey = "request_id" func withValue(ctx context.Context, k ctxKey, v string) context.Context { return context.WithValue(ctx, k, v) } func valueString(ctx context.Context, k ctxKey) string { if v, ok := ctx.Value(k).(string); ok { return v } return "" } func (s *Server) 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 == "" { var b [8]byte _, _ = rand.Read(b[:]) id = hex.EncodeToString(b[:]) } w.Header().Set("X-Request-ID", id) next.ServeHTTP(w, r.WithContext(withValue(r.Context(), ctxRequestID, id))) }) } func (s *Server) withRequestLogging(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) s.log.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "bytes", rec.bytes, "duration_ms", time.Since(start).Milliseconds(), "request_id", valueString(r.Context(), ctxRequestID), ) }) } func (s *Server) withRecovery(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { s.log.Error("panic", "path", r.URL.Path, "recover", rec, "request_id", valueString(r.Context(), ctxRequestID), ) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) } }() next.ServeHTTP(w, r) }) } // ── Helpers ──────────────────────────────────────────────────────────── type statusRecorder struct { http.ResponseWriter status int bytes int } func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } func (r *statusRecorder) Write(b []byte) (int, error) { n, err := r.ResponseWriter.Write(b) r.bytes += n return n, err } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{ "error": "not found", "path": r.URL.Path, }) }