2026-03-14 10:03:29 +00:00
---
title: Architecture
description: Internals of the go-api REST framework -- Engine, RouteGroup, middleware composition, response envelope, authentication, real-time transports, OpenAPI generation, and SDK codegen.
---
<!-- SPDX - License - Identifier: EUPL - 1.2 -->
# 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:
```go
type Engine struct {
addr string
groups []RouteGroup
middlewares []gin.HandlerFunc
wsHandler http.Handler
sseBroker *SSEBroker
swaggerEnabled bool
swaggerTitle string
swaggerDesc string
swaggerVersion string
2026-04-01 20:47:38 +00:00
swaggerExternalDocsDescription string
swaggerExternalDocsURL string
2026-03-14 10:03:29 +00:00
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:
```go
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:
```go
// 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:
```go
type RouteDescription struct {
Method string
Path string
Summary string
Description string
Tags []string
2026-04-01 20:40:53 +00:00
Deprecated bool
2026-04-01 20:04:34 +00:00
StatusCode int
2026-04-01 19:12:51 +00:00
Parameters []ParameterDescription
2026-03-14 10:03:29 +00:00
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 |
2026-04-01 14:00:04 +00:00
| `WithResponseMeta()` | Request metadata in JSON envelopes | Merges `request_id` and `duration` into standard responses |
2026-03-14 10:03:29 +00:00
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins` ; 12-hour `MaxAge` |
2026-04-01 16:01:09 +00:00
| `WithRateLimit(limit)` | Per-IP token-bucket rate limiting | `429 Too Many Requests` ; `X-RateLimit-*` on success; `Retry-After` on rejection; zero or negative disables |
2026-03-14 10:03:29 +00:00
| `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` |
2026-04-01 20:44:01 +00:00
| `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 |
2026-04-01 16:07:16 +00:00
| `WithSwaggerServers(servers...)` | OpenAPI server metadata | Feeds the runtime Swagger spec and exported docs |
2026-04-01 20:26:55 +00:00
| `WithSwaggerLicense(name, url)` | OpenAPI licence metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
2026-04-01 20:47:38 +00:00
| `WithSwaggerExternalDocs(description, url)` | OpenAPI external documentation metadata | Populates the top-level `externalDocs` block without manual `SpecBuilder` wiring |
2026-03-14 10:03:29 +00:00
| `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 |
2026-04-02 08:36:59 +00:00
| `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 |
2026-03-14 10:03:29 +00:00
| `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:
```go
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:
```go
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:
```go
user := api.GetUser(c) // returns nil when unauthenticated
```
### Route guards
For protected routes, apply guards as Gin middleware on individual routes:
```go
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
```go
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
```go
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):
```go
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.
```go
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` .
```go
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
2026-04-02 08:36:59 +00:00
`WithCacheLimits(ttl, maxEntries, maxBytes)` installs a URL-keyed in-memory response cache scoped to GET requests:
```go
engine, _ := api.New(api.WithCacheLimits(5*time.Minute, 100, 10< < 20 ) )
```
2026-03-14 10:03:29 +00:00
- 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.
2026-04-02 08:36:59 +00:00
- `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.
2026-03-14 10:03:29 +00:00
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.
```go
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:
```go
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:
```go
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:
```go
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:
```go
// 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.
```go
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:
```go
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:
```go
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 |
2026-04-01 19:09:59 +00:00
| `--description` | `-d` | `Lethean Core API` | API description |
2026-03-14 10:03:29 +00:00
| `--version` | `-V` | `1.0.0` | API version |
2026-04-01 19:09:59 +00:00
| `--server` | `-S` | (none) | Comma-separated OpenAPI server URL(s) |
2026-03-14 10:03:29 +00:00
### `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 |
2026-04-01 19:09:59 +00:00
| `--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) |
2026-03-14 10:03:29 +00:00
---
## 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.