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.
152 lines
4.1 KiB
Go
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,
|
|
})
|
|
}
|