Documentation
¶
Overview ¶
Package api provides the HTTP API layer for the HelixKnowledge Skill Graph System. It includes handlers, middleware, content negotiation, and response utilities.
Tenant context middleware for the HelixKnowledge skill graph system.
Extracts tenant identification from incoming HTTP requests and propagates it via context.Context so that every downstream handler, database query, and audit log can operate within the correct tenant scope.
Tenant resolution order (first match wins):
- X-Tenant-ID header — explicit tenant selection (highest priority).
- API key → tenant mapping — implicit tenant from the authenticated key.
- Default tenant — fallback when cfg.DefaultTenant is set.
- Reject — 403 when cfg.Required is true and no tenant could be resolved.
The middleware stores a TenantContext in both gin.Context and the standard request context so that code outside the Gin handler chain (background jobs, database hooks) can still call TenantFromContext(ctx).
§11.4.84 Tenant context extraction middleware.
Tenant audit logging middleware for the HelixKnowledge skill graph system.
Records every tenant-scoped API request into the tenant_audit_log table, providing a durable, queryable trail of who did what and when. Health and readiness probes are excluded to avoid noise.
Audit writes are asynchronous: the middleware enqueues entries into a buffered channel and a dedicated goroutine batches them to the database, ensuring the request path is never blocked by I/O. When the channel is full (back-pressure), entries are dropped with a warning log rather than causing handler latency.
§11.4.84 Tenant audit logging.
Tenant request metrics middleware for the HelixKnowledge skill graph system.
Increments the tenant_requests_total counter for every tenant-scoped API request, recording the tenant ID, HTTP method, path, and response status. Health, readiness, and metrics probes are excluded to match the audit middleware's skip set.
§11.4.84 Tenant request metrics.
Per-tenant rate limiting middleware for the HelixKnowledge skill graph system.
Applies a token-bucket rate limiter per tenant, preventing any single tenant from monopolising shared API capacity. Each tenant identified by TenantMiddleware receives an independent bucket whose parameters are configured via TenantRateLimitConfig.
When a request exceeds the tenant's allowance the middleware responds with 429 Too Many Requests and a Retry-After header, giving the client a deterministic back-off signal.
A background goroutine reaps idle tenant limiters after the configured TTL to bound memory usage in long-running deployments.
§11.4.84 Tenant-scoped rate limiting.
Index ¶
- Constants
- Variables
- func APIKeyAuth(validKeys []string) gin.HandlerFunc
- func BrotliMiddleware() gin.HandlerFunc
- func CORS(allowedOrigins []string) gin.HandlerFunc
- func ContentNegotiation() gin.HandlerFunc
- func DetectContentType() gin.HandlerFunc
- func Logger(logger *zap.Logger) gin.HandlerFunc
- func MaxBodySize(maxBytes int64) gin.HandlerFunc
- func MetricsHandler() gin.HandlerFunc
- func MetricsMiddleware() gin.HandlerFunc
- func NegotiateResponse(c *gin.Context, status int, data interface{})
- func PoolFromContext(ctx context.Context) *db.Pool
- func Recovery() gin.HandlerFunc
- func RequestID() gin.HandlerFunc
- func RequireAuthConfigured() gin.HandlerFunc
- func ResolveAPIKeyAuth(apiKeys []string, authDisabled bool, logger *zap.Logger) gin.HandlerFunc
- func RespondError(c *gin.Context, status int, message string)
- func RespondErrorWithCode(c *gin.Context, status int, code, message string)
- func RespondJSON(c *gin.Context, status int, data interface{})
- func RespondPaginated[T any](c *gin.Context, status int, data []T, total, limit, offset int)
- func RespondTOML(c *gin.Context, status int, data interface{})
- func RespondTOON(c *gin.Context, status int, data interface{})
- func SetResponseFormat(c *gin.Context, format ResponseFormat)
- func TenantAuditMiddleware(logger AuditLogger, tm *metrics.TenantMetrics) gin.HandlerFunc
- func TenantIDFromContext(ctx context.Context) uuid.UUID
- func TenantMiddleware(cfg config.TenantConfig) gin.HandlerFunc
- func TenantRateLimitMiddleware(rl *TenantRateLimiter, tm *metrics.TenantMetrics) gin.HandlerFunc
- func TenantRequestMetricsMiddleware(tm *metrics.TenantMetrics) gin.HandlerFunc
- func ValidateContentType(allowed ...string) gin.HandlerFunc
- func VersionHandler() gin.HandlerFunc
- func WithDBPool(ctx context.Context, pool *db.Pool) context.Context
- func WithDBPoolMiddleware(pool *db.Pool) gin.HandlerFunc
- func WithTenant(ctx context.Context, tc *TenantContext) context.Context
- type AuditEntry
- type AuditLogger
- type BrotliWriter
- type DBAuditLogger
- type ErrorResponse
- type HealthResponse
- type PaginatedResponse
- type RateLimitConfig
- type RateLimiter
- type ResponseFormat
- type TenantContext
- type TenantRateLimitConfig
- type TenantRateLimiter
- type VersionResponse
Constants ¶
const DefaultMaxBodyBytes int64 = 100 * 1024 * 1024
DefaultMaxBodyBytes is the request-body cap applied on the live router when the operator does not configure one (§G22). 100 MiB matches the value the import path was designed around.
Variables ¶
var ( Version = "dev" Commit = "unknown" BuildTime = "unknown" )
Build-time variables (injected via ldflags).
Functions ¶
func APIKeyAuth ¶
func APIKeyAuth(validKeys []string) gin.HandlerFunc
APIKeyAuth validates requests against a set of valid API keys. It checks the X-API-Key header and aborts with 401 if invalid.
func BrotliMiddleware ¶
func BrotliMiddleware() gin.HandlerFunc
BrotliMiddleware adds Brotli compression for responses when the client advertises support via Accept-Encoding.
func CORS ¶
func CORS(allowedOrigins []string) gin.HandlerFunc
CORS returns a middleware that sets Cross-Origin Resource Sharing headers driven by an explicit origin allowlist.
Security: the CORS specification forbids combining a wildcard "Access-Control-Allow-Origin: *" (or a reflected arbitrary Origin) with "Access-Control-Allow-Credentials: true" — doing so would let any website make credentialed cross-origin requests. This implementation therefore:
- only echoes an Origin back when it appears in allowedOrigins, and only then sets Allow-Credentials: true;
- supports a literal "*" entry that allows any origin WITHOUT credentials;
- always emits "Vary: Origin" so shared caches never serve a response keyed for the wrong origin;
- sends no Allow-Origin header at all for non-allowlisted origins, so the browser blocks the cross-origin response.
An empty allowlist is fail-closed: no cross-origin origin is permitted.
func ContentNegotiation ¶
func ContentNegotiation() gin.HandlerFunc
ContentNegotiation parses the Accept header and determines the response format. It sets the negotiated format in the Gin context for downstream handlers.
func DetectContentType ¶
func DetectContentType() gin.HandlerFunc
DetectContentType automatically detects whether request body is JSON or TOML and sets the "body_format" context key. It reads and restores the body.
func Logger ¶
func Logger(logger *zap.Logger) gin.HandlerFunc
Logger returns a Gin middleware that logs all HTTP requests with structured fields.
func MaxBodySize ¶
func MaxBodySize(maxBytes int64) gin.HandlerFunc
MaxBodySize limits the request body size and returns 413 (Request Entity Too Large) when it is exceeded (§G22). It enforces the cap in three cases so the rejection is handler-INDEPENDENT (a handler that never reads the body cannot let an oversized request through):
- A DECLARED Content-Length above the cap is refused up-front, before the body is read at all.
- A CHUNKED / unknown-length body (Content-Length < 0) is drained through a bounded reader that stops at cap+1 bytes: if it exceeds the cap the request is refused 413, otherwise the already-read bytes are handed back to the handler. The read is bounded to cap+1 regardless of how large — or unbounded — the incoming stream is, so a chunked flood cannot exhaust memory (no OOM), while an under-cap body is delivered intact (W2).
- A declared-length body within the cap is wrapped in http.MaxBytesReader so a body that lies about its length (sends more than it declared) is still truncated at the cap when a handler reads it.
A maxBytes <= 0 disables the cap.
func MetricsHandler ¶
func MetricsHandler() gin.HandlerFunc
MetricsHandler returns a standalone Gin handler that serves the Prometheus exposition, decoupled from *Server. The LIVE cmd/server router (buildRouter) registers /metrics with THIS handler UNDER its fail-closed auth guard (§G24), so the exposition — goroutine/memory/uptime gauges plus the Go runtime registry — is served only to authenticated scrapers and never leaked to an anonymous caller. It refreshes the system gauges before serving so each scrape reflects the current uptime/goroutine/memory state. handleMetrics (the dead *Server path) delegates here so there is a single exposition implementation.
func MetricsMiddleware ¶
func MetricsMiddleware() gin.HandlerFunc
MetricsMiddleware records Prometheus metrics for each request.
func NegotiateResponse ¶
NegotiateResponse serializes the response in the negotiated wire format (TOON primary, TOML, or JSON fallback) based on content negotiation.
func PoolFromContext ¶
PoolFromContext extracts the *db.Pool stored in ctx. Returns nil when no pool has been set.
func Recovery ¶
func Recovery() gin.HandlerFunc
Recovery returns a Gin middleware that recovers from panics, logs the stack trace, and returns a 500 Internal Server Error.
func RequestID ¶
func RequestID() gin.HandlerFunc
RequestID generates and attaches a unique request ID to each request. It checks for an existing X-Request-ID header first; if not present, it generates a new UUID.
func RequireAuthConfigured ¶
func RequireAuthConfigured() gin.HandlerFunc
RequireAuthConfigured is a fail-CLOSED middleware installed when API authentication is neither configured (no API keys) nor explicitly disabled. It rejects every request with 503 so protected routes are never served with authentication silently absent.
func ResolveAPIKeyAuth ¶
ResolveAPIKeyAuth selects the authentication middleware for the protected API group under a fail-CLOSED policy:
- API keys configured -> APIKeyAuth validates every request.
- no keys, authDisabled=true -> nil (no auth middleware), logged loudly as a deliberate, explicit open-access mode.
- no keys, authDisabled=false -> RequireAuthConfigured rejects every request (503). This replaces the prior fail-OPEN "len(keys) > 0" gate that served protected routes wide open whenever no keys happened to be configured.
A nil return means "install no auth middleware" and occurs ONLY in the explicit auth-disabled mode.
func RespondError ¶
RespondError writes a structured error response in the negotiated format.
func RespondErrorWithCode ¶
RespondErrorWithCode writes a structured error response with a custom error code.
func RespondJSON ¶
RespondJSON writes a JSON response with the given HTTP status code.
func RespondPaginated ¶
RespondPaginated writes a paginated response in the negotiated format.
func RespondTOML ¶
RespondTOML writes a TOML response with the given HTTP status code.
func RespondTOON ¶
RespondTOON writes a TOON response with the given HTTP status code. On encode failure it emits a JSON error envelope instead — never a silent, truncated, or empty body (§11.4.6 honest boundary; the silent-fallback bluff G08 forbids).
func SetResponseFormat ¶
func SetResponseFormat(c *gin.Context, format ResponseFormat)
SetResponseFormat stores the negotiated format in the Gin context.
func TenantAuditMiddleware ¶
func TenantAuditMiddleware(logger AuditLogger, tm *metrics.TenantMetrics) gin.HandlerFunc
TenantAuditMiddleware returns a Gin middleware that logs every tenant-scoped request via the provided AuditLogger. Health, readiness, and metrics endpoints are skipped.
The tm parameter is optional — pass nil to skip tenant metrics recording.
The middleware MUST run AFTER TenantMiddleware so that the tenant context is available. Requests without a resolved tenant are logged with uuid.Nil.
§11.4.84 Tenant audit logging middleware.
func TenantIDFromContext ¶
TenantIDFromContext is a convenience accessor that returns the tenant UUID from ctx. Returns uuid.Nil when no tenant is present, allowing callers to test with `id == uuid.Nil` instead of a nil-pointer check.
func TenantMiddleware ¶
func TenantMiddleware(cfg config.TenantConfig) gin.HandlerFunc
TenantMiddleware resolves the tenant for each request and stores the result in both gin.Context and the standard request context.
Resolution order:
- X-Tenant-ID header — an explicit UUID selects the tenant directly.
- API key → tenant mapping — looks up the tenant associated with the authenticated API key (requires APIKeyAuth to have run first).
- cfg.DefaultTenant — when set, used as the fallback tenant UUID.
- If cfg.Required is true and no tenant was resolved, the request is aborted with 403 Forbidden.
When a tenant UUID is resolved (from any source), the middleware queries the tenants table to populate TenantName. A missing row is treated as an invalid tenant — the request is aborted with 403.
§11.4.84 Tenant context extraction.
func TenantRateLimitMiddleware ¶
func TenantRateLimitMiddleware(rl *TenantRateLimiter, tm *metrics.TenantMetrics) gin.HandlerFunc
TenantRateLimitMiddleware returns a Gin middleware that enforces per-tenant rate limits. The middleware MUST run AFTER TenantMiddleware so that the tenant context is available.
The tm parameter is optional — pass nil to skip tenant metrics recording.
When the rate limit is exceeded the request is aborted with 429 and a Retry-After header indicating when the client may retry.
Requests without a resolved tenant (single-tenant mode) pass through unthrottled.
§11.4.84 Tenant rate limiting middleware.
func TenantRequestMetricsMiddleware ¶
func TenantRequestMetricsMiddleware(tm *metrics.TenantMetrics) gin.HandlerFunc
TenantRequestMetricsMiddleware returns a Gin middleware that increments the tenant_requests_total counter for each tenant-scoped request. The tm parameter is optional — when nil the middleware is a no-op.
This middleware should run AFTER TenantMiddleware so that the tenant context is available. Requests without a resolved tenant are recorded with an empty tenant_id label.
§11.4.84 Tenant request metrics middleware.
func ValidateContentType ¶
func ValidateContentType(allowed ...string) gin.HandlerFunc
ValidateContentType ensures the request Content-Type is acceptable for the endpoint.
func VersionHandler ¶
func VersionHandler() gin.HandlerFunc
VersionHandler returns a standalone Gin handler that serves the build/version information, decoupled from *Server. The LIVE cmd/server router (buildRouter) registers /version with THIS handler UNDER its fail-closed auth guard (§G24), matching api/openapi.yaml's 401 posture for the endpoint. handleVersion (the dead *Server path) delegates here so there is a single version implementation.
func WithDBPool ¶
WithDBPool stores a *db.Pool in the context. Called by the server setup code when constructing the Gin engine.
func WithDBPoolMiddleware ¶
func WithDBPoolMiddleware(pool *db.Pool) gin.HandlerFunc
WithDBPoolMiddleware returns a Gin middleware that injects the *db.Pool into the request context. This must run BEFORE TenantMiddleware so that tenant resolution can query the tenants table.
§11.4.84 DB pool context injection.
func WithTenant ¶
func WithTenant(ctx context.Context, tc *TenantContext) context.Context
WithTenant returns a child context that carries the given TenantContext. Use this to propagate tenant identity into goroutines, background jobs, or any code path that runs outside the Gin handler chain.
Types ¶
type AuditEntry ¶
type AuditEntry struct {
// TenantID is the UUID of the tenant that made the request.
TenantID uuid.UUID
// Action is a high-level verb inferred from the HTTP method
// (CREATE, READ, UPDATE, DELETE, LIST, UNKNOWN).
Action string
// Resource is the primary resource path segment (e.g. "skills").
Resource string
// Method is the raw HTTP method (GET, POST, ...).
Method string
// Path is the full request path.
Path string
// StatusCode is the HTTP response status code.
StatusCode int
// Timestamp is when the request started.
Timestamp time.Time
// RequestID is the unique request identifier from the RequestID middleware.
RequestID string
// Duration is the wall-clock time the handler took to respond.
Duration time.Duration
}
AuditEntry represents a single auditable request event.
type AuditLogger ¶
type AuditLogger interface {
// Log enqueues an audit entry for asynchronous persistence.
Log(entry AuditEntry)
// Stop flushes any buffered entries and shuts down the writer goroutine.
Stop()
}
AuditLogger defines the contract for writing audit entries. Implementations may write to a database, a file, or an external system.
type BrotliWriter ¶
type BrotliWriter struct {
gin.ResponseWriter
// contains filtered or unexported fields
}
BrotliWriter wraps gin.ResponseWriter with Brotli compression.
func (*BrotliWriter) Write ¶
func (w *BrotliWriter) Write(data []byte) (int, error)
Write implements http.ResponseWriter.
func (*BrotliWriter) WriteString ¶
func (w *BrotliWriter) WriteString(s string) (int, error)
WriteString implements gin.ResponseWriter.
type DBAuditLogger ¶
type DBAuditLogger struct {
// contains filtered or unexported fields
}
DBAuditLogger writes audit entries asynchronously to the tenant_audit_log table. It is safe for concurrent use.
func NewDBAuditLogger ¶
func NewDBAuditLogger(pool *db.Pool, logger *zap.Logger) *DBAuditLogger
NewDBAuditLogger creates and starts a new database-backed audit logger. The returned logger's writer goroutine runs until Stop() is called.
func (*DBAuditLogger) Log ¶
func (dal *DBAuditLogger) Log(entry AuditEntry)
Log enqueues an audit entry for asynchronous persistence. If the buffer is full the entry is dropped with a warning — this is a deliberate trade-off to avoid blocking the request path.
func (*DBAuditLogger) Stop ¶
func (dal *DBAuditLogger) Stop()
Stop signals the writer goroutine to flush remaining entries and exit. It blocks until the goroutine finishes.
type ErrorResponse ¶
type ErrorResponse struct {
Error string `json:"error" toml:"error"`
Code string `json:"code" toml:"code"`
Details string `json:"details,omitempty" toml:"details,omitempty"`
}
ErrorResponse is the standardized error envelope returned by the API.
type HealthResponse ¶
type HealthResponse struct {
Status string `json:"status" toml:"status"`
Timestamp time.Time `json:"timestamp" toml:"timestamp"`
Uptime string `json:"uptime" toml:"uptime"`
Checks map[string]string `json:"checks,omitempty" toml:"checks,omitempty"`
Version string `json:"version" toml:"version"`
}
HealthResponse represents the health check response.
type PaginatedResponse ¶
type PaginatedResponse[T any] struct { Data []T `json:"data" toml:"data"` Total int `json:"total" toml:"total"` Limit int `json:"limit" toml:"limit"` Offset int `json:"offset" toml:"offset"` }
PaginatedResponse wraps a list response with pagination metadata.
type RateLimitConfig ¶
type RateLimitConfig struct {
// RequestsPerSecond is the steady-state token refill rate per client key.
RequestsPerSecond float64
// Burst is the token-bucket depth: the maximum instantaneous number of
// requests a single client key may make before being throttled.
Burst int
// TTL is the idle window after which an unused per-client limiter entry is
// evicted so the tracking map does not retain long-idle keys.
TTL time.Duration
// MaxClients is the HARD upper bound on the number of distinct client keys
// tracked at once. When it is reached, the least-recently-used entries are
// evicted to make room, so a distinct-key/IP flood can never grow the map
// without bound (F2). A non-positive value clamps to a safe default.
MaxClients int
}
RateLimitConfig configures the per-client token-bucket limiter (§G22).
type RateLimiter ¶
type RateLimiter struct {
// contains filtered or unexported fields
}
RateLimiter is a per-client token-bucket rate limiter (§G22). It keeps one *rate.Limiter per client key so one client's flood cannot starve another (per-client isolation). The tracking map is bounded by TWO independent mechanisms so it genuinely cannot grow without bound: (1) idle keys are reaped after TTL, and (2) a HARD MaxClients cap evicts the least-recently-used entry the instant a new key would exceed it — so even a burst of distinct keys faster than the reap interval stays bounded (F2). It is safe for concurrent use: every map/list mutation is performed under mu.
func NewRateLimiter ¶
func NewRateLimiter(cfg RateLimitConfig) *RateLimiter
NewRateLimiter builds a per-client token-bucket limiter from cfg. Non-positive rate/burst/TTL/MaxClients values are clamped to safe defaults rather than producing a limiter that throttles every request or an unbounded map.
func (*RateLimiter) Allow ¶
func (rl *RateLimiter) Allow(key string) bool
Allow reports whether a request under key may proceed right now. It is the unit-testable core of the middleware.
func (*RateLimiter) Middleware ¶
func (rl *RateLimiter) Middleware() gin.HandlerFunc
Middleware returns a Gin middleware that rejects a request with HTTP 429 when the client's token bucket is empty, and otherwise lets it proceed. It is installed BEFORE authentication on the live router so throttling precedes credential checks (a flood cannot force the auth path). A throttled response carries a Retry-After hint (W3) so a well-behaved client knows when to retry.
type ResponseFormat ¶
type ResponseFormat string
ResponseFormat indicates the serialization format for API responses.
const ( // FormatJSON is the fallback wire format (safety net — a client that cannot // speak TOON still works, §11.4.6). FormatJSON ResponseFormat = "json" // FormatTOML is supported via Accept: application/toml header. FormatTOML ResponseFormat = "toml" // FormatTOON is the primary token-oriented wire format (register G08), // selected via Accept: application/toon (or text/x-toon) or ?format=toon. FormatTOON ResponseFormat = "toon" )
func GetResponseFormat ¶
func GetResponseFormat(c *gin.Context) ResponseFormat
GetResponseFormat retrieves the negotiated format from the Gin context. Defaults to JSON when none is set.
type TenantContext ¶
type TenantContext struct {
// TenantID is the UUID primary key from the tenants table (004_enterprise).
TenantID uuid.UUID
// TenantName is the human-readable tenant name, populated when the tenant
// is loaded from the database. May be empty if only the header UUID was
// provided without a DB lookup.
TenantName string
}
TenantContext carries the resolved tenant identity for the current request. Handlers must treat TenantID as authoritative for all data-access scoping; TenantName is informational (convenience for logging and audit).
func TenantFromContext ¶
func TenantFromContext(ctx context.Context) *TenantContext
TenantFromContext extracts the TenantContext stored in ctx. Returns nil when no tenant has been set (e.g. in unauthenticated code paths or tests).
func TenantFromDB ¶
TenantFromDB loads a tenant record from the tenants table by UUID. Returns a populated TenantContext or an error when the tenant is not found.
§11.4.84 Tenant DB lookup.
func TenantFromGinContext ¶
func TenantFromGinContext(c *gin.Context) *TenantContext
TenantFromGinContext retrieves the TenantContext from a gin.Context. Returns nil when no tenant has been resolved for the request.
type TenantRateLimitConfig ¶
type TenantRateLimitConfig struct {
// RequestsPerMinute is the steady-state refill rate converted to tokens
// per second (= RequestsPerMinute / 60).
RequestsPerMinute int
// BurstSize is the maximum instantaneous number of requests a tenant may
// make before being throttled (the token-bucket depth).
BurstSize int
// ReaperInterval controls how often the background goroutine scans for
// idle tenants. Zero defaults to 5 minutes.
ReaperInterval time.Duration
// ReaperTTL is the idle window after which an unused per-tenant limiter
// entry is evicted. Zero defaults to 1 hour.
ReaperTTL time.Duration
}
TenantRateLimitConfig controls the per-tenant token-bucket rate limiter.
type TenantRateLimiter ¶
type TenantRateLimiter struct {
// contains filtered or unexported fields
}
TenantRateLimiter manages per-tenant rate limiters backed by token buckets. It is safe for concurrent use from multiple goroutines.
func NewTenantRateLimiter ¶
func NewTenantRateLimiter(cfg TenantRateLimitConfig) *TenantRateLimiter
NewTenantRateLimiter creates and starts a new per-tenant rate limiter. The returned limiter's background reaper goroutine runs until Stop() is called.
func (*TenantRateLimiter) Allow ¶
func (trl *TenantRateLimiter) Allow(tenantID uuid.UUID) bool
Allow checks whether a request from the given tenant is within the rate limit. Returns true if the request should proceed, false if it should be throttled.
func (*TenantRateLimiter) Len ¶
func (trl *TenantRateLimiter) Len() int
Len returns the number of tracked tenant limiters (useful for metrics and testing).
func (*TenantRateLimiter) Stop ¶
func (trl *TenantRateLimiter) Stop()
Stop terminates the background reaper goroutine. The limiter itself remains usable — Stop is only needed for graceful shutdown.
type VersionResponse ¶
type VersionResponse struct {
Version string `json:"version" toml:"version"`
Commit string `json:"commit" toml:"commit"`
BuildTime string `json:"build_time" toml:"build_time"`
GoVersion string `json:"go_version" toml:"go_version"`
Platform string `json:"platform" toml:"platform"`
}
VersionResponse represents the version information response.