// Package api wires HTTP routing, middleware, and JSON conventions on top // of the stdlib ServeMux (Go 1.22+ method patterns and r.PathValue). package api import ( "context" "log/slog" "net/http" "strings" "time" "git.k6n.net/mats/fullstack-template/internal/items" ) // Pinger abstracts the database health check so tests can stub it. type Pinger interface { PingContext(ctx context.Context) error } // Deps are the server's runtime collaborators, wired in cmd/api. type Deps struct { Logger *slog.Logger Items *items.Service DB Pinger AllowedOrigins []string RequestTimeout time.Duration // zero means the 30s default Auth *AuthDeps // nil disables OIDC: /api/auth answers 404, /api/v1 stays open } const defaultRequestTimeout = 30 * time.Second // New builds the full handler: middleware chain around the route table. // Middleware order (outermost first): logging → recover → cors → timeout // → (requireAuth, only when OIDC is configured). func New(deps Deps) http.Handler { if deps.RequestTimeout <= 0 { deps.RequestTimeout = defaultRequestTimeout } var authH *authHandler if deps.Auth != nil { if deps.Auth.SessionTTL <= 0 { deps.Auth.SessionTTL = defaultSessionTTL } authH = &authHandler{ client: deps.Auth.Client, signer: deps.Auth.Signer, sessionTTL: deps.Auth.SessionTTL, logger: deps.Logger, } } s := &server{ logger: deps.Logger, db: deps.DB, items: &itemHandler{svc: deps.Items, logger: deps.Logger}, auth: authH, } mux := http.NewServeMux() // Liveness is "the process is up"; readiness is "it can serve real // traffic", here meaning the database answers. mux.HandleFunc("GET /healthz", s.health) mux.HandleFunc("GET /readyz", s.readiness) if s.auth != nil { mux.HandleFunc("GET /api/auth/session", s.auth.session) mux.HandleFunc("GET /api/auth/login", s.auth.login) mux.HandleFunc("GET /api/auth/callback", s.auth.callback) mux.HandleFunc("POST /api/auth/logout", s.auth.logout) } // Reference CRUD resource. Method-specific patterns make the mux // answer 405 with an Allow header for free; read segments with // r.PathValue("id"). mux.HandleFunc("GET /api/v1/items", s.items.list) mux.HandleFunc("POST /api/v1/items", s.items.create) mux.HandleFunc("GET /api/v1/items/{id}", s.items.get) mux.HandleFunc("PATCH /api/v1/items/{id}", s.items.update) mux.HandleFunc("DELETE /api/v1/items/{id}", s.items.delete) // The wrapper keeps the mux's automatic 405 + Allow responses for // wrong-method requests and renders all unmatched routes as JSON 404s // (the mux default is plain text). mws := []middleware{ logging(deps.Logger), recoverPanics(deps.Logger), cors(deps.AllowedOrigins), timeout(deps.RequestTimeout), } if s.auth != nil { mws = append(mws, requireAuth(deps.Auth.Signer)) } return chain(jsonNotFounds(mux), mws...) } type server struct { logger *slog.Logger db Pinger items *itemHandler auth *authHandler } // jsonNotFounds delegates to mux and turns its default plain-text responses // for unmatched requests into the JSON error envelope. A registered "/" or // catch-all route would swallow the mux's automatic 405s, so this checks // mux.Handler instead: an empty pattern means nothing matched, and probing // the registered methods distinguishes "wrong method" (405 + Allow) from // "unknown route" (404). The probe runs only on the miss path. func jsonNotFounds(mux *http.ServeMux) http.Handler { methods := []string{ http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, pattern := mux.Handler(r); pattern == "" { var allowed []string for _, m := range methods { probe := r.Clone(r.Context()) probe.Method = m if _, pattern := mux.Handler(probe); pattern != "" { allowed = append(allowed, m) } } if len(allowed) > 0 { w.Header().Set("Allow", strings.Join(allowed, ", ")) writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed for this route") return } writeError(w, http.StatusNotFound, "not_found", "route not found") return } mux.ServeHTTP(w, r) }) } func (s *server) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *server) readiness(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() if err := s.db.PingContext(ctx); err != nil { writeError(w, http.StatusServiceUnavailable, "unavailable", "database is not reachable") return } writeJSON(w, http.StatusOK, map[string]string{"status": "ready"}) }