rdev/pkg/api/app.go
jordan fa66a69120 fix: Defer health endpoints to Run() for proper middleware ordering
Chi requires middleware to be defined before routes. Moved
setupHealthEndpoints() from New() to Run() to allow callers to
add middleware before routes are registered.

Also:
- Updated rdev-api.yaml with DB env vars, RBAC, ServiceAccount
- Added Dockerfile.api.simple for pre-built binary deployment

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 23:28:54 -07:00

226 lines
5.4 KiB
Go

// Package api provides HTTP service infrastructure for rdev.
package api
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// Router is an alias for chi.Router, exposing it for handler mounting.
type Router = chi.Router
// App is the main application struct that provides HTTP infrastructure.
// It manages routing, logging, and graceful shutdown.
type App struct {
name string
port int
logger *slog.Logger
router chi.Router
server *http.Server
onShutdown []func(context.Context) error
}
// Option configures the App.
type Option func(*App)
// WithPort sets the server port.
func WithPort(port int) Option {
return func(a *App) {
a.port = port
}
}
// WithLogger sets a custom logger.
func WithLogger(logger *slog.Logger) Option {
return func(a *App) {
a.logger = logger
}
}
// New creates a new App instance.
// Note: Call Use() to add middleware before mounting routes.
func New(name string, opts ...Option) *App {
app := &App{
name: name,
port: 8080,
onShutdown: make([]func(context.Context) error, 0),
}
for _, opt := range opts {
opt(app)
}
if app.logger == nil {
app.logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
}
app.router = chi.NewRouter()
app.setupMiddleware()
// Health endpoints are set up in Run() to allow middleware to be added first
return app
}
// setupMiddleware configures the standard middleware stack.
func (a *App) setupMiddleware() {
a.router.Use(middleware.RequestID)
a.router.Use(middleware.RealIP)
a.router.Use(middleware.Logger)
a.router.Use(middleware.Recoverer)
a.router.Use(middleware.Timeout(60 * time.Second))
}
// setupHealthEndpoints registers /health and /ready endpoints.
func (a *App) setupHealthEndpoints() {
a.router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
WriteSuccess(w, r, map[string]string{
"status": "ok",
"service": a.name,
})
})
a.router.Get("/ready", func(w http.ResponseWriter, r *http.Request) {
WriteSuccess(w, r, map[string]string{
"status": "ready",
"service": a.name,
})
})
}
// Logger returns the application logger.
func (a *App) Logger() *slog.Logger {
return a.logger
}
// Router returns the underlying chi router.
func (a *App) Router() chi.Router {
return a.router
}
// Use appends middleware to the router middleware stack.
func (a *App) Use(middlewares ...func(http.Handler) http.Handler) {
a.router.Use(middlewares...)
}
// GET registers a handler for GET requests.
func (a *App) GET(pattern string, handler http.HandlerFunc) {
a.router.Get(pattern, handler)
}
// POST registers a handler for POST requests.
func (a *App) POST(pattern string, handler http.HandlerFunc) {
a.router.Post(pattern, handler)
}
// PUT registers a handler for PUT requests.
func (a *App) PUT(pattern string, handler http.HandlerFunc) {
a.router.Put(pattern, handler)
}
// PATCH registers a handler for PATCH requests.
func (a *App) PATCH(pattern string, handler http.HandlerFunc) {
a.router.Patch(pattern, handler)
}
// DELETE registers a handler for DELETE requests.
func (a *App) DELETE(pattern string, handler http.HandlerFunc) {
a.router.Delete(pattern, handler)
}
// Route creates a new sub-router with the given pattern prefix.
func (a *App) Route(pattern string, fn func(r chi.Router)) {
a.router.Route(pattern, fn)
}
// Mount attaches a sub-router or http.Handler at the given pattern.
func (a *App) Mount(pattern string, handler http.Handler) {
a.router.Mount(pattern, handler)
}
// OnShutdown registers a function to be called during graceful shutdown.
func (a *App) OnShutdown(fn func(context.Context) error) {
a.onShutdown = append(a.onShutdown, fn)
}
// Run starts the HTTP server and blocks until shutdown.
func (a *App) Run() {
// Set up health endpoints after all middleware has been added
a.setupHealthEndpoints()
addr := fmt.Sprintf(":%d", a.port)
a.server = &http.Server{
Addr: addr,
Handler: a.router,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
errChan := make(chan error, 1)
go func() {
a.logger.Info("starting server",
"service", a.name,
"address", addr,
)
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errChan <- err
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errChan:
a.logger.Error("server error", "error", err)
os.Exit(1)
case sig := <-quit:
a.logger.Info("received shutdown signal", "signal", sig.String())
}
a.shutdown()
}
// shutdown performs graceful shutdown of the application.
func (a *App) shutdown() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
a.logger.Info("shutting down server")
if err := a.server.Shutdown(ctx); err != nil {
a.logger.Error("server shutdown error", "error", err)
}
for _, fn := range a.onShutdown {
if err := fn(ctx); err != nil {
a.logger.Error("shutdown hook error", "error", err)
}
}
a.logger.Info("server stopped", "service", a.name)
}
// ServeHTTP implements http.Handler, allowing App to be used in tests.
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
a.router.ServeHTTP(w, r)
}
// ListenAddr returns the address the server is configured to listen on.
func (a *App) ListenAddr() string {
return fmt.Sprintf(":%d", a.port)
}