api/api.go
Snider d90a5be936 refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.

Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00

298 lines
7 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"
"iter"
"net/http"
"reflect"
"slices"
"time"
core "dappco.re/go/core"
"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.
//
// Example:
//
// engine, err := api.New(api.WithAddr(":8081"))
// if err != nil {
// panic(err)
// }
// _ = engine.Handler()
type Engine struct {
addr string
groups []RouteGroup
middlewares []gin.HandlerFunc
cacheTTL time.Duration
cacheMaxEntries int
cacheMaxBytes int
wsHandler http.Handler
wsPath string
sseBroker *SSEBroker
swaggerEnabled bool
swaggerTitle string
swaggerSummary string
swaggerDesc string
swaggerVersion string
swaggerPath string
swaggerTermsOfService string
swaggerServers []string
swaggerContactName string
swaggerContactURL string
swaggerContactEmail string
swaggerLicenseName string
swaggerLicenseURL string
swaggerSecuritySchemes map[string]any
swaggerExternalDocsDescription string
swaggerExternalDocsURL string
authentikConfig AuthentikConfig
pprofEnabled bool
expvarEnabled bool
ssePath string
graphql *graphqlConfig
i18nConfig I18nConfig
}
// New creates an Engine with the given options.
// The default listen address is ":8080".
//
// Example:
//
// engine, err := api.New(api.WithAddr(":8081"), api.WithResponseMeta())
// if err != nil {
// panic(err)
// }
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.
//
// Example:
//
// engine, _ := api.New(api.WithAddr(":9090"))
// addr := engine.Addr()
func (e *Engine) Addr() string {
return e.addr
}
// Groups returns a copy of all registered route groups.
//
// Example:
//
// groups := engine.Groups()
func (e *Engine) Groups() []RouteGroup {
return slices.Clone(e.groups)
}
// GroupsIter returns an iterator over all registered route groups.
//
// Example:
//
// for group := range engine.GroupsIter() {
// _ = group
// }
func (e *Engine) GroupsIter() iter.Seq[RouteGroup] {
groups := slices.Clone(e.groups)
return slices.Values(groups)
}
// Register adds a route group to the engine.
//
// Example:
//
// engine.Register(myGroup)
func (e *Engine) Register(group RouteGroup) {
if isNilRouteGroup(group) {
return
}
e.groups = append(e.groups, group)
}
// Channels returns all WebSocket channel names from registered StreamGroups.
// Groups that do not implement StreamGroup are silently skipped.
//
// Example:
//
// channels := engine.Channels()
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
}
// ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups.
//
// Example:
//
// for channel := range engine.ChannelsIter() {
// _ = channel
// }
func (e *Engine) ChannelsIter() iter.Seq[string] {
groups := slices.Clone(e.groups)
return func(yield func(string) bool) {
for _, g := range groups {
if sg, ok := g.(StreamGroup); ok {
for _, c := range sg.Channels() {
if !yield(c) {
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.
//
// Example:
//
// handler := 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.
//
// Example:
//
// ctx, cancel := context.WithCancel(context.Background())
// defer cancel()
// _ = engine.Serve(ctx)
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 && !core.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
// Return immediately if the listener fails before shutdown is requested.
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
// Signal SSE clients first so their handlers can exit cleanly before the
// HTTP server begins its own shutdown sequence.
if e.sseBroker != nil {
e.sseBroker.Drain()
}
// 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(recoveryMiddleware())
// 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 {
if isNilRouteGroup(g) {
continue
}
rg := r.Group(g.BasePath())
g.RegisterRoutes(rg)
}
// Mount WebSocket handler if configured.
if e.wsHandler != nil {
r.GET(resolveWSPath(e.wsPath), wrapWSHandler(e.wsHandler))
}
// Mount SSE endpoint if configured.
if e.sseBroker != nil {
r.GET(resolveSSEPath(e.ssePath), 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, 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
}
func isNilRouteGroup(group RouteGroup) bool {
if group == nil {
return true
}
value := reflect.ValueOf(group)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
}