312 lines
8.6 KiB
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/css-verify-1770193392/pkg/config"
|
|
"git.threesix.ai/jordan/css-verify-1770193392/pkg/httpresponse"
|
|
"git.threesix.ai/jordan/css-verify-1770193392/pkg/logging"
|
|
"git.threesix.ai/jordan/css-verify-1770193392/pkg/middleware"
|
|
"git.threesix.ai/jordan/css-verify-1770193392/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)
|
|
}
|