Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
255 commits
Select commit Hold shift + click to select a range
db1efd5
feat(api): add unified pixel tracking endpoint
Apr 1, 2026
797c5f9
feat(api): add entitlements endpoint
Apr 1, 2026
6fdd769
feat(api): add per-IP rate limiting middleware
Apr 1, 2026
1c9e489
feat(api): add spec description flag
Apr 1, 2026
71eebc5
feat(provider): expose proxy metadata in registry info
Apr 1, 2026
5eaaa8a
feat(api): add seo analysis endpoint
Apr 1, 2026
491c9a1
fix(i18n): preserve matched locale tags
Apr 1, 2026
16abc45
feat(api): add stable openapi operation ids
Apr 1, 2026
f030665
feat(api): preserve path params in operationId
Apr 1, 2026
9afaed4
feat(api): document bearer auth in openapi
Apr 1, 2026
837a910
feat(cache): add LRU eviction limit
Apr 1, 2026
65ae0fc
feat(api): drain SSE clients on shutdown
Apr 1, 2026
9aa7c64
fix(api): disable non-positive timeouts
Apr 1, 2026
5d5ca8a
feat(api): validate ToolBridge output schemas
Apr 1, 2026
c9cf407
feat(api): add Stoplight docs renderer
Apr 1, 2026
825b61c
feat(api): add request ID accessor helper
Apr 1, 2026
2d8bb12
fix(api): preserve request id on cache hits
Apr 1, 2026
4efa435
feat(api): add MCP resource listing endpoint
Apr 1, 2026
00a5994
feat(api): attach request metadata to responses
Apr 1, 2026
37b7fd2
feat(cache): refresh request meta on cache hits
Apr 1, 2026
c4cbd01
feat(api): auto-attach request metadata
Apr 1, 2026
fb6812d
feat(api): emit request bodies for non-GET operations
Apr 1, 2026
926a723
feat(api): add runtime OpenAPI client
Apr 1, 2026
684a37c
fix(api): return listen errors immediately
Apr 1, 2026
90e237a
feat(api): include HEAD request bodies in OpenAPI
Apr 1, 2026
b58de8f
feat(provider): expose registry spec files
Apr 1, 2026
4bc132f
feat(api): fall back to group tags in openapi
Apr 1, 2026
321ced1
feat(api): add OpenAPI server metadata
Apr 1, 2026
726938f
feat(api): add auth responses to openapi
Apr 1, 2026
fd09309
feat(api): document rate limit and timeout responses
Apr 1, 2026
1bb2f68
feat(api): document rate limit response headers
Apr 1, 2026
4420651
feat(api): document request ID response headers
Apr 1, 2026
ac21992
feat(api): enforce tool schema enums
Apr 1, 2026
28f9540
fix(bridge): enforce tool schema enum validation
Apr 1, 2026
e713fb9
feat(api): emit rate limit headers on success and reject
Apr 1, 2026
90600aa
feat(api): expose swagger server metadata
Apr 1, 2026
3b92eda
feat(api): add shared response envelope schema
Apr 1, 2026
ac59d28
feat(api): document rate limit headers on all responses
Apr 1, 2026
1cc0f2f
feat(api): standardise panic responses
Apr 1, 2026
1983877
feat(api): normalize CLI list arguments
Apr 1, 2026
b9f9181
feat(api): support HEAD request bodies in OpenAPI client
Apr 1, 2026
bfa80e3
feat(api): support repeated query parameters in openapi client
Apr 1, 2026
c603403
feat(bridge): enforce additional schema constraints
Apr 1, 2026
da9bb91
fix(api): tighten public path auth bypass matching
Apr 1, 2026
5b59a1d
feat(api): prefer absolute OpenAPI servers
Apr 1, 2026
c48effb
feat(api): normalise OpenAPI server metadata
Apr 1, 2026
1ec5bf4
feat(api): attach request meta to error envelopes
Apr 1, 2026
f634914
feat(api): validate openapi client requests and responses
Apr 1, 2026
164a1d4
feat(api): document cache hits in OpenAPI
Apr 1, 2026
aff5440
fix(api): compose swagger server metadata
Apr 1, 2026
db787a7
feat(api): document SEO and MCP query parameters
Apr 1, 2026
ee83aab
fix(api): pass MCP tool version through execution
Apr 1, 2026
cdef85d
feat(graphql): normalise custom mount paths
Apr 1, 2026
6e87877
feat(api-docs): document MCP tool call body
Apr 1, 2026
5da281c
feat(bridge): support schema composition keywords
Apr 1, 2026
2f8f8f8
fix(api): scope rate limiting by key
Apr 1, 2026
edb1cf0
feat(openapi): document path parameters
Apr 1, 2026
3b26a15
feat(api): register CLI spec groups
Apr 1, 2026
69beb45
feat(api): expose webhook secret routes
Apr 1, 2026
0144244
feat(api): dedupe registered spec groups
Apr 1, 2026
862604d
feat(api): expose SDK spec metadata flags
Apr 1, 2026
0ed72c4
feat(api): document explicit route parameters
Apr 1, 2026
a055781
feat(i18n): add locale message fallback
Apr 1, 2026
ea94081
feat(api): normalise OpenAPI path joins
Apr 1, 2026
3f010b8
feat(api): declare explicit OpenAPI tags
Apr 1, 2026
13cc93f
fix(openapi): skip blank tags in generated specs
Apr 1, 2026
7c3e8e7
feat(openapi): support gin-style path params
Apr 1, 2026
ebad4c3
feat(client): support header and cookie parameters
Apr 1, 2026
408a709
feat(openapi): allow route-level security overrides
Apr 1, 2026
6034579
feat(openapi): fall back to group tags
Apr 1, 2026
6017ac7
feat(api): collapse equivalent OpenAPI servers
Apr 1, 2026
cd4e24d
feat(api): document custom success statuses
Apr 1, 2026
c962772
fix(provider): harden proxy path stripping
Apr 1, 2026
e2935ce
feat(api): dedupe PHP OpenAPI operation IDs
Apr 1, 2026
b341b4b
docs(api): add AX usage examples
Apr 1, 2026
d373797
feat(openapi): add info license metadata
Apr 1, 2026
0ed1cfa
docs(api): add AX examples to public APIs
Apr 1, 2026
b2d3c96
feat(api): expose swagger licence metadata
Apr 1, 2026
d45ee65
feat(api): expose swagger licence metadata in CLI
Apr 1, 2026
a589d3b
feat(api): add OpenAPI contact metadata
Apr 1, 2026
071de51
feat(openapi): mark deprecated operations in spec
Apr 1, 2026
4d7f3a9
feat(openapi): add terms of service metadata
Apr 1, 2026
bb7d88f
feat(openapi): add external docs metadata
Apr 1, 2026
7e4d8eb
feat(openapi): add route examples to spec
Apr 1, 2026
eceda4e
feat(openapi): support iterator-backed route descriptions
Apr 1, 2026
f2f262a
refactor(api): standardise unauthorised wording
Apr 1, 2026
cb72600
feat(api): add iterator for spec registry
Apr 1, 2026
b0adb53
fix(openapi): snapshot describable groups once
Apr 1, 2026
f62933f
feat(openapi): document example-only request bodies
Apr 1, 2026
db9daad
fix(api): return engine groups by copy
Apr 1, 2026
4ce6971
fix(client): promote declared query params on all methods
Apr 1, 2026
e0bdca7
fix(api): snapshot engine iterator views
Apr 1, 2026
867221c
fix(api): snapshot tool bridge iterators
Apr 1, 2026
2d1ed13
refactor(api): align OpenAPI client with AX principles
Apr 1, 2026
475027d
refactor(api): wrap ToolBridge errors
Apr 1, 2026
2fd17a4
fix(provider): handle invalid proxy upstreams safely
Apr 1, 2026
b2116cc
feat(openapi): omit auth errors on public routes
Apr 1, 2026
691ef93
feat(api): allow versioned route sunset replacements
Apr 1, 2026
93cdb62
feat(api): allow deprecation without sunset date
Apr 1, 2026
cba25cf
feat(api-docs): document sunset middleware in OpenAPI
Apr 1, 2026
2bdcb55
feat(api): add ApiSunset middleware
Apr 1, 2026
2cfa970
fix(api-docs): align sunset docs with middleware args
Apr 1, 2026
1a8fafe
feat(api): enrich MCP server details on demand
Apr 1, 2026
f0d2539
feat(provider): add iterator for provider info summaries
Apr 1, 2026
ec7391c
feat(api): add iterator-backed spec export
Apr 1, 2026
a89a708
fix(api): deduplicate spec iterator groups
Apr 1, 2026
929b6b9
fix(api-docs): deduplicate explicit OpenAPI parameters
Apr 1, 2026
9553808
feat(api): add counts to MCP server detail
Apr 1, 2026
00c20ea
refactor(api): streamline ToolBridge iterator snapshots
Apr 1, 2026
ccfbe57
feat(openapi): document response headers
Apr 1, 2026
cebad9b
feat(api): honour header toggles for versioning
Apr 1, 2026
dd74a80
fix(api): infer JsonResource schemas in docs
Apr 1, 2026
b64c8d3
docs(api): add AX usage examples
Apr 1, 2026
29324b0
feat(api): add sunset deprecation middleware
Apr 1, 2026
06f2263
fix(api): disable cache middleware for non-positive ttl
Apr 1, 2026
eb7e1e5
feat(openapi): reuse deprecation header components
Apr 1, 2026
14eedd7
feat(cmd/api): dedupe sdk spec groups
Apr 1, 2026
47e8c8a
feat(openapi): document route headers on all responses
Apr 1, 2026
0984c2f
docs(api): add AX usage examples
Apr 1, 2026
9449c19
fix(api): preserve existing link headers in sunset middleware
Apr 1, 2026
0f20eaa
fix(api): preserve sunset response headers
Apr 1, 2026
93bef3e
fix(api): ignore nil route groups
Apr 1, 2026
159f8d3
feat(api-docs): document versioned response headers
Apr 1, 2026
799de22
fix(api): preserve sunset middleware headers
Apr 1, 2026
1f43f01
feat(api): allow openapi specs from readers
Apr 1, 2026
f67e3fe
feat(api): validate required openapi parameters
Apr 2, 2026
68edd77
docs(api): add ax usage examples
Apr 2, 2026
ad751fc
fix(api): preserve multi-value cached headers
Apr 2, 2026
9b5477c
fix(api): ignore blank swagger metadata overrides
Apr 2, 2026
68bf8dc
feat(openapi): document GraphQL endpoint
Apr 2, 2026
2c87fa0
feat(cmd/api): add GraphQL path to spec generation
Apr 2, 2026
ffbb6d8
fix(api): snapshot swagger groups
Apr 2, 2026
68f5abe
fix(api): trim tool bridge tags
Apr 2, 2026
0bb07f4
feat(openapi): hide undocumented routes
Apr 2, 2026
812400f
feat(openapi): keep empty describable group tags
Apr 2, 2026
c21c340
feat(api): harden version header parsing
Apr 2, 2026
e23d8e9
feat(openapi): sort generated tags deterministically
Apr 2, 2026
6b075a2
feat(provider): add registry subset iterators
Apr 2, 2026
08a2d93
fix(openapi): fall back to Describe for nil iterators
Apr 2, 2026
50b6a91
refactor(openapi): remove unused route-group prep state
Apr 2, 2026
02082db
fix(openapi): document graphql cache headers
Apr 2, 2026
86c2150
feat(openapi): document SSE endpoint
Apr 2, 2026
085c57a
feat(cache): add byte-bounded eviction
Apr 2, 2026
b4d414b
feat(cmd/api): add SSE path spec flags
Apr 2, 2026
39bf094
feat(api): add configurable SSE path
Apr 2, 2026
ef641c7
feat(api): add configurable Swagger path
Apr 2, 2026
1fb55c9
fix(cmd/api): use CLI context for SDK generation
Apr 2, 2026
d803ac8
feat(api): add engine OpenAPI spec builder
Apr 2, 2026
41615bb
feat(api): document debug endpoints in OpenAPI spec
Apr 2, 2026
273bc3d
feat(openapi): document GraphQL playground endpoint
Apr 2, 2026
006a065
feat(openapi): document WebSocket endpoint
Apr 2, 2026
f0b2d8b
feat(cmd/api): expose runtime spec metadata flags
Apr 2, 2026
8e28b02
feat(cmd/api): add GraphQL playground spec flag
Apr 2, 2026
6ea0b26
feat(api-docs): document binary pixel responses
Apr 2, 2026
13f901b
fix(api-docs): describe 410 gone responses
Apr 2, 2026
f53617c
feat(openapi): document sunsetted operations as gone
Apr 2, 2026
85d6f6d
feat(openapi): default graphql path for playground specs
Apr 2, 2026
e8d5479
feat(openapi): include graphql tag for playground default path
Apr 2, 2026
c3143a5
feat(openapi): declare json schema dialect
Apr 2, 2026
d9ccd7c
feat(openapi): export swagger ui path metadata
Apr 2, 2026
e47b010
feat(api): add configurable websocket path
Apr 2, 2026
a469a78
feat(openapi): document GraphQL GET queries
Apr 2, 2026
5d28b8d
feat(openapi): export default swagger path metadata
Apr 2, 2026
76aa4c9
fix(openapi): preserve explicit swagger path metadata
Apr 2, 2026
d7ef361
fix(response): attach meta to all json responses
Apr 2, 2026
29f4c23
fix(api): preserve streaming response passthrough
Apr 2, 2026
9b24a46
feat(openapi): export runtime transport metadata
Apr 2, 2026
fe25614
feat(openapi): export configured transport paths
Apr 2, 2026
824fc2c
refactor(export): simplify spec writer handling
Apr 2, 2026
428552e
feat(api): add iterator-based spec group registration
Apr 2, 2026
b8fd020
refactor(cmd/api): thread swagger path through sdk spec builder
Apr 2, 2026
dd83421
fix(auth): exempt swagger ui path in authentik middleware
Apr 2, 2026
3b75dc1
fix(openapi): preserve example-only response schemas
Apr 2, 2026
8a23545
feat(api): expose transport config snapshot
Apr 2, 2026
30e6106
refactor(cmd/api): remove redundant sdk spec slice helper
Apr 2, 2026
b99e445
feat(openapi): document debug endpoint rate-limit headers
Apr 2, 2026
920c227
chore(api): validate OpenAPI implementation
Apr 2, 2026
8e1a424
feat(api): merge custom OpenAPI security schemes
Apr 2, 2026
fb7702d
feat(api): expose swagger security schemes
Apr 2, 2026
69dd16c
feat(api): expose reusable OpenAPI response components
Apr 2, 2026
b0549dc
fix(api): deep-clone swagger security schemes
Apr 2, 2026
22d600e
fix(api): normalise version config values
Apr 2, 2026
bc6a9ea
feat(cmd): expose spec security schemes
Apr 2, 2026
87a973a
feat(cmd/api): add SDK security scheme parity
Apr 2, 2026
bbee192
refactor(export): reduce spec file writer duplication
Apr 2, 2026
08cb138
fix(api): redirect swagger base path
Apr 2, 2026
ec94597
docs(api): add AX usage examples
Apr 2, 2026
ed58220
refactor(api): streamline spec export paths
Apr 2, 2026
8149b0a
refactor(api): centralise spec group iterator
Apr 2, 2026
8d92ee2
docs(cmd/api): add AX usage example to AddAPICommands
Apr 2, 2026
e6f2d12
refactor(cmd/api): centralise spec builder config
Apr 2, 2026
be7616d
fix(cmd/api): normalise spec export formats
Apr 2, 2026
d225fd3
feat(api): add openapi info summary support
Apr 2, 2026
d06f495
feat(api): expose swagger config snapshot
Apr 2, 2026
2fb2c69
feat(api): expose swagger path in config snapshot
Apr 2, 2026
1526454
feat(api): validate openapi parameter values
Apr 2, 2026
172a98f
fix(api): validate path parameter schemas
Apr 2, 2026
5e4cf1f
refactor(api): clarify cache limits api
Apr 2, 2026
4725b39
docs(api): align cache docs with explicit limits
Apr 2, 2026
51b176c
refactor(api): expose GraphQL transport snapshot flag
Apr 2, 2026
83d12d6
feat(api): expose swagger enabled transport flag
Apr 2, 2026
192f833
feat(api): expose websocket and sse transport flags
Apr 2, 2026
d40ff2c
fix(api): remove global openapi bearer security
Apr 2, 2026
57ff0d2
feat(api): expose swagger and graphql spec flags
Apr 2, 2026
4fc9361
feat(api): make spec builder nil-safe
Apr 2, 2026
c383d85
refactor(cmd/api): centralize spec builder config
Apr 2, 2026
71c1790
refactor(api): snapshot route metadata during spec build
Apr 2, 2026
78d16a7
docs(api): add AX example for SDK availability check
Apr 2, 2026
c4743a5
refactor(cmd/api): fail fast on sdk generator availability
Apr 2, 2026
592cdd3
fix(api): fail fast on sdk generator availability
Apr 2, 2026
f760ab6
feat(api): expose cache config snapshot
Apr 2, 2026
5de64a0
feat(api): add i18n config snapshot
Apr 2, 2026
f919e8a
feat(api): expose cache and i18n OpenAPI metadata
Apr 2, 2026
5c067b3
refactor(api): normalise config snapshots
Apr 2, 2026
814c1b6
feat(cmd/api): expose cache and i18n spec flags
Apr 2, 2026
3c2f551
feat(api): add GraphQL config snapshot
Apr 2, 2026
655faa1
refactor(api): add runtime config snapshot
Apr 2, 2026
ede71e2
feat(cmd/api): infer spec transport enablement from flags
Apr 2, 2026
ef51d9b
refactor(cmd/api): centralize spec flag binding
Apr 2, 2026
ec268c8
feat(api): default enabled transport paths in specs
Apr 2, 2026
0171f9a
refactor(api): assert swagger spec interface
Apr 2, 2026
eb18611
feat(api): snapshot authentik runtime config
Apr 2, 2026
f234fcb
feat(api): surface authentik metadata in specs
Apr 2, 2026
f6add24
fix(api): normalise authentik public paths
Apr 2, 2026
bfef723
fix(api): harden SDK generator inputs
Apr 2, 2026
a07896d
fix(cmd/api): normalise authentik spec public paths
Apr 2, 2026
a6693e1
feat(api): surface effective Authentik public paths in specs
Apr 2, 2026
0a299b7
fix(api): normalise empty Authentik public paths
Apr 2, 2026
0dc9695
feat(api): include graphql in runtime snapshots
Apr 2, 2026
eb77187
fix(openapi): document authentik public paths as public
Apr 2, 2026
579b27d
refactor(openapi): precompute authentik public paths
Apr 2, 2026
8301d4d
fix(cmd/api): ignore non-positive cache ttl in spec
Apr 2, 2026
d7290c5
fix(cmd/api): align cache metadata with runtime
Apr 2, 2026
5971951
fix(cmd/api): trim spec metadata inputs
Apr 2, 2026
2d09cc5
fix(api): add tracing AX examples
Apr 2, 2026
be43aa3
fix(openapi): deep clone route metadata
Apr 2, 2026
2b71c78
fix(openapi): ignore non-positive cache ttl in spec
Apr 2, 2026
0022931
fix(openapi): normalise spec builder metadata
Apr 2, 2026
1491e16
fix(api): normalise runtime metadata snapshots
Apr 2, 2026
76acb45
fix(api): surface GraphQL playground metadata
Apr 3, 2026
8b5e572
fix(api): expose OpenAPI client snapshots
Apr 3, 2026
0ec5f20
fix(api): add AX examples to client snapshots
Apr 3, 2026
3896896
fix(api): correct OpenAPI iterator examples
Apr 3, 2026
a3a1c20
fix(api): support custom GraphQL playground paths
Apr 3, 2026
8dd1525
fix(api): omit disabled graphql playground spec metadata
Apr 3, 2026
aea902e
fix(cmd/api): forward graphql playground path to sdk specs
Apr 3, 2026
194e7f6
fix: migrate module paths from forge.lthn.ai to dappco.re
Snider Apr 4, 2026
e54dd2e
fix(pr#2): address CodeRabbit major/critical review findings
Snider Apr 7, 2026
3723262
fix(pr#2): address CodeRabbit round 2 review findings
Snider Apr 7, 2026
e27006d
fix(pr#2): address CodeRabbit round 3 review findings
Snider Apr 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 128 additions & 22 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"errors"
"iter"
"net/http"
"reflect"
"slices"
"time"

Expand All @@ -24,23 +25,57 @@ const defaultAddr = ":8080"
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
wsHandler http.Handler
sseBroker *SSEBroker
swaggerEnabled bool
swaggerTitle string
swaggerDesc string
swaggerVersion string
pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
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,
Expand All @@ -52,27 +87,54 @@ func New(opts ...Option) (*Engine, error) {
}

// 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 all registered route groups.
// Groups returns a copy of all registered route groups.
//
// Example:
//
// groups := engine.Groups()
func (e *Engine) Groups() []RouteGroup {
return e.groups
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] {
return slices.Values(e.groups)
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 {
Expand All @@ -84,9 +146,16 @@ func (e *Engine) Channels() []string {
}

// 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 e.groups {
for _, g := range groups {
if sg, ok := g.(StreamGroup); ok {
for _, c := range sg.Channels() {
if !yield(c) {
Expand All @@ -100,12 +169,22 @@ func (e *Engine) ChannelsIter() iter.Seq[string] {

// 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,
Expand All @@ -120,8 +199,18 @@ func (e *Engine) Serve(ctx context.Context) error {
close(errCh)
}()

// Block until context is cancelled.
<-ctx.Done()
// 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)
Expand All @@ -139,7 +228,7 @@ func (e *Engine) Serve(ctx context.Context) error {
// user-supplied middleware, the health endpoint, and all registered route groups.
func (e *Engine) build() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
r.Use(recoveryMiddleware())

// Apply user-supplied middleware after recovery but before routes.
for _, mw := range e.middlewares {
Expand All @@ -153,18 +242,21 @@ func (e *Engine) build() *gin.Engine {

// 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("/ws", wrapWSHandler(e.wsHandler))
r.GET(resolveWSPath(e.wsPath), wrapWSHandler(e.wsHandler))
}

// Mount SSE endpoint if configured.
if e.sseBroker != nil {
r.GET("/events", e.sseBroker.Handler())
r.GET(resolveSSEPath(e.ssePath), e.sseBroker.Handler())
}

// Mount GraphQL endpoint if configured.
Expand All @@ -174,7 +266,7 @@ func (e *Engine) build() *gin.Engine {

// Mount Swagger UI if enabled.
if e.swaggerEnabled {
registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
registerSwagger(r, e, e.groups)
}

// Mount pprof profiling endpoints if enabled.
Expand All @@ -189,3 +281,17 @@ func (e *Engine) build() *gin.Engine {

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
}
}
96 changes: 96 additions & 0 deletions api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ func (h *healthGroup) RegisterRoutes(rg *gin.RouterGroup) {
})
}

type panicGroup struct{}

func (p *panicGroup) Name() string { return "panic" }
func (p *panicGroup) BasePath() string { return "/panic" }
func (p *panicGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/boom", func(c *gin.Context) {
panic("boom")
})
}

// ── New ─────────────────────────────────────────────────────────────────

func TestNew_Good(t *testing.T) {
Expand Down Expand Up @@ -85,6 +95,28 @@ func TestRegister_Good_MultipleGroups(t *testing.T) {
}
}

func TestRegister_Good_GroupsReturnsCopy(t *testing.T) {
e, _ := api.New()
first := &healthGroup{}
second := &stubGroup{}
e.Register(first)
e.Register(second)

groups := e.Groups()
groups[0] = nil

fresh := e.Groups()
if fresh[0] == nil {
t.Fatal("expected Groups to return a copy, but engine state was mutated")
}
if fresh[0].Name() != first.Name() {
t.Fatalf("expected first group name %q, got %q", first.Name(), fresh[0].Name())
}
if fresh[1].Name() != "stub" {
t.Fatalf("expected second group name %q, got %q", "stub", fresh[1].Name())
}
}

// ── Handler ─────────────────────────────────────────────────────────────

func TestHandler_Good_HealthEndpoint(t *testing.T) {
Expand Down Expand Up @@ -149,6 +181,41 @@ func TestHandler_Bad_NotFound(t *testing.T) {
}
}

func TestHandler_Bad_PanicReturnsEnvelope(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRequestID())
e.Register(&panicGroup{})

h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/panic/boom", nil)
h.ServeHTTP(w, req)

if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", w.Code)
}

var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false")
}
if resp.Error == nil {
t.Fatal("expected Error to be non-nil")
}
if resp.Error.Code != "internal_server_error" {
t.Fatalf("expected error code=%q, got %q", "internal_server_error", resp.Error.Code)
}
if resp.Error.Message != "Internal server error" {
t.Fatalf("expected error message=%q, got %q", "Internal server error", resp.Error.Message)
}
if got := w.Header().Get("X-Request-ID"); got == "" {
t.Fatal("expected X-Request-ID header to survive panic recovery")
}
}

// ── Serve + graceful shutdown ───────────────────────────────────────────

func TestServe_Good_GracefulShutdown(t *testing.T) {
Expand Down Expand Up @@ -202,3 +269,32 @@ func TestServe_Good_GracefulShutdown(t *testing.T) {
t.Fatal("Serve did not return within 5 seconds after context cancellation")
}
}

func TestServe_Bad_ReturnsListenErrorBeforeCancel(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to reserve port: %v", err)
}
addr := ln.Addr().String()
defer ln.Close()

e, _ := api.New(api.WithAddr(addr))

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

errCh := make(chan error, 1)
go func() {
errCh <- e.Serve(ctx)
}()

select {
case serveErr := <-errCh:
if serveErr == nil {
t.Fatal("expected Serve to return a listen error, got nil")
}
case <-time.After(2 * time.Second):
cancel()
t.Fatal("Serve did not return promptly after listener failure")
}
}
Loading