// SPDX-License-Identifier: EUPL-1.2 // Package api provides a Gin-based REST framework with OpenAPI generation. // Subsystems implement RouteGroup to register their own endpoints. package api import ( "context" "iter" "net/http" "slices" "time" coreerr "dappco.re/go/core/log" "github.com/gin-contrib/expvar" "github.com/gin-contrib/pprof" "github.com/gin-gonic/gin" ) const defaultAddr = ":8080" // shutdownTimeout is the maximum duration to wait for in-flight requests // to complete during graceful shutdown. const shutdownTimeout = 10 * time.Second // Engine is the central API server managing route groups and middleware. type Engine struct { addr string groups []RouteGroup middlewares []gin.HandlerFunc wsHandler http.Handler sseBroker *SSEBroker swaggerEnabled bool swaggerTitle string swaggerDesc string swaggerVersion string pprofEnabled bool expvarEnabled bool graphql *graphqlConfig } // New creates an Engine with the given options. // The default listen address is ":8080". // // engine, _ := api.New(api.WithAddr(":9090"), api.WithCORS("*")) // engine.Register(myGroup) // engine.Serve(ctx) func New(opts ...Option) (*Engine, error) { e := &Engine{ addr: defaultAddr, } for _, opt := range opts { opt(e) } return e, nil } // Addr returns the configured listen address. // // engine, _ := api.New(api.WithAddr(":9090")) // addr := engine.Addr() // ":9090" func (e *Engine) Addr() string { return e.addr } // Groups returns all registered route groups. func (e *Engine) Groups() []RouteGroup { return e.groups } // GroupsIter returns an iterator over all registered route groups. func (e *Engine) GroupsIter() iter.Seq[RouteGroup] { return slices.Values(e.groups) } // Register adds a route group to the engine. // // engine.Register(api.NewToolBridge("/tools")) // engine.Register(myRouteGroup) func (e *Engine) Register(group RouteGroup) { e.groups = append(e.groups, group) } // Channels returns all WebSocket channel names from registered StreamGroups. // Groups that do not implement StreamGroup are silently skipped. func (e *Engine) Channels() []string { var channels []string for _, group := range e.groups { if streamGroup, ok := group.(StreamGroup); ok { channels = append(channels, streamGroup.Channels()...) } } return channels } // ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups. func (e *Engine) ChannelsIter() iter.Seq[string] { return func(yield func(string) bool) { for _, group := range e.groups { if streamGroup, ok := group.(StreamGroup); ok { for _, channelName := range streamGroup.Channels() { if !yield(channelName) { return } } } } } } // Handler builds the Gin engine and returns it as an http.Handler. // Each call produces a fresh handler reflecting the current set of groups. // // http.ListenAndServe(":8080", engine.Handler()) func (e *Engine) Handler() http.Handler { return e.build() } // Serve starts the HTTP server and blocks until the context is cancelled, // then performs a graceful shutdown allowing in-flight requests to complete. func (e *Engine) Serve(ctx context.Context) error { server := &http.Server{ Addr: e.addr, Handler: e.build(), } errCh := make(chan error, 1) go func() { if err := server.ListenAndServe(); err != nil && !coreerr.Is(err, http.ErrServerClosed) { errCh <- err } close(errCh) }() // Block until context is cancelled. <-ctx.Done() // Graceful shutdown with timeout. shutdownContext, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() if err := server.Shutdown(shutdownContext); err != nil { return err } // Return any listen error that occurred before shutdown. return <-errCh } // build creates a configured Gin engine with recovery middleware, // user-supplied middleware, the health endpoint, and all registered route groups. func (e *Engine) build() *gin.Engine { router := gin.New() router.Use(gin.Recovery()) // Apply user-supplied middleware after recovery but before routes. for _, middleware := range e.middlewares { router.Use(middleware) } // Built-in health check. router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, OK("healthy")) }) // Mount each registered group at its base path. for _, group := range e.groups { routerGroup := router.Group(group.BasePath()) group.RegisterRoutes(routerGroup) } // Mount WebSocket handler if configured. if e.wsHandler != nil { router.GET("/ws", wrapWSHandler(e.wsHandler)) } // Mount SSE endpoint if configured. if e.sseBroker != nil { router.GET("/events", e.sseBroker.Handler()) } // Mount GraphQL endpoint if configured. if e.graphql != nil { mountGraphQL(router, e.graphql) } // Mount Swagger UI if enabled. if e.swaggerEnabled { registerSwagger(router, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups) } // Mount pprof profiling endpoints if enabled. if e.pprofEnabled { pprof.Register(router) } // Mount expvar runtime metrics endpoint if enabled. if e.expvarEnabled { router.GET("/debug/vars", expvar.Handler()) } return router }