- Go backend skeleton: config (caarlos0/env), slog JSON logging, pgxpool store, HTTP server with graceful shutdown. - httpapi: render helpers, Problem errors, middleware chain (requestID / logging / recover / CORS), /healthz and /readyz. - Migrations: full initial schema (users, sessions, lists, list_members, items, op_log SOURCE OF TRUTH, item_names) + golang-migrate runner binary using source/iofs (embedded). - Docker: multi-stage Dockerfile (Go 1.26 -> distroless nonroot), builds both server and migrate binaries. - deploy: docker-compose (caddy + backend + migrate + postgres:16), Caddyfile (auto-HTTPS), .env.example, pg extensions init script. - AGENTS.md: project context + roadmap for AI agents. Verified: image builds, both binaries run in container (smoke test).
125 lines
3.3 KiB
Go
125 lines
3.3 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"net/http"
|
|
"runtime/debug"
|
|
|
|
"github.com/mitbringsl/backend/internal/config"
|
|
)
|
|
|
|
// ctxKey is an unexported key type for context values in this package.
|
|
type ctxKey int
|
|
|
|
const (
|
|
ctxKeyRequestID ctxKey = iota
|
|
ctxKeyUserID
|
|
ctxKeySessionID
|
|
)
|
|
|
|
type wrappedWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
}
|
|
|
|
func (w *wrappedWriter) WriteHeader(status int) {
|
|
w.status = status
|
|
w.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (w *wrappedWriter) Write(b []byte) (int, error) {
|
|
if w.status == 0 {
|
|
w.status = http.StatusOK
|
|
}
|
|
n, err := w.ResponseWriter.Write(b)
|
|
w.bytes += n
|
|
return n, err
|
|
}
|
|
|
|
// requestIDMiddleware injects a random request id into the request context and response header.
|
|
func requestIDMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
id := r.Header.Get("X-Request-ID")
|
|
if id == "" {
|
|
b := make([]byte, 8)
|
|
_, _ = rand.Read(b)
|
|
id = hex.EncodeToString(b)
|
|
}
|
|
w.Header().Set("X-Request-ID", id)
|
|
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
|
|
next.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
}
|
|
|
|
// loggingMiddleware logs each request as structured JSON.
|
|
func loggingMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ww := &wrappedWriter{ResponseWriter: w}
|
|
next.ServeHTTP(ww, r)
|
|
slog.Info("http request",
|
|
"method", r.Method,
|
|
"path", r.URL.Path,
|
|
"status", ww.status,
|
|
"bytes", ww.bytes,
|
|
"request_id", r.Context().Value(ctxKeyRequestID),
|
|
"remote", r.RemoteAddr,
|
|
)
|
|
})
|
|
}
|
|
|
|
// recoverMiddleware catches panics, logs them and returns 500.
|
|
func recoverMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
slog.Error("panic recovered",
|
|
"error", rec,
|
|
"request_id", r.Context().Value(ctxKeyRequestID),
|
|
"stack", string(debug.Stack()),
|
|
)
|
|
renderError(w, http.StatusInternalServerError, "Internal error", "An unexpected error occurred.")
|
|
}
|
|
}()
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
// corsMiddleware adds permissive-but-scoped CORS headers when configured.
|
|
// Native clients (Android) don't need CORS; this only affects browsers.
|
|
func corsMiddleware(cfg *config.Config) func(http.Handler) http.Handler {
|
|
allowed := map[string]bool{}
|
|
for _, o := range cfg.CORSAllowedOrigins {
|
|
if o != "" {
|
|
allowed[o] = true
|
|
}
|
|
}
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
if origin != "" && allowed[origin] {
|
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
|
w.Header().Set("Vary", "Origin")
|
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
|
if r.Method == http.MethodOptions {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Chain wires middlewares in declaration order: first one runs outermost.
|
|
func Chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
|
|
for i := len(mws) - 1; i >= 0; i-- {
|
|
h = mws[i](h)
|
|
}
|
|
return h
|
|
}
|