go-api/docs/architecture.md
Snider 0d3479839d docs: add architecture, development, and history documentation
Standard docs trio matching the pattern used across all Go ecosystem repos.
Covers Engine architecture, 21 With*() options, RouteGroup interfaces,
OpenAPI generation pipeline, and three-phase development history (176 tests).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 01:57:44 +00:00

498 lines
18 KiB
Markdown

<!-- SPDX-License-Identifier: EUPL-1.2 -->
# go-api Architecture
**Module**: `forge.lthn.ai/core/go-api`
**Language**: Go 1.25
**Licence**: EUPL-1.2
**LOC**: ~2.1 K non-test, 176 tests passing
---
## 1. Overview
`go-api` is the Gin-based REST framework for the Lethean Go ecosystem. Subsystems across the
ecosystem plug into a central `Engine` via the `RouteGroup` interface, and go-api handles the HTTP
plumbing: middleware composition, response envelopes, WebSocket and SSE integration, GraphQL,
Authentik identity, OpenAPI spec generation, and SDK codegen.
### Position in the Lethean Stack
```
AI Clients / Browsers / SDK consumers
| HTTP / WebSocket / SSE
v
[ go-api Engine ] ← this module
| | |
| | └─ OpenAPI spec → SDKGenerator → openapi-generator-cli
| └─ ToolBridge ──→ go-ai / go-ml / go-rag route groups
└─ RouteGroups ─────────→ any package that implements RouteGroup
Core CLI or standalone main() bootstraps and wires everything
```
go-api is a library. It contains no `main` package. Callers construct an `Engine`, register route
groups, and call `Serve()`.
---
## 2. Engine
### 2.1 The Engine Struct
`Engine` is the central container. It owns 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
pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
}
```
### 2.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"),
)
engine.Register(myRoutes)
engine.Serve(ctx)
```
### 2.3 Build Sequence
`Engine.build()` (called internally by `Handler()` and `Serve()`) 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.
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.
### 2.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.
---
## 3. RouteGroup / DescribableGroup Interfaces
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 will appear in the generated spec.
type DescribableGroup interface {
RouteGroup
Describe() []RouteDescription
}
```
`RouteDescription` carries the HTTP method, path, summary, description, tags, and JSON Schema
maps for the request body and response data. The `SpecBuilder` consumes these to generate the
OpenAPI 3.1 paths object.
`Engine.Channels()` iterates all registered groups and collects channel names from those that
implement `StreamGroup`. This list is used when initialising a WebSocket hub.
---
## 4. Middleware Stack — 21 With*() Options
All middleware options append to `Engine.middlewares` in the order they are passed to `New()`.
They are applied after `gin.Recovery()` but before any route handler.
| Option | Purpose | Key detail |
|--------|---------|-----------|
| `WithAddr(addr)` | Listen address | Default `:8080` |
| `WithBearerAuth(token)` | Static bearer token auth | Skips `/health` and `/swagger` |
| `WithRequestID()` | X-Request-ID propagation | Preserves client-supplied IDs |
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` |
| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch |
| `WithStatic(prefix, root)` | Static file serving | Directory listing disabled |
| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` |
| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC | Permissive; sets context, never rejects |
| `WithSwagger(title, desc, ver)` | Swagger UI at `/swagger/` | Runtime spec via `SpecBuilder` |
| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production |
| `WithExpvar()` | Runtime metrics at `/debug/vars` | WARNING: do not expose in production |
| `WithSecure()` | Security headers | HSTS 1yr, X-Frame-Options DENY, nosniff |
| `WithGzip(level...)` | Gzip response compression | Default compression if level omitted |
| `WithBrotli(level...)` | Brotli response compression | Default compression if level omitted |
| `WithSlog(logger)` | Structured request logging | Falls back to `slog.Default()` if nil |
| `WithTimeout(d)` | Per-request deadline | 504 error envelope on timeout |
| `WithCache(ttl)` | In-memory GET response caching | `X-Cache: HIT` on cache hits |
| `WithSessions(name, secret)` | Cookie-backed server sessions | gin-contrib/sessions |
| `WithAuthz(enforcer)` | Casbin policy-based authorisation | Subject from HTTP Basic Auth |
| `WithHTTPSign(secrets, opts...)` | HTTP Signatures verification | draft-cavage-http-signatures |
| `WithSSE(broker)` | Server-Sent Events at `/events` | `?channel=` filtering |
| `WithLocation()` | Reverse proxy header detection | X-Forwarded-Proto / X-Forwarded-Host |
| `WithI18n(cfg...)` | Accept-Language locale detection | BCP 47 matching via x/text/language |
| `WithTracing(name, opts...)` | OpenTelemetry distributed tracing | otelgin + W3C traceparent |
| `WithGraphQL(schema, opts...)` | GraphQL endpoint | gqlgen; optional playground UI |
---
## 5. Response[T] Envelope
All API responses share 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"`
}
```
Constructor helpers:
| Helper | Usage |
|--------|-------|
| `OK(data)` | Successful response with typed data |
| `Fail(code, message)` | Error response with code and message |
| `FailWithDetails(code, message, details)` | Error with additional detail payload |
| `Paginated(data, page, perPage, total)` | Successful response with pagination meta |
`Meta` carries `request_id`, `duration`, `page`, `per_page`, and `total`. `Error` carries `code`,
`message`, and an optional `details` field for structured validation errors.
---
## 6. 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 Traefik or another reverse proxy is configured with Authentik forward-auth, it injects
`X-authentik-username`, `X-authentik-email`, `X-authentik-name`, `X-authentik-uid`,
`X-authentik-groups` (pipe-separated), `X-authentik-entitlements`, and `X-authentik-jwt` headers.
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. Providers are cached by issuer URL to avoid repeated discovery requests.
In both paths, if extraction fails the request continues unauthenticated (fail-open). Handlers
check identity with:
```go
user := api.GetUser(c) // returns nil when unauthenticated
```
For protected routes, apply guards:
```go
rg.GET("/private", api.RequireAuth(), handler) // 401 if no user
rg.GET("/admin", api.RequireGroup("admins"), handler) // 403 if wrong group
```
`AuthentikConfig.PublicPaths` lists additional paths exempt from header extraction.
`/health` and paths starting with `/swagger` are always exempt.
---
## 7. WebSocket and SSE
### WebSocket
`WithWSHandler(h)` mounts any `http.Handler` at `GET /ws`. The intended pairing is a `go-ws` hub:
```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.
### Server-Sent Events
`SSEBroker` manages persistent SSE connections. Clients connect to `GET /events` and optionally
subscribe to a named channel via `?channel=<name>`. Clients with no channel parameter receive
events on all channels.
```go
broker := api.NewSSEBroker()
engine, _ := api.New(api.WithSSE(broker))
// In a handler or background goroutine:
broker.Publish("deployments", "deploy.started", payload)
```
Each client has a 64-event buffer. Overflow events are dropped without blocking the publisher.
`SSEBroker.Drain()` signals all clients to disconnect on graceful shutdown.
---
## 8. GraphQL
`WithGraphQL()` mounts a gqlgen `ExecutableSchema` at `/graphql` (or a custom path via
`WithGraphQLPath()`). An optional `WithPlayground()` adds the interactive 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).
---
## 9. OpenAPI Spec Generation
### SpecBuilder
`SpecBuilder` generates an OpenAPI 3.1 JSON document from registered route groups:
```go
builder := &api.SpecBuilder{
Title: "My API",
Description: "...",
Version: "1.0.0",
}
data, _ := 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 dirs 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 and registered in the global `swag` registry with a unique sequence-based instance name.
Multiple `Engine` instances in the same process (common in tests) do not collide in the registry.
---
## 10. ToolBridge
`ToolBridge` converts tool descriptors — as produced by go-ai, go-ml, and similar packages — into
`POST /{tool_name}` REST endpoints, and implements `DescribableGroup` so those endpoints appear in
the generated OpenAPI spec.
```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. `ToolBridge.Tools()` returns
the full list of descriptors, making it easy to enumerate tools from the outside.
---
## 11. SDK Codegen
`SDKGenerator` wraps `openapi-generator-cli` to generate client SDKs from an exported 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: `go`, `typescript-fetch`, `typescript-axios`, `python`, `java`,
`csharp`, `ruby`, `swift`, `kotlin`, `rust`, `php`.
`SupportedLanguages()` returns the full list in sorted order. `SDKGenerator.Available()` reports
whether `openapi-generator-cli` is on `PATH` without running it.
---
## 12. In-Memory Cache
`WithCache(ttl)` installs a URL-keyed in-memory response cache scoped to GET requests. Successful
2xx responses are stored. Cached responses are served with an `X-Cache: HIT` header and bypass all
downstream handlers. Expired entries are evicted lazily on the next access for the same key. The
cache is not shared across `Engine` instances and has no size limit.
---
## 13. 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 single-service
deployments that constructs a synchronous `TracerProvider` and installs it globally. Production
deployments should build a batching provider with appropriate resource attributes.
---
## 14. 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 against the configured supported locales.
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.
---
## 15. Package Layout
```
go-api/
├── api.go — Engine struct, New(), build(), Serve(), Handler(), Addr(), Channels()
├── options.go — All With*() option functions
├── group.go — RouteGroup, StreamGroup, DescribableGroup interfaces; RouteDescription
├── response.go — Response[T], Error, Meta, OK(), Fail(), FailWithDetails(), Paginated()
├── middleware.go — bearerAuthMiddleware(), requestIDMiddleware()
├── authentik.go — AuthentikUser, AuthentikConfig, GetUser(), RequireAuth(), RequireGroup()
├── websocket.go — wrapWSHandler() helper
├── sse.go — SSEBroker, NewSSEBroker(), Publish(), Handler(), Drain()
├── cache.go — cacheStore, cacheEntry, cacheWriter, cacheMiddleware()
├── brotli.go — brotliHandler, newBrotliHandler(); BrotliDefault/BestSpeed/BestCompression constants
├── graphql.go — graphqlConfig, GraphQLOption, WithPlayground(), WithGraphQLPath(), mountGraphQL()
├── i18n.go — I18nConfig, WithI18n(), i18nMiddleware(), GetLocale(), GetMessage()
├── tracing.go — WithTracing(), NewTracerProvider()
├── swagger.go — swaggerSpec, registerSwagger(); swaggerSeq for multi-instance safety
├── openapi.go — SpecBuilder, Build(), buildPaths(), buildTags(), envelopeSchema()
├── export.go — ExportSpec(), ExportSpecToFile()
├── bridge.go — ToolDescriptor, ToolBridge, NewToolBridge(), Add(), Describe(), Tools()
└── codegen.go — SDKGenerator, Generate(), buildArgs(), Available(), SupportedLanguages()
```
---
## 16. Dependency Diagram
### Direct Dependencies
| Module | Role |
|--------|------|
| `github.com/gin-gonic/gin` | HTTP router and middleware engine |
| `github.com/gin-contrib/cors` | CORS policy middleware |
| `github.com/gin-contrib/secure` | Security headers |
| `github.com/gin-contrib/gzip` | Gzip compression |
| `github.com/gin-contrib/slog` | Structured logging |
| `github.com/gin-contrib/timeout` | Per-request deadlines |
| `github.com/gin-contrib/static` | Static file serving |
| `github.com/gin-contrib/sessions` | Cookie-backed sessions |
| `github.com/gin-contrib/authz` | Casbin authorisation |
| `github.com/gin-contrib/httpsign` | HTTP Signatures |
| `github.com/gin-contrib/location/v2` | Reverse proxy header detection |
| `github.com/gin-contrib/pprof` | Go profiling endpoints |
| `github.com/gin-contrib/expvar` | Runtime metrics endpoint |
| `github.com/casbin/casbin/v2` | Policy-based access control engine |
| `github.com/coreos/go-oidc/v3` | OIDC JWT validation for Authentik |
| `github.com/andybalholm/brotli` | Brotli compression |
| `github.com/gorilla/websocket` | WebSocket upgrade |
| `github.com/swaggo/gin-swagger` | Swagger UI handler |
| `github.com/swaggo/files` | Swagger UI static assets |
| `github.com/swaggo/swag` | Swagger spec registry |
| `github.com/99designs/gqlgen` | GraphQL schema execution |
| `go.opentelemetry.io/otel` | OpenTelemetry tracing |
| `go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin` | OTel + Gin |
| `golang.org/x/text/language` | BCP 47 language matching |
| `gopkg.in/yaml.v3` | YAML export of OpenAPI spec |
### What Imports go-api
go-api is imported by packages that need to expose REST endpoints. Within the Lethean ecosystem
the expected importers are:
```
Core CLI (forge.lthn.ai/core/go)
└─ go-api ← wires route groups from go-ai, go-ml, go-rag into an Engine
go-ai
└─ go-api ← ToolBridge converts MCP tool descriptors to REST endpoints
go-ml
└─ go-api ← may register inference/scoring route groups
Application main packages
└─ go-api ← top-level server bootstrap
```
go-api itself has no imports from other Lethean ecosystem modules. It is the stable base that
others import, not a consumer of them.