Backend Phase A: foundation, migrations, Docker setup
- 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).
This commit is contained in:
commit
2899eb205b
23 changed files with 1372 additions and 0 deletions
56
backend/internal/httpapi/api.go
Normal file
56
backend/internal/httpapi/api.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/mitbringsl/backend/internal/config"
|
||||
)
|
||||
|
||||
// API bundles all handler groups and wires the router.
|
||||
// Handler groups are added in subsequent phases (auth, lists, items, suggestions).
|
||||
type API struct {
|
||||
cfg *config.Config
|
||||
pool *pgxpool.Pool
|
||||
|
||||
health *HealthHandler
|
||||
}
|
||||
|
||||
// NewAPI constructs the API with all handler groups.
|
||||
func NewAPI(cfg *config.Config, pool *pgxpool.Pool) *API {
|
||||
return &API{
|
||||
cfg: cfg,
|
||||
pool: pool,
|
||||
health: &HealthHandler{Pool: pool},
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the fully wired http.Handler with all middleware applied.
|
||||
func (a *API) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// --- public health endpoints (no auth) ---
|
||||
mux.HandleFunc("GET /healthz", a.health.Healthz)
|
||||
mux.HandleFunc("GET /readyz", a.health.Readyz)
|
||||
|
||||
// --- auth endpoints (added in Phase B) ---
|
||||
// mux.HandleFunc("POST /auth/register", a.auth.Register)
|
||||
// mux.HandleFunc("POST /auth/login", a.auth.Login)
|
||||
// mux.HandleFunc("POST /auth/oidc", a.auth.OIDC)
|
||||
// mux.HandleFunc("POST /auth/logout", a.auth.Logout)
|
||||
|
||||
// --- authenticated API endpoints (added in Phase C) ---
|
||||
// mux.HandleFunc("GET /api/lists", a.requireAuth(a.lists.List))
|
||||
// mux.HandleFunc("GET /api/lists/{id}/ops", a.requireAuth(a.items.PullOps))
|
||||
// mux.HandleFunc("POST /api/lists/{id}/ops", a.requireAuth(a.items.PushOps))
|
||||
// mux.HandleFunc("GET /api/suggestions", a.requireAuth(a.suggest.Suggest))
|
||||
|
||||
return Chain(
|
||||
mux,
|
||||
requestIDMiddleware,
|
||||
loggingMiddleware,
|
||||
recoverMiddleware,
|
||||
corsMiddleware(a.cfg),
|
||||
)
|
||||
}
|
||||
28
backend/internal/httpapi/health.go
Normal file
28
backend/internal/httpapi/health.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// HealthHandler exposes /healthz (liveness) and /readyz (readiness + DB ping).
|
||||
type HealthHandler struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Healthz(w http.ResponseWriter, r *http.Request) {
|
||||
renderJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := h.Pool.Ping(ctx); err != nil {
|
||||
renderError(w, http.StatusServiceUnavailable, "Database unavailable", err.Error())
|
||||
return
|
||||
}
|
||||
renderJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
125
backend/internal/httpapi/middleware.go
Normal file
125
backend/internal/httpapi/middleware.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
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
|
||||
}
|
||||
78
backend/internal/httpapi/render.go
Normal file
78
backend/internal/httpapi/render.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// Package httpapi contains HTTP handlers, routing and middleware.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Problem is a small RFC 7807-ish error body.
|
||||
type Problem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// renderJSON writes v as JSON with the given status code.
|
||||
func renderJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("write json response failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// renderError writes a Problem response.
|
||||
func renderError(w http.ResponseWriter, status int, title, detail string) {
|
||||
renderJSON(w, status, Problem{
|
||||
Title: title,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
|
||||
// apiError maps well-known errors to HTTP status codes. Returns true if handled.
|
||||
func apiError(w http.ResponseWriter, err error) bool {
|
||||
switch {
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
renderError(w, http.StatusNotFound, "Not found", "The requested resource does not exist.")
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
renderError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
|
||||
case errors.Is(err, ErrForbidden):
|
||||
renderError(w, http.StatusForbidden, "Forbidden", err.Error())
|
||||
case errors.Is(err, ErrConflict):
|
||||
renderError(w, http.StatusConflict, "Conflict", err.Error())
|
||||
case errors.Is(err, ErrBadRequest):
|
||||
renderError(w, http.StatusBadRequest, "Bad request", err.Error())
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Sentinel domain errors mapped by apiError.
|
||||
var (
|
||||
ErrBadRequest = errors.New("bad request")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrConflict = errors.New("conflict")
|
||||
)
|
||||
|
||||
// decodeJSON decodes r.Body into v. Returns false and writes an error on failure.
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
renderError(w, http.StatusBadRequest, "Invalid JSON", err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue