160 lines
5.1 KiB
Go
160 lines
5.1 KiB
Go
// 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/browse"
|
|
"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
|
|
Browse *browse.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},
|
|
browse: &browseHandler{svc: deps.Browse, 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)
|
|
|
|
// Agent view: renders a remote page the way a text-only agent sees it.
|
|
// The URL comes in via query parameter, so the response is a plain GET.
|
|
mux.HandleFunc("GET /api/v1/browse", s.browse.get)
|
|
|
|
// 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
|
|
browse *browseHandler
|
|
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"})
|
|
}
|