sp4-run-1770498675/pkg/app/app.go

312 lines
8.6 KiB
Go

// Package app provides a service bootstrapper for HTTP services.
//
// App is the main application struct that provides infrastructure for HTTP services.
// It manages configuration, logging, routing, and graceful shutdown.
//
// Example usage:
//
// func main() {
// app := app.New("my-service", app.WithDefaultPort(8080))
// app.GET("/users/{id}", handlers.GetUser)
// app.POST("/users", handlers.CreateUser)
// app.Run()
// }
package app
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"git.threesix.ai/jordan/sp4-run-1770498675/pkg/config"
"git.threesix.ai/jordan/sp4-run-1770498675/pkg/httpresponse"
"git.threesix.ai/jordan/sp4-run-1770498675/pkg/logging"
"git.threesix.ai/jordan/sp4-run-1770498675/pkg/middleware"
"git.threesix.ai/jordan/sp4-run-1770498675/pkg/openapi"
)
// Router is an alias for chi.Router, exposing it for handler mounting.
type Router = chi.Router
// App is the main application struct that provides infrastructure for HTTP services.
type App struct {
name string
defaultPort int
logger *logging.Logger
router chi.Router
server *http.Server
// Configuration
appConfig config.AppConfig
serverConfig config.ServerConfig
// Lifecycle hooks
onShutdown []func(context.Context) error
}
// Option configures the App.
type Option func(*App)
// WithLogger sets a custom logger for the application.
func WithLogger(logger *logging.Logger) Option {
return func(a *App) {
a.logger = logger
}
}
// WithDefaultPort sets the default port if not configured via environment.
func WithDefaultPort(port int) Option {
return func(a *App) {
a.defaultPort = port
}
}
// New creates a new App instance with the given service name.
// It initializes configuration, logging, and routing infrastructure.
//
// The service name is used for:
// - Configuration defaults (APP_NAME)
// - Logging context (service attribute)
// - Health check identification
//
// Configuration is loaded from environment variables with support for:
// - .env file (in development)
// - Environment variables (highest priority)
func New(serviceName string, opts ...Option) *App {
app := &App{
name: serviceName,
defaultPort: 8080,
onShutdown: make([]func(context.Context) error, 0),
}
// Apply options before initialization (to capture defaultPort)
for _, opt := range opts {
opt(app)
}
// Initialize configuration
config.MustInit(config.Options{
AppName: serviceName,
DefaultPort: app.defaultPort,
})
// Load configuration
app.appConfig = config.ReadAppConfig()
app.serverConfig = config.ReadServerConfig()
// Initialize logger if not provided
if app.logger == nil {
logCfg := config.ReadLoggingConfig()
app.logger = logging.New(logging.Config{
Level: logging.ParseLevel(logCfg.Level),
Format: logging.ParseFormat(logCfg.Format),
Environment: app.appConfig.Environment,
AddSource: app.appConfig.IsDevelopment(),
}).WithService(serviceName)
}
// Initialize router with standard middleware
app.router = chi.NewRouter()
app.setupMiddleware()
app.setupHealthEndpoints()
return app
}
// setupMiddleware configures the standard middleware stack.
func (a *App) setupMiddleware() {
// Core middleware (order matters)
a.router.Use(middleware.RequestID())
a.router.Use(middleware.Tracing())
a.router.Use(middleware.RequestLogger(a.logger))
a.router.Use(middleware.Recoverer(a.logger))
// CORS (configurable via environment)
a.router.Use(middleware.CORS(middleware.DefaultCORSConfig()))
}
// setupHealthEndpoints registers /health and /ready endpoints.
func (a *App) setupHealthEndpoints() {
// Liveness probe - returns 200 if the process is running
a.router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
httpresponse.OK(w, r, map[string]string{
"status": "ok",
"service": a.name,
})
})
// Readiness probe - returns 200 if the service is ready to accept traffic
a.router.Get("/ready", func(w http.ResponseWriter, r *http.Request) {
httpresponse.OK(w, r, map[string]string{
"status": "ready",
"service": a.name,
})
})
}
// Logger returns the application logger.
func (a *App) Logger() *logging.Logger {
return a.logger
}
// Config returns the application configuration.
func (a *App) Config() config.AppConfig {
return a.appConfig
}
// ServerConfig returns the server configuration.
func (a *App) ServerConfig() config.ServerConfig {
return a.serverConfig
}
// Router returns the underlying chi router for advanced configuration.
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 to the given pattern.
func (a *App) GET(pattern string, handler http.HandlerFunc) {
a.router.Get(pattern, handler)
}
// POST registers a handler for POST requests to the given pattern.
func (a *App) POST(pattern string, handler http.HandlerFunc) {
a.router.Post(pattern, handler)
}
// PUT registers a handler for PUT requests to the given pattern.
func (a *App) PUT(pattern string, handler http.HandlerFunc) {
a.router.Put(pattern, handler)
}
// PATCH registers a handler for PATCH requests to the given pattern.
func (a *App) PATCH(pattern string, handler http.HandlerFunc) {
a.router.Patch(pattern, handler)
}
// DELETE registers a handler for DELETE requests to the given pattern.
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.
//
// Example:
//
// app.Route("/api/v1", func(r chi.Router) {
// r.Get("/users", listUsers)
// r.Post("/users", createUser)
// })
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.
// Functions are called in the order they were registered.
func (a *App) OnShutdown(fn func(context.Context) error) {
a.onShutdown = append(a.onShutdown, fn)
}
// Run starts the HTTP server and blocks until shutdown.
// It handles graceful shutdown on SIGINT and SIGTERM signals.
func (a *App) Run() {
addr := a.serverConfig.Addr()
a.server = &http.Server{
Addr: addr,
Handler: a.router,
ReadTimeout: a.serverConfig.ReadTimeout,
WriteTimeout: a.serverConfig.WriteTimeout,
IdleTimeout: a.serverConfig.IdleTimeout,
}
// Start server in a goroutine
errChan := make(chan error, 1)
go func() {
a.logger.Info("starting server",
"service", a.name,
"address", addr,
"environment", a.appConfig.Environment,
)
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errChan <- err
}
}()
// Wait for shutdown signal or server error
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())
}
// Graceful shutdown
a.shutdown()
}
// shutdown performs graceful shutdown of the application.
func (a *App) shutdown() {
// Create shutdown context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
a.logger.Info("shutting down server")
// Shutdown HTTP server
if err := a.server.Shutdown(ctx); err != nil {
a.logger.Error("server shutdown error", "error", err)
}
// Run shutdown hooks
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)
}
// ListenAddr returns the address the server is configured to listen on.
func (a *App) ListenAddr() string {
return a.serverConfig.Addr()
}
// EnableDocs adds /docs and /openapi.json endpoints to the application.
// It mounts the Scalar UI at /docs and the OpenAPI JSON spec at /openapi.json.
//
// Example:
//
// spec := openapi.NewOpenAPISpec("My Service", "1.0.0")
// // ... add paths and schemas ...
// application.EnableDocs(spec)
func (a *App) EnableDocs(spec *openapi.OpenAPISpec) {
openapi.Mount(a.router, spec)
a.logger.Info("API documentation enabled", "docs", "/docs", "spec", "/openapi.json")
}
// 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)
}