api/docs/architecture.md
Virgil 4725b39049 docs(api): align cache docs with explicit limits
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:36:59 +00:00

23 KiB

title description
Architecture Internals of the go-api REST framework -- Engine, RouteGroup, middleware composition, response envelope, authentication, real-time transports, OpenAPI generation, and SDK codegen.

Architecture

This document explains how go-api works internally. It covers every major subsystem, the key types, and the data flow from incoming HTTP request to outgoing JSON response.


1. Engine

1.1 The Engine struct

Engine is the central container. It holds the listen address, the ordered list of registered route groups, the middleware chain, and all optional integrations:

type Engine struct {
    addr           string
    groups         []RouteGroup
    middlewares    []gin.HandlerFunc
    wsHandler      http.Handler
    sseBroker      *SSEBroker
    swaggerEnabled bool
    swaggerTitle   string
    swaggerDesc    string
    swaggerVersion string
    swaggerExternalDocsDescription string
    swaggerExternalDocsURL         string
    pprofEnabled   bool
    expvarEnabled  bool
    graphql        *graphqlConfig
}

All fields are private. Configuration happens exclusively through Option functions passed to New().

1.2 Construction

New() applies functional options and returns a configured engine. The default listen address is :8080. No middleware is added automatically beyond Gin's built-in panic recovery; every feature requires an explicit With*() option:

engine, err := api.New(
    api.WithAddr(":9000"),
    api.WithBearerAuth("secret"),
    api.WithCORS("*"),
    api.WithRequestID(),
    api.WithSlog(nil),
    api.WithSwagger("My API", "Description", "1.0.0"),
)

After construction, call engine.Register(group) to add route groups, then either engine.Serve(ctx) to start an HTTP server or engine.Handler() to obtain an http.Handler for use with httptest or an external server.

1.3 Build sequence

Engine.build() is called internally by Handler() and Serve(). It assembles a fresh *gin.Engine each time. The order is fixed:

  1. gin.Recovery() -- panic recovery (always first).
  2. User middleware in registration order -- all With*() options that append to e.middlewares.
  3. Built-in GET /health endpoint -- always present, returns {"success":true,"data":"healthy"}.
  4. Route groups -- each mounted at its BasePath().
  5. WebSocket handler at GET /ws -- when WithWSHandler() was called.
  6. SSE broker at GET /events -- when WithSSE() was called.
  7. GraphQL endpoint -- when WithGraphQL() was called.
  8. Swagger UI at GET /swagger/*any -- when WithSwagger() was called.
  9. pprof endpoints at GET /debug/pprof/* -- when WithPprof() was called.
  10. expvar endpoint at GET /debug/vars -- when WithExpvar() was called.

1.4 Graceful shutdown

Serve() starts an http.Server in a goroutine and blocks on ctx.Done(). When the context is cancelled, a 10-second shutdown deadline is applied. In-flight requests complete or time out before the server exits. Any listen error that occurred before shutdown is returned to the caller.

1.5 Iterators

Engine provides iterator methods following Go 1.23+ conventions:

  • GroupsIter() returns iter.Seq[RouteGroup] over all registered groups.
  • ChannelsIter() returns iter.Seq[string] over WebSocket channel names from groups that implement StreamGroup.

2. RouteGroup, StreamGroup, and DescribableGroup

Three interfaces form the extension point model:

// RouteGroup is the minimum interface. All subsystems implement this.
type RouteGroup interface {
    Name() string
    BasePath() string
    RegisterRoutes(rg *gin.RouterGroup)
}

// StreamGroup is optionally implemented by groups that publish WebSocket channels.
type StreamGroup interface {
    Channels() []string
}

// DescribableGroup extends RouteGroup with OpenAPI metadata.
// Groups implementing this have their endpoints included in the generated spec.
type DescribableGroup interface {
    RouteGroup
    Describe() []RouteDescription
}

RouteDescription carries the HTTP method, path (relative to BasePath()), summary, description, tags, and JSON Schema maps for the request body and response data:

type RouteDescription struct {
    Method      string
    Path        string
    Summary     string
    Description string
    Tags        []string
    Deprecated  bool
    StatusCode  int
    Parameters  []ParameterDescription
    RequestBody map[string]any
    Response    map[string]any
}

Engine.Channels() iterates all registered groups and collects channel names from those that implement StreamGroup. This list is used when initialising a WebSocket hub.


3. Middleware Stack

All middleware options append to Engine.middlewares in the order they are passed to New(). They execute after gin.Recovery() but before any route handler. The Option type is simply func(*Engine).

Complete option reference

Option Purpose Key detail
WithAddr(addr) Listen address Default :8080
WithBearerAuth(token) Static bearer token authentication Skips /health and /swagger
WithRequestID() X-Request-ID propagation Preserves client-supplied IDs; generates 16-byte hex otherwise
WithResponseMeta() Request metadata in JSON envelopes Merges request_id and duration into standard responses
WithCORS(origins...) CORS policy "*" enables AllowAllOrigins; 12-hour MaxAge
WithRateLimit(limit) Per-IP token-bucket rate limiting 429 Too Many Requests; X-RateLimit-* on success; Retry-After on rejection; zero or negative disables
WithMiddleware(mw...) Arbitrary Gin middleware Escape hatch for custom middleware
WithStatic(prefix, root) Static file serving Directory listing disabled
WithWSHandler(h) WebSocket at /ws Wraps any http.Handler
WithAuthentik(cfg) Authentik forward-auth + OIDC JWT Permissive; populates context, never rejects
WithSwagger(title, desc, ver) Swagger UI at /swagger/ Runtime spec via SpecBuilder
WithSwaggerTermsOfService(url) OpenAPI terms of service metadata Populates the Swagger spec info block without manual SpecBuilder wiring
WithSwaggerContact(name, url, email) OpenAPI contact metadata Populates the Swagger spec info block without manual SpecBuilder wiring
WithSwaggerServers(servers...) OpenAPI server metadata Feeds the runtime Swagger spec and exported docs
WithSwaggerLicense(name, url) OpenAPI licence metadata Populates the Swagger spec info block without manual SpecBuilder wiring
WithSwaggerExternalDocs(description, url) OpenAPI external documentation metadata Populates the top-level externalDocs block without manual SpecBuilder wiring
WithPprof() Go profiling at /debug/pprof/ WARNING: do not expose in production without authentication
WithExpvar() Runtime metrics at /debug/vars WARNING: do not expose in production without authentication
WithSecure() Security headers HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer
WithGzip(level...) Gzip response compression Default compression if level omitted
WithBrotli(level...) Brotli response compression Writer pool for efficiency; default compression if level omitted
WithSlog(logger) Structured request logging Falls back to slog.Default() if nil
WithTimeout(d) Per-request deadline 504 with standard error envelope on timeout
WithCache(ttl) In-memory GET response caching Compatibility wrapper for WithCacheLimits(ttl, 0, 0); X-Cache: HIT header on cache hits; 2xx only
WithCacheLimits(ttl, maxEntries, maxBytes) In-memory GET response caching with explicit bounds Clearer cache configuration when eviction policy should be self-documenting
WithSessions(name, secret) Cookie-backed server sessions gin-contrib/sessions with cookie store
WithAuthz(enforcer) Casbin policy-based authorisation Subject from HTTP Basic Auth; 403 on deny
WithHTTPSign(secrets, opts...) HTTP Signatures verification draft-cavage-http-signatures; 401/400 on failure
WithSSE(broker) Server-Sent Events at /events ?channel= query parameter filtering
WithLocation() Reverse proxy header detection X-Forwarded-Proto / X-Forwarded-Host
WithI18n(cfg...) Accept-Language locale detection BCP 47 matching via golang.org/x/text/language
WithTracing(name, opts...) OpenTelemetry distributed tracing otelgin + W3C traceparent header propagation
WithGraphQL(schema, opts...) GraphQL endpoint gqlgen ExecutableSchema; optional playground UI

Bearer authentication flow

bearerAuthMiddleware validates the Authorization: Bearer <token> header. Requests to paths in the skip list (/health, /swagger) pass through without authentication. Missing or invalid tokens produce a 401 Unauthorised response using the standard error envelope.

Request ID flow

requestIDMiddleware checks for an incoming X-Request-ID header. If present, the value is preserved. Otherwise, a cryptographically random 16-byte hex string is generated. The ID is stored in the Gin context under the key "request_id" and set as an X-Request-ID response header.


4. Response Envelope

All API responses use a single generic envelope:

type Response[T any] struct {
    Success bool   `json:"success"`
    Data    T      `json:"data,omitempty"`
    Error   *Error `json:"error,omitempty"`
    Meta    *Meta  `json:"meta,omitempty"`
}

Supporting types:

type Error struct {
    Code    string `json:"code"`
    Message string `json:"message"`
    Details any    `json:"details,omitempty"`
}

type Meta struct {
    RequestID string `json:"request_id,omitempty"`
    Duration  string `json:"duration,omitempty"`
    Page      int    `json:"page,omitempty"`
    PerPage   int    `json:"per_page,omitempty"`
    Total     int    `json:"total,omitempty"`
}

Constructor helpers

Helper Produces
OK(data) {"success":true,"data":...}
Fail(code, message) {"success":false,"error":{"code":"...","message":"..."}}
FailWithDetails(code, message, details) Same as Fail with an additional details field
Paginated(data, page, perPage, total) {"success":true,"data":...,"meta":{"page":...,"per_page":...,"total":...}}

All handlers should use these helpers rather than constructing Response[T] manually. This guarantees a consistent envelope across every route group.


5. Authentik Integration

The WithAuthentik() option installs a permissive identity middleware that runs on every non-public request. It has two extraction paths:

Path 1 -- Forward-auth headers (TrustedProxy: true)

When a reverse proxy (e.g. Traefik) is configured with Authentik forward-auth, it injects headers: X-authentik-username, X-authentik-email, X-authentik-name, X-authentik-uid, X-authentik-groups (pipe-separated), X-authentik-entitlements (pipe-separated), and X-authentik-jwt. The middleware reads these and populates an AuthentikUser in the Gin context.

Path 2 -- OIDC JWT validation

For direct API clients that present a Bearer token, the middleware validates the JWT against the configured OIDC issuer and client ID. Providers are cached by issuer URL to avoid repeated discovery requests.

Fail-open behaviour

In both paths, if extraction fails the request continues unauthenticated. The middleware never rejects requests. Handlers check identity with:

user := api.GetUser(c) // returns nil when unauthenticated

Route guards

For protected routes, apply guards as Gin middleware on individual routes:

rg.GET("/private", api.RequireAuth(), handler)          // 401 if no user
rg.GET("/admin",   api.RequireGroup("admins"), handler) // 403 if wrong group

RequireAuth() returns 401 when GetUser(c) is nil. RequireGroup(group) returns 401 when no user is present, or 403 when the user lacks the specified group membership.

AuthentikUser type

type AuthentikUser struct {
    Username     string   `json:"username"`
    Email        string   `json:"email"`
    Name         string   `json:"name"`
    UID          string   `json:"uid"`
    Groups       []string `json:"groups,omitempty"`
    Entitlements []string `json:"entitlements,omitempty"`
    JWT          string   `json:"-"`
}

The HasGroup(group string) bool method provides a convenience check for group membership.

Configuration

type AuthentikConfig struct {
    Issuer       string   // OIDC issuer URL
    ClientID     string   // OAuth2 client identifier
    TrustedProxy bool     // Whether to read X-authentik-* headers
    PublicPaths  []string // Additional paths exempt from header extraction
}

/health and /swagger are always public. Additional paths may be specified via PublicPaths.


6. WebSocket and Server-Sent Events

WebSocket

WithWSHandler(h) mounts any http.Handler at GET /ws. The handler is responsible for upgrading the connection. The intended pairing is a WebSocket hub (e.g. from go-ws):

hub := ws.NewHub()
go hub.Run(ctx)
engine, _ := api.New(api.WithWSHandler(hub.Handler()))

Groups implementing StreamGroup declare channel names, which Engine.Channels() aggregates into a single slice.

Server-Sent Events

SSEBroker manages persistent SSE connections at GET /events. Clients optionally subscribe to a named channel via the ?channel=<name> query parameter. Clients without a channel parameter receive events on all channels.

broker := api.NewSSEBroker()
engine, _ := api.New(api.WithSSE(broker))

// Publish from anywhere:
broker.Publish("deployments", "deploy.started", payload)

Key implementation details:

  • Each client has a 64-event buffered channel. Overflow events are dropped without blocking the publisher.
  • SSEBroker.ClientCount() returns the number of currently connected clients.
  • SSEBroker.Drain() signals all clients to disconnect, useful during graceful shutdown.
  • The response is streamed with Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, and X-Accel-Buffering: no headers.
  • Data payloads are JSON-encoded before being written as SSE data: fields.

7. GraphQL

WithGraphQL() mounts a gqlgen ExecutableSchema at /graphql (or a custom path via WithGraphQLPath()). An optional WithPlayground() adds the interactive GraphQL Playground at {path}/playground.

engine, _ := api.New(
    api.WithGraphQL(schema,
        api.WithPlayground(),
        api.WithGraphQLPath("/gql"),
    ),
)

The endpoint accepts all HTTP methods (POST for queries and mutations, GET for playground redirects and introspection). The GraphQL handler is created via gqlgen's handler.NewDefaultServer().


8. Response Caching

WithCacheLimits(ttl, maxEntries, maxBytes) installs a URL-keyed in-memory response cache scoped to GET requests:

engine, _ := api.New(api.WithCacheLimits(5*time.Minute, 100, 10<<20))
  • Only successful 2xx responses are cached.
  • Non-GET methods pass through uncached.
  • Cached responses are served with an X-Cache: HIT header.
  • Expired entries are evicted lazily on the next access for the same key.
  • The cache is not shared across Engine instances.
  • WithCache(ttl) remains available as a compatibility wrapper for callers that do not need to spell out the bounds.
  • Passing non-positive values to WithCacheLimits leaves that limit unbounded.

The implementation uses a cacheWriter that wraps gin.ResponseWriter to intercept and capture the response body and status code for storage.


9. Brotli Compression

WithBrotli(level...) adds Brotli response compression. The middleware checks the Accept-Encoding header for br support before compressing.

Key implementation details:

  • A sync.Pool of brotli.Writer instances is used to avoid allocation per request.
  • Error responses (4xx and above) bypass compression and are sent uncompressed.
  • Three compression level constants are exported: BrotliBestSpeed, BrotliBestCompression, and BrotliDefaultCompression.

10. Internationalisation

WithI18n(cfg) parses the Accept-Language header on every request and stores the resolved BCP 47 locale tag in the Gin context. The golang.org/x/text/language matcher handles quality-weighted negotiation and script/region subtag matching.

engine, _ := api.New(
    api.WithI18n(api.I18nConfig{
        DefaultLocale: "en",
        Supported:     []string{"en", "fr", "de"},
        Messages: map[string]map[string]string{
            "en": {"greeting": "Hello"},
            "fr": {"greeting": "Bonjour"},
        },
    }),
)

Handlers retrieve the locale and optional localised messages:

locale := api.GetLocale(c)               // e.g. "en", "fr"
msg, ok := api.GetMessage(c, "greeting") // from configured Messages map

The built-in message map is a lightweight bridge. The go-i18n grammar engine is the intended replacement for production-grade localisation.


11. OpenTelemetry Tracing

WithTracing(serviceName) adds otelgin middleware that creates a span for each request, tagged with HTTP method, route template, and response status code. Trace context is propagated via the W3C traceparent header.

NewTracerProvider(exporter) is a convenience helper for tests and simple deployments that constructs a synchronous TracerProvider and installs it globally:

tp := api.NewTracerProvider(exporter)
defer tp.Shutdown(ctx)

engine, _ := api.New(api.WithTracing("my-service"))

Production deployments should build a batching provider with appropriate resource attributes and span processors.


12. OpenAPI Specification Generation

SpecBuilder

SpecBuilder generates an OpenAPI 3.1 JSON document from registered route groups:

builder := &api.SpecBuilder{
    Title:       "My API",
    Description: "Service description",
    Version:     "1.0.0",
}
data, err := builder.Build(engine.Groups())

The built document includes:

  • The GET /health endpoint under the system tag.
  • One path entry per RouteDescription returned by DescribableGroup.Describe().
  • #/components/schemas/Error and #/components/schemas/Meta shared schemas.
  • All response bodies wrapped in the Response[T] envelope schema.
  • Tags derived from every registered group's Name().

Groups that implement RouteGroup but not DescribableGroup contribute a tag but no paths.

Export

Two convenience functions write the spec to an io.Writer or directly to a file:

// Write JSON or YAML to a writer:
api.ExportSpec(os.Stdout, "yaml", builder, engine.Groups())

// Write to a file (parent directories created automatically):
api.ExportSpecToFile("./api/openapi.yaml", "yaml", builder, engine.Groups())

Swagger UI

When WithSwagger() is active, the spec is built lazily on first access by a swaggerSpec wrapper that satisfies the swag.Spec interface. It is registered in the global swag registry with a unique sequence-based instance name (via atomic.Uint64), so multiple Engine instances in the same process do not collide.


13. ToolBridge

ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths. It implements both RouteGroup and DescribableGroup. This is the primary mechanism for exposing MCP tool descriptors as a REST API.

bridge := api.NewToolBridge("/v1/tools")
bridge.Add(api.ToolDescriptor{
    Name:        "file_read",
    Description: "Read a file",
    Group:       "files",
    InputSchema: map[string]any{"type": "object", "properties": ...},
}, fileReadHandler)

engine.Register(bridge)

Each registered tool becomes a POST /v1/tools/{tool_name} endpoint. The bridge provides:

  • Tools() / ToolsIter() -- enumerate registered tool descriptors.
  • Describe() / DescribeIter() -- generate RouteDescription entries for OpenAPI.

ToolDescriptor carries:

type ToolDescriptor struct {
    Name         string         // Tool name (becomes POST path segment)
    Description  string         // Human-readable description
    Group        string         // OpenAPI tag group
    InputSchema  map[string]any // JSON Schema for request body
    OutputSchema map[string]any // JSON Schema for response data (optional)
}

14. SDK Codegen

SDKGenerator wraps openapi-generator-cli to generate client SDKs from an exported OpenAPI spec:

gen := &api.SDKGenerator{
    SpecPath:    "./api/openapi.yaml",
    OutputDir:   "./sdk",
    PackageName: "myapi",
}
if gen.Available() {
    _ = gen.Generate(ctx, "typescript-fetch")
    _ = gen.Generate(ctx, "python")
}

Supported target languages (11 total): csharp, go, java, kotlin, php, python, ruby, rust, swift, typescript-axios, typescript-fetch.

  • SupportedLanguages() returns the full list in sorted order.
  • SupportedLanguagesIter() returns an iter.Seq[string] over the same list.
  • SDKGenerator.Available() checks whether openapi-generator-cli is on PATH.

15. CLI Subcommands

The cmd/api/ package registers two CLI subcommands under the core api namespace:

core api spec

Generates an OpenAPI 3.1 specification from registered route groups.

Flag Short Default Description
--output -o (stdout) Write spec to file
--format -f json Output format: json or yaml
--title -t Lethean Core API API title
--description -d Lethean Core API API description
--version -V 1.0.0 API version
--server -S (none) Comma-separated OpenAPI server URL(s)

core api sdk

Generates client SDKs from an OpenAPI spec using openapi-generator-cli.

Flag Short Default Description
--lang -l (required) Target language(s), comma-separated
--output -o ./sdk Output directory
--spec -s (auto-generated) Path to existing OpenAPI spec
--package -p lethean Package name for generated SDK
--title -t Lethean Core API API title in generated spec
--description -d Lethean Core API API description in generated spec
--version -V 1.0.0 API version in generated spec
--server -S (none) Comma-separated OpenAPI server URL(s)

16. Data Flow Summary

HTTP Request
    |
    v
gin.Recovery()            -- panic recovery
    |
    v
User middleware chain     -- WithBearerAuth, WithCORS, WithRequestID, WithAuthentik, etc.
    |                        (in registration order)
    v
Route matching            -- /health (built-in) or BasePath() + route from RouteGroup
    |
    v
Handler function          -- uses api.OK(), api.Fail(), api.Paginated()
    |
    v
Response[T] envelope      -- {"success": bool, "data": T, "error": Error, "meta": Meta}
    |
    v
HTTP Response

Real-time transports (WebSocket at /ws, SSE at /events) and development endpoints (Swagger at /swagger/, pprof at /debug/pprof/, expvar at /debug/vars) are mounted alongside the route groups during the build phase.