package app import ( "context" "errors" "net" "net/http" "os" "os/signal" "sync" "syscall" "time" "git.yuntuo.hk/k2safe/go-omni/config" apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/middleware" netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp" ) // App is the top-level GoOmni application. Its public behavior depends on // httpx.Router, leaving the network implementation replaceable. type App struct { router httpx.Router config config.Config middlewares []httpx.Middleware closers []func() error closeOnce sync.Once closeErr error } // New creates an application using GoOmni's standard-library router. func New(options ...config.Option) *App { settings := configure(options...) return newApp(netrouter.New(), settings) } // NewWithRouter creates an application with a custom network layer. A custom // router only needs to implement httpx.Router; handlers and business code stay // unchanged when the transport changes. func NewWithRouter(router httpx.Router, options ...config.Option) *App { if router == nil { panic("go-omni: nil router") } return newApp(router, configure(options...)) } func configure(options ...config.Option) config.Config { settings := config.Default() for _, option := range options { if option != nil { option(&settings) } } return settings } func newApp(router httpx.Router, settings config.Config) *App { middlewares := []httpx.Middleware{middleware.RequestID(settings.RequestIDHeader)} if settings.EnableLogger { middlewares = append(middlewares, middleware.AccessLog()) } middlewares = append(middlewares, middleware.Recovery()) return &App{router: router, config: settings, middlewares: middlewares} } // Router returns the transport contract used by this application. func (a *App) Router() httpx.Router { return a.router } // Engine is kept as a compatibility name for the old API. It returns a // standard http.Handler, so callers do not depend on a router type. func (a *App) Engine() http.Handler { return a.router } // ServeHTTP implements http.Handler for standard servers and HTTP tests. func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) { a.router.ServeHTTP(writer, request) } // Config returns the effective application configuration. func (a *App) Config() config.Config { return a.config } // Use adds middleware to routes registered after this call. func (a *App) Use(middleware ...httpx.Middleware) *App { a.middlewares = append(a.middlewares, middleware...) return a } // OnShutdown registers a cleanup function owned by the application. Cleanup // functions run in reverse registration order when Close or RunContext ends. // This keeps omni.New as the only public startup entry point while allowing // projects to attach databases, loggers and other resources to its lifecycle. func (a *App) OnShutdown(cleanup func() error) *App { if cleanup != nil { a.closers = append(a.closers, cleanup) } return a } // Close releases application resources. It is safe to call more than once. func (a *App) Close() error { a.closeOnce.Do(func() { for index := len(a.closers) - 1; index >= 0; index-- { if err := a.closers[index](); err != nil { a.closeErr = errors.Join(a.closeErr, err) } } }) return a.closeErr } // Run starts the HTTP server and handles SIGINT/SIGTERM. Use RunContext when // the caller owns cancellation. The default address is :8080. func (a *App) Run(address ...string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() return a.RunContext(ctx, address...) } // NewServer creates a standard HTTP server around the application. func (a *App) NewServer(address string) *http.Server { if address == "" { address = a.config.Address if address == "" { address = ":8080" } } return &http.Server{Addr: address, Handler: a.router} } // RunContext serves until the context is canceled, then performs a graceful shutdown. func (a *App) RunContext(ctx context.Context, address ...string) error { if ctx == nil { ctx = context.Background() } if ctx.Err() != nil { return a.Close() } listenOn := a.config.Address if listenOn == "" { listenOn = ":8080" } if len(address) > 0 && address[0] != "" { listenOn = address[0] } server := a.NewServer(listenOn) listener, err := net.Listen("tcp", server.Addr) if err != nil { return errors.Join(err, a.Close()) } return a.serveContext(ctx, server, listener) } func (a *App) serveContext(ctx context.Context, server *http.Server, listener net.Listener) error { serveErr := make(chan error, 1) go func() { serveErr <- server.Serve(listener) }() select { case err := <-serveErr: closeErr := a.Close() if err == http.ErrServerClosed { return closeErr } return errors.Join(err, closeErr) case <-ctx.Done(): shutdownTimeout := a.config.ShutdownTimeout if shutdownTimeout <= 0 { shutdownTimeout = 10 * time.Second } shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() shutdownErr := server.Shutdown(shutdownCtx) if shutdownErr != nil { // Shutdown leaves active connections open on timeout. Close them so // request contexts are canceled before releasing shared resources. shutdownErr = errors.Join(shutdownErr, server.Close()) } if err := <-serveErr; err != nil && err != http.ErrServerClosed { shutdownErr = errors.Join(shutdownErr, err) } return errors.Join(shutdownErr, a.Close()) } } func (a *App) writeError(ctx *httpx.Context, err error) { if err == nil || ctx.Responded() { return } ctx.WriteError(apierrors.From(err)) }