Files
llm-browser/backend/internal/api/server.go
T
2026-10-19 15:25:27 +00:00

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"})
}