Vellum/internal/httpapi/server.go
mithral eb1ad6d920 Phase 0: skeleton, config, migrations, health check, container
Bootstraps Vellum as a single Go binary with one SQLite database.

- core: env-driven config, slog logging, SQLite (WAL) with pooled pragmas,
  embedded migration runner keyed on PRAGMA user_version
- migrations/0001_core.sql: identity, media, user state, infra. Media items
  are global; all per-user state (progress, favorites, grants) is a join.
  One polymorphic progress table covers every media kind.
- httpapi: request-ID, structured request logging, panic recovery, JSON
  endpoints for /healthz and /api/v1/version
- multi-arch Dockerfile (pure-Go SQLite, CGO_ENABLED=0) with an entrypoint
  that honours PUID/PGID like the rest of the self-hosted stack
- tests for migration idempotency, FK enforcement, WAL, config, middleware

Media does not exist yet. Nothing is exposed beyond health and version.
2026-10-09 16:26:08 -04:00

152 lines
4.1 KiB
Go

// 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"
)
// Server holds the dependencies every handler needs.
type Server struct {
db *sql.DB
log *slog.Logger
version string
started time.Time
mux *http.ServeMux
}
// 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()
return s
}
// 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)
s.mux.HandleFunc("/", s.handleNotFound)
}
// 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.
func (s *Server) Handler() http.Handler {
return s.withRecovery(s.withRequestID(s.withRequestLogging(s.mux)))
}
// ── 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,
})
}