// 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/testgo3/pkg/config" "git.threesix.ai/jordan/testgo3/pkg/httpresponse" "git.threesix.ai/jordan/testgo3/pkg/logging" "git.threesix.ai/jordan/testgo3/pkg/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 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() } // 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) }