api

package
v0.0.0-...-b165b9f Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 23, 2026 License: Apache-2.0 Imports: 30 Imported by: 0

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):

  1. X-Tenant-ID header — explicit tenant selection (highest priority).
  2. API key → tenant mapping — implicit tenant from the authenticated key.
  3. Default tenant — fallback when cfg.DefaultTenant is set.
  4. 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

View Source
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

View Source
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

func NegotiateResponse(c *gin.Context, status int, data interface{})

NegotiateResponse serializes the response in the negotiated wire format (TOON primary, TOML, or JSON fallback) based on content negotiation.

func PoolFromContext

func PoolFromContext(ctx context.Context) *db.Pool

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

func ResolveAPIKeyAuth(apiKeys []string, authDisabled bool, logger *zap.Logger) gin.HandlerFunc

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

func RespondError(c *gin.Context, status int, message string)

RespondError writes a structured error response in the negotiated format.

func RespondErrorWithCode

func RespondErrorWithCode(c *gin.Context, status int, code, message string)

RespondErrorWithCode writes a structured error response with a custom error code.

func RespondJSON

func RespondJSON(c *gin.Context, status int, data interface{})

RespondJSON writes a JSON response with the given HTTP status code.

func RespondPaginated

func RespondPaginated[T any](c *gin.Context, status int, data []T, total, limit, offset int)

RespondPaginated writes a paginated response in the negotiated format.

func RespondTOML

func RespondTOML(c *gin.Context, status int, data interface{})

RespondTOML writes a TOML response with the given HTTP status code.

func RespondTOON

func RespondTOON(c *gin.Context, status int, data interface{})

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

func TenantIDFromContext(ctx context.Context) uuid.UUID

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:

  1. X-Tenant-ID header — an explicit UUID selects the tenant directly.
  2. API key → tenant mapping — looks up the tenant associated with the authenticated API key (requires APIKeyAuth to have run first).
  3. cfg.DefaultTenant — when set, used as the fallback tenant UUID.
  4. 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

func WithDBPool(ctx context.Context, pool *db.Pool) context.Context

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

func TenantFromDB(pool *db.Pool, tenantID uuid.UUID) (*TenantContext, error)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL