docs(api): add AX usage examples

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 07:51:21 +00:00
parent 08cb1385d3
commit ec945970ee
14 changed files with 262 additions and 0 deletions

20
api.go
View file

@ -91,11 +91,21 @@ func (e *Engine) Addr() string {
}
// 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)
@ -115,6 +125,10 @@ func (e *Engine) Register(group RouteGroup) {
// 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 {
@ -126,6 +140,12 @@ 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) {

View file

@ -14,6 +14,10 @@ import (
)
// AuthentikConfig holds settings for the Authentik forward-auth integration.
//
// Example:
//
// cfg := api.AuthentikConfig{Issuer: "https://auth.example.com/", ClientID: "core-api"}
type AuthentikConfig struct {
// Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/).
Issuer string
@ -32,6 +36,10 @@ type AuthentikConfig struct {
// AuthentikUser represents an authenticated user extracted from Authentik
// forward-auth headers or a validated JWT.
//
// Example:
//
// user := &api.AuthentikUser{Username: "alice", Groups: []string{"admins"}}
type AuthentikUser struct {
Username string `json:"username"`
Email string `json:"email"`
@ -43,6 +51,10 @@ type AuthentikUser struct {
}
// HasGroup reports whether the user belongs to the named group.
//
// Example:
//
// user.HasGroup("admins")
func (u *AuthentikUser) HasGroup(group string) bool {
return slices.Contains(u.Groups, group)
}
@ -53,6 +65,10 @@ const authentikUserKey = "authentik_user"
// GetUser retrieves the AuthentikUser from the Gin context.
// Returns nil when no user has been set (unauthenticated request or
// middleware not active).
//
// Example:
//
// user := api.GetUser(c)
func GetUser(c *gin.Context) *AuthentikUser {
val, exists := c.Get(authentikUserKey)
if !exists {
@ -204,6 +220,10 @@ func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.H
// RequireAuth is Gin middleware that rejects unauthenticated requests.
// It checks for a user set by the Authentik middleware and returns 401
// when none is present.
//
// Example:
//
// r.GET("/private", api.RequireAuth(), handler)
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if GetUser(c) == nil {
@ -218,6 +238,10 @@ func RequireAuth() gin.HandlerFunc {
// RequireGroup is Gin middleware that rejects requests from users who do
// not belong to the specified group. Returns 401 when no user is present
// and 403 when the user lacks the required group membership.
//
// Example:
//
// r.GET("/admin", api.RequireGroup("admins"), handler)
func RequireGroup(group string) gin.HandlerFunc {
return func(c *gin.Context) {
user := GetUser(c)

View file

@ -23,6 +23,10 @@ import (
)
// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
//
// Example:
//
// desc := api.ToolDescriptor{Name: "ping", Description: "Ping the service"}
type ToolDescriptor struct {
Name string // Tool name, e.g. "file_read" (becomes POST path segment)
Description string // Human-readable description
@ -33,6 +37,10 @@ type ToolDescriptor struct {
// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
// It implements both RouteGroup and DescribableGroup.
//
// Example:
//
// bridge := api.NewToolBridge("/mcp")
type ToolBridge struct {
basePath string
name string
@ -75,12 +83,24 @@ func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc) {
}
// Name returns the bridge identifier.
//
// Example:
//
// name := bridge.Name()
func (b *ToolBridge) Name() string { return b.name }
// BasePath returns the URL prefix for all tool endpoints.
//
// Example:
//
// path := bridge.BasePath()
func (b *ToolBridge) BasePath() string { return b.basePath }
// RegisterRoutes mounts POST /{tool_name} for each registered tool.
//
// Example:
//
// bridge.RegisterRoutes(rg)
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
for _, t := range b.tools {
rg.POST("/"+t.descriptor.Name, t.handler)
@ -88,6 +108,10 @@ func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
}
// Describe returns OpenAPI route descriptions for all registered tools.
//
// Example:
//
// descs := bridge.Describe()
func (b *ToolBridge) Describe() []RouteDescription {
tools := b.snapshotTools()
descs := make([]RouteDescription, 0, len(tools))
@ -98,6 +122,12 @@ func (b *ToolBridge) Describe() []RouteDescription {
}
// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools.
//
// Example:
//
// for rd := range bridge.DescribeIter() {
// _ = rd
// }
func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
tools := b.snapshotTools()
return func(yield func(RouteDescription) bool) {
@ -124,6 +154,12 @@ func (b *ToolBridge) Tools() []ToolDescriptor {
}
// ToolsIter returns an iterator over all registered tool descriptors.
//
// Example:
//
// for desc := range bridge.ToolsIter() {
// _ = desc
// }
func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] {
tools := b.snapshotTools()
return func(yield func(ToolDescriptor) bool) {

View file

@ -56,6 +56,10 @@ type openAPIParameter struct {
}
// OpenAPIClientOption configures a runtime OpenAPI client.
//
// Example:
//
// client := api.NewOpenAPIClient(api.WithSpec("./openapi.yaml"))
type OpenAPIClientOption func(*OpenAPIClient)
// WithSpec sets the filesystem path to the OpenAPI document.

View file

@ -23,9 +23,17 @@ type graphqlConfig struct {
}
// GraphQLOption configures a GraphQL endpoint.
//
// Example:
//
// opts := []api.GraphQLOption{api.WithPlayground(), api.WithGraphQLPath("/gql")}
type GraphQLOption func(*graphqlConfig)
// WithPlayground enables the GraphQL Playground UI at {path}/playground.
//
// Example:
//
// api.WithGraphQL(schema, api.WithPlayground())
func WithPlayground() GraphQLOption {
return func(cfg *graphqlConfig) {
cfg.playground = true
@ -34,6 +42,10 @@ func WithPlayground() GraphQLOption {
// WithGraphQLPath sets a custom URL path for the GraphQL endpoint.
// The default path is "/graphql".
//
// Example:
//
// api.WithGraphQL(schema, api.WithGraphQLPath("/gql"))
func WithGraphQLPath(path string) GraphQLOption {
return func(cfg *graphqlConfig) {
cfg.path = normaliseGraphQLPath(path)

View file

@ -10,6 +10,10 @@ import (
// RouteGroup registers API routes onto a Gin router group.
// Subsystems implement this interface to declare their endpoints.
//
// Example:
//
// var g api.RouteGroup = &myGroup{}
type RouteGroup interface {
// Name returns a human-readable identifier for the group.
Name() string
@ -22,6 +26,10 @@ type RouteGroup interface {
}
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
//
// Example:
//
// var sg api.StreamGroup = &myStreamGroup{}
type StreamGroup interface {
// Channels returns the list of channel names this group streams on.
Channels() []string
@ -30,6 +38,10 @@ type StreamGroup interface {
// DescribableGroup extends RouteGroup with OpenAPI metadata.
// RouteGroups that implement this will have their endpoints
// included in the generated OpenAPI specification.
//
// Example:
//
// var dg api.DescribableGroup = &myDescribableGroup{}
type DescribableGroup interface {
RouteGroup
// Describe returns endpoint descriptions for OpenAPI generation.
@ -38,6 +50,10 @@ type DescribableGroup interface {
// DescribableGroupIter extends DescribableGroup with an iterator-based
// description source for callers that want to avoid slice allocation.
//
// Example:
//
// var dg api.DescribableGroupIter = &myDescribableGroup{}
type DescribableGroupIter interface {
DescribableGroup
// DescribeIter returns endpoint descriptions for OpenAPI generation.

20
i18n.go
View file

@ -22,6 +22,14 @@ const i18nCatalogKey = "i18n.catalog"
const i18nDefaultLocaleKey = "i18n.default_locale"
// I18nConfig configures the internationalisation middleware.
//
// Example:
//
// cfg := api.I18nConfig{
// DefaultLocale: "en",
// Supported: []string{"en", "fr"},
// Messages: map[string]map[string]string{"fr": {"greeting": "Bonjour"}},
// }
type I18nConfig struct {
// DefaultLocale is the fallback locale when the Accept-Language header
// is absent or does not match any supported locale. Defaults to "en".
@ -43,6 +51,10 @@ type I18nConfig struct {
// with quality weighting support. The detected locale is stored in the Gin
// context and can be retrieved by handlers via GetLocale().
//
// Example:
//
// api.New(api.WithI18n(api.I18nConfig{Supported: []string{"en", "fr"}}))
//
// If messages are configured, handlers can look up localised strings via
// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine
// can replace the message map later.
@ -103,6 +115,10 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
// GetLocale returns the detected locale for the current request.
// Returns "en" if the i18n middleware was not applied.
//
// Example:
//
// locale := api.GetLocale(c)
func GetLocale(c *gin.Context) string {
if v, ok := c.Get(i18nContextKey); ok {
if s, ok := v.(string); ok {
@ -115,6 +131,10 @@ func GetLocale(c *gin.Context) string {
// GetMessage looks up a localised message by key for the current request.
// Returns the message string and true if found, or empty string and false
// if the key does not exist or the i18n middleware was not applied.
//
// Example:
//
// msg, ok := api.GetMessage(c, "greeting")
func GetMessage(c *gin.Context, key string) (string, bool) {
if v, ok := c.Get(i18nMessagesKey); ok {
if msgs, ok := v.(map[string]string); ok {

View file

@ -110,6 +110,10 @@ func requestIDMiddleware() gin.HandlerFunc {
// GetRequestID returns the request ID assigned by requestIDMiddleware.
// Returns an empty string when the middleware was not applied.
//
// Example:
//
// id := api.GetRequestID(c)
func GetRequestID(c *gin.Context) string {
if v, ok := c.Get(requestIDContextKey); ok {
if s, ok := v.(string); ok {
@ -121,6 +125,10 @@ func GetRequestID(c *gin.Context) string {
// GetRequestDuration returns the elapsed time since requestIDMiddleware started
// handling the request. Returns 0 when the middleware was not applied.
//
// Example:
//
// d := api.GetRequestDuration(c)
func GetRequestDuration(c *gin.Context) time.Duration {
if v, ok := c.Get(requestStartContextKey); ok {
if started, ok := v.(time.Time); ok && !started.IsZero() {
@ -133,6 +141,10 @@ func GetRequestDuration(c *gin.Context) time.Duration {
// GetRequestMeta returns request metadata collected by requestIDMiddleware.
// The returned meta includes the request ID and elapsed duration when
// available. It returns nil when neither value is available.
//
// Example:
//
// meta := api.GetRequestMeta(c)
func GetRequestMeta(c *gin.Context) *Meta {
meta := &Meta{}

View file

@ -191,6 +191,10 @@ func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) {
// BuildIter generates the complete OpenAPI 3.1 JSON spec from a route-group
// iterator. The iterator is snapshotted before building so the result stays
// stable even if the source changes during rendering.
//
// Example:
//
// data, err := (&api.SpecBuilder{Title: "Service"}).BuildIter(api.RegisteredSpecGroupsIter())
func (sb *SpecBuilder) BuildIter(groups iter.Seq[RouteGroup]) ([]byte, error) {
return sb.Build(collectRouteGroups(groups))
}

View file

@ -27,6 +27,10 @@ import (
)
// Option configures an Engine during construction.
//
// Example:
//
// engine, _ := api.New(api.WithAddr(":8080"))
type Option func(*Engine)
// WithAddr sets the listen address for the server.
@ -42,6 +46,10 @@ func WithAddr(addr string) Option {
// WithBearerAuth adds bearer token authentication middleware.
// Requests to /health and the Swagger UI path are exempt.
//
// Example:
//
// api.New(api.WithBearerAuth("secret"))
func WithBearerAuth(token string) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, func() []string {
@ -56,6 +64,10 @@ func WithBearerAuth(token string) Option {
// WithRequestID adds middleware that assigns an X-Request-ID to every response.
// Client-provided IDs are preserved; otherwise a random hex ID is generated.
//
// Example:
//
// api.New(api.WithRequestID())
func WithRequestID() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, requestIDMiddleware())
@ -80,6 +92,10 @@ func WithResponseMeta() Option {
// Pass "*" to allow all origins, or supply specific origin URLs.
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common
// headers (Authorization, Content-Type, X-Request-ID) are permitted.
//
// Example:
//
// api.New(api.WithCORS("*"))
func WithCORS(allowOrigins ...string) Option {
return func(e *Engine) {
cfg := cors.Config{
@ -100,6 +116,10 @@ func WithCORS(allowOrigins ...string) Option {
}
// WithMiddleware appends arbitrary Gin middleware to the engine.
//
// Example:
//
// api.New(api.WithMiddleware(loggingMiddleware))
func WithMiddleware(mw ...gin.HandlerFunc) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, mw...)
@ -109,6 +129,10 @@ func WithMiddleware(mw ...gin.HandlerFunc) Option {
// WithStatic serves static files from the given root directory at urlPrefix.
// Directory listing is disabled; only individual files are served.
// Internally this uses gin-contrib/static as Gin middleware.
//
// Example:
//
// api.New(api.WithStatic("/assets", "./public"))
func WithStatic(urlPrefix, root string) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false)))
@ -130,6 +154,10 @@ func WithWSHandler(h http.Handler) Option {
// WithWSPath sets a custom URL path for the WebSocket endpoint.
// The default path is "/ws".
//
// Example:
//
// api.New(api.WithWSPath("/socket"))
func WithWSPath(path string) Option {
return func(e *Engine) {
e.wsPath = normaliseWSPath(path)
@ -139,6 +167,10 @@ func WithWSPath(path string) Option {
// WithAuthentik adds Authentik forward-auth middleware that extracts user
// identity from X-authentik-* headers set by a trusted reverse proxy.
// The middleware is permissive: unauthenticated requests are allowed through.
//
// Example:
//
// api.New(api.WithAuthentik(api.AuthentikConfig{TrustedProxy: true}))
func WithAuthentik(cfg AuthentikConfig) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, authentikMiddleware(cfg, func() []string {
@ -151,6 +183,10 @@ func WithAuthentik(cfg AuthentikConfig) Option {
// The middleware appends Deprecation, optional Sunset, optional Link, and
// X-API-Warn headers without clobbering any existing header values. Use it to
// deprecate an entire route group or API version.
//
// Example:
//
// api.New(api.WithSunset("2026-12-31", "https://api.example.com/v2"))
func WithSunset(sunsetDate, replacement string) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, ApiSunset(sunsetDate, replacement))
@ -159,6 +195,10 @@ func WithSunset(sunsetDate, replacement string) Option {
// WithSwagger enables the Swagger UI at /swagger/ by default.
// The title, description, and version populate the OpenAPI info block.
//
// Example:
//
// api.New(api.WithSwagger("Service", "Public API", "1.0.0"))
func WithSwagger(title, description, version string) Option {
return func(e *Engine) {
e.swaggerTitle = title
@ -170,6 +210,10 @@ func WithSwagger(title, description, version string) Option {
// WithSwaggerPath sets a custom URL path for the Swagger UI.
// The default path is "/swagger".
//
// Example:
//
// api.New(api.WithSwaggerPath("/docs"))
func WithSwaggerPath(path string) Option {
return func(e *Engine) {
e.swaggerPath = normaliseSwaggerPath(path)
@ -296,6 +340,10 @@ func WithSwaggerExternalDocs(description, url string) Option {
//
// WARNING: pprof exposes sensitive runtime data and should only be
// enabled in development or behind authentication in production.
//
// Example:
//
// api.New(api.WithPprof())
func WithPprof() Option {
return func(e *Engine) {
e.pprofEnabled = true
@ -310,6 +358,10 @@ func WithPprof() Option {
// WARNING: expvar exposes runtime internals (memory allocation,
// goroutine counts, command-line arguments) and should only be
// enabled in development or behind authentication in production.
//
// Example:
//
// api.New(api.WithExpvar())
func WithExpvar() Option {
return func(e *Engine) {
e.expvarEnabled = true
@ -321,6 +373,10 @@ func WithExpvar() Option {
// X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin.
// SSL redirect is not enabled so the middleware works behind a reverse proxy
// that terminates TLS.
//
// Example:
//
// api.New(api.WithSecure())
func WithSecure() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, secure.New(secure.Config{
@ -337,6 +393,10 @@ func WithSecure() Option {
// WithGzip adds gzip response compression middleware via gin-contrib/gzip.
// An optional compression level may be supplied (e.g. gzip.BestSpeed,
// gzip.BestCompression). If omitted, gzip.DefaultCompression is used.
//
// Example:
//
// api.New(api.WithGzip())
func WithGzip(level ...int) Option {
return func(e *Engine) {
l := gzip.DefaultCompression
@ -350,6 +410,10 @@ func WithGzip(level ...int) Option {
// WithBrotli adds Brotli response compression middleware using andybalholm/brotli.
// An optional compression level may be supplied (e.g. BrotliBestSpeed,
// BrotliBestCompression). If omitted, BrotliDefaultCompression is used.
//
// Example:
//
// api.New(api.WithBrotli())
func WithBrotli(level ...int) Option {
return func(e *Engine) {
l := BrotliDefaultCompression
@ -363,6 +427,10 @@ func WithBrotli(level ...int) Option {
// WithSlog adds structured request logging middleware via gin-contrib/slog.
// Each request is logged with method, path, status code, latency, and client IP.
// If logger is nil, slog.Default() is used.
//
// Example:
//
// api.New(api.WithSlog(nil))
func WithSlog(logger *slog.Logger) Option {
return func(e *Engine) {
if logger == nil {
@ -384,6 +452,10 @@ func WithSlog(logger *slog.Logger) Option {
//
// A zero or negative duration effectively disables the timeout (the handler
// runs without a deadline) — this is safe and will not panic.
//
// Example:
//
// api.New(api.WithTimeout(5 * time.Second))
func WithTimeout(d time.Duration) Option {
return func(e *Engine) {
if d <= 0 {
@ -456,6 +528,10 @@ func WithRateLimit(limit int) Option {
// gin-contrib/sessions using a cookie-based store. The name parameter
// sets the session cookie name (e.g. "session") and secret is the key
// used for cookie signing and encryption.
//
// Example:
//
// api.New(api.WithSessions("session", []byte("secret")))
func WithSessions(name string, secret []byte) Option {
return func(e *Engine) {
store := cookie.NewStore(secret)
@ -468,6 +544,10 @@ func WithSessions(name string, secret []byte) Option {
// holding the desired model and policy rules. The middleware extracts the
// subject from HTTP Basic Authentication, evaluates it against the request
// method and path, and returns 403 Forbidden when the policy denies access.
//
// Example:
//
// api.New(api.WithAuthz(enforcer))
func WithAuthz(enforcer *casbin.Enforcer) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer))
@ -487,6 +567,10 @@ func WithAuthz(enforcer *casbin.Enforcer) Option {
//
// Requests with a missing, malformed, or invalid signature are rejected with
// 401 Unauthorised or 400 Bad Request.
//
// Example:
//
// api.New(api.WithHTTPSign(secrets))
func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
return func(e *Engine) {
auth := httpsign.NewAuthenticator(secrets, opts...)
@ -512,6 +596,10 @@ func WithSSE(broker *SSEBroker) Option {
// WithSSEPath sets a custom URL path for the SSE endpoint.
// The default path is "/events".
//
// Example:
//
// api.New(api.WithSSEPath("/stream"))
func WithSSEPath(path string) Option {
return func(e *Engine) {
e.ssePath = normaliseSSEPath(path)

View file

@ -18,6 +18,10 @@ type Response[T any] struct {
}
// Error describes a failed API request.
//
// Example:
//
// err := api.Error{Code: "invalid_input", Message: "Name is required"}
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
@ -25,6 +29,10 @@ type Error struct {
}
// Meta carries pagination and request metadata.
//
// Example:
//
// meta := api.Meta{RequestID: "req_123", Duration: "12ms"}
type Meta struct {
RequestID string `json:"request_id,omitempty"`
Duration string `json:"duration,omitempty"`

View file

@ -76,6 +76,12 @@ func RegisteredSpecGroups() []RouteGroup {
//
// The iterator snapshots the current registry contents so callers can range
// over it without holding the registry lock.
//
// Example:
//
// for g := range api.RegisteredSpecGroupsIter() {
// _ = g
// }
func RegisteredSpecGroupsIter() iter.Seq[RouteGroup] {
specRegistry.mu.RLock()
groups := slices.Clone(specRegistry.groups)

4
sse.go
View file

@ -192,6 +192,10 @@ func (b *SSEBroker) Handler() gin.HandlerFunc {
}
// ClientCount returns the number of currently connected SSE clients.
//
// Example:
//
// n := broker.ClientCount()
func (b *SSEBroker) ClientCount() int {
b.mu.RLock()
defer b.mu.RUnlock()

View file

@ -8,6 +8,10 @@ import "strings"
//
// It is intentionally small and serialisable so callers can inspect the active HTTP
// surface without rebuilding an OpenAPI document.
//
// Example:
//
// cfg := api.TransportConfig{SwaggerPath: "/swagger", WSPath: "/ws"}
type TransportConfig struct {
SwaggerPath string
GraphQLPath string
@ -22,6 +26,10 @@ type TransportConfig struct {
//
// The result snapshots the Engine state at call time and normalises any configured
// URL paths using the same rules as the runtime handlers.
//
// Example:
//
// cfg := engine.TransportConfig()
func (e *Engine) TransportConfig() TransportConfig {
if e == nil {
return TransportConfig{}