go-api/api.go
Snider 303779f527 feat: refactor swagger to use SpecBuilder for runtime OpenAPI generation
Replace the hardcoded Swagger 2.0 JSON template with SpecBuilder-backed
OpenAPI 3.1 generation. The swagger spec is now built lazily from
registered RouteGroups (including DescribableGroup and ToolBridge
endpoints) and cached via sync.Once. Uses unique swag instance names
to avoid global registry collisions in tests.

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:53:37 +00:00

169 lines
4 KiB
Go

// 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"
"errors"
"net/http"
"time"
"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".
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.
func (e *Engine) Addr() string {
return e.addr
}
// Groups returns all registered route groups.
func (e *Engine) Groups() []RouteGroup {
return e.groups
}
// Register adds a route group to the engine.
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 _, g := range e.groups {
if sg, ok := g.(StreamGroup); ok {
channels = append(channels, sg.Channels()...)
}
}
return channels
}
// Handler builds the Gin engine and returns it as an http.Handler.
// Each call produces a fresh handler reflecting the current set of groups.
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 {
srv := &http.Server{
Addr: e.addr,
Handler: e.build(),
}
errCh := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
// Block until context is cancelled.
<-ctx.Done()
// Graceful shutdown with timeout.
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutdownCtx); 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 {
r := gin.New()
r.Use(gin.Recovery())
// Apply user-supplied middleware after recovery but before routes.
for _, mw := range e.middlewares {
r.Use(mw)
}
// Built-in health check.
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, OK("healthy"))
})
// Mount each registered group at its base path.
for _, g := range e.groups {
rg := r.Group(g.BasePath())
g.RegisterRoutes(rg)
}
// Mount WebSocket handler if configured.
if e.wsHandler != nil {
r.GET("/ws", wrapWSHandler(e.wsHandler))
}
// Mount SSE endpoint if configured.
if e.sseBroker != nil {
r.GET("/events", e.sseBroker.Handler())
}
// Mount GraphQL endpoint if configured.
if e.graphql != nil {
mountGraphQL(r, e.graphql)
}
// Mount Swagger UI if enabled.
if e.swaggerEnabled {
registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
}
// Mount pprof profiling endpoints if enabled.
if e.pprofEnabled {
pprof.Register(r)
}
// Mount expvar runtime metrics endpoint if enabled.
if e.expvarEnabled {
r.GET("/debug/vars", expvar.Handler())
}
return r
}