Initial commit
This commit is contained in:
commit
afed023152
8152 files changed
+2086461
No files matched your search
@@ -0,0 +1,151 @@
|
||||
// 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"})
|
||||
}
|
||||
Reference in new issue
Block a user