httpx

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Jul 6, 2026 License: Apache-2.0 Imports: 24 Imported by: 0

Documentation

Overview

Package httpx is wowapi's HTTP toolbox: response envelopes, the RFC 9457 problem-details error writer, strict JSON decoding, metadata-enforced route registration, and the request helpers module handlers compose. The kernel provides helpers; modules keep the request flow visible (blueprint 05 §1, 04 §4–5).

Index

Constants

View Source
const IdempotencyHeader = "Idempotency-Key"

IdempotencyHeader is the client header carrying the idempotency key.

Variables

This section is empty.

Functions

func ActorFrom

func ActorFrom(ctx context.Context) (authz.Actor, bool)

ActorFrom extracts the authenticated principal; ok=false when none is bound.

func BindAndValidate

func BindAndValidate[T any](r *http.Request, v *validation.Validator, maxBytes int64) (T, error)

BindAndValidate decodes the body (strict) and then runs struct-tag validation, returning KindValidation with field errors on failure (blueprint 05 §1). maxBytes bounds the body.

func Chain

func Chain(h http.Handler, mws ...Middleware) http.Handler

Chain applies middlewares so that the first listed runs outermost.

func DecodeJSON

func DecodeJSON[T any](r *http.Request, maxBytes int64) (T, error)

DecodeJSON strict-decodes the request body into T: unknown fields are rejected (typo defense), the body is size-capped, and exactly one JSON value is required. All failures map to KindValidation — a malformed body is a client error, never a 500. The raw body content is never echoed into the error (redaction discipline).

func ETagFrom

func ETagFrom(version int) string

ETagFrom renders a strong ETag from an aggregate's optimistic-lock version. Clients echo it back via If-Match on mutating requests.

func KeyByActor

func KeyByActor(r *http.Request) string

KeyByActor derives a per-principal, tenant-scoped bucket key so one tenant's callers can never share a limiter bucket with another's (cross-tenant DoS). It is stable per principal even for machine callers, whose audit CapacityID is uuid.Nil for every API-key / system / webhook actor — keying on that id alone would collapse ALL machine traffic across ALL tenants into one bucket.

The key is always prefixed with the bound tenant id, then the strongest available principal identifier: the user's capacity, else an api-key/system name, else the JWT subject (user id). If no principal identifier is available it falls back to per-IP — still tenant-prefixed when a tenant is bound — rather than a single shared bucket. Place RateLimit AFTER the authz gate to use this.

func KeyByIP

func KeyByIP(r *http.Request) string

KeyByIP keys on the client IP (RemoteAddr host). Behind the reference proxy, which sets X-Real-IP / X-Forwarded-For, a product that trusts its proxy should supply a keyFn reading the forwarded header instead.

func ParseFilters

func ParseFilters(r *http.Request, allow filtering.Allowlist) (filtering.Set, error)

ParseFilters collects filter.<field>=<op>:<value> query params and parses them against the allowlist. Unknown fields or disallowed operators are KindValidation errors — the client can only ever reference allowlisted columns, and values always become bound parameters.

func ParsePagination

func ParsePagination(r *http.Request, def pagination.Defaults) (pagination.Request, error)

ParsePagination reads per_page + cursor and returns the clamped page request.

func ParseResourceID

func ParseResourceID(r *http.Request, param string) (uuid.UUID, error)

ParseResourceID reads a path parameter (net/http 1.22 pattern wildcard) and parses it as a UUID. A missing or malformed id is KindValidation.

func ParseSort

func ParseSort(r *http.Request, allow filtering.SortAllowlist) (filtering.Sort, error)

ParseSort reads the sort query parameter against the sort allowlist.

func RequestHash

func RequestHash(r *http.Request, canonical []byte) string

RequestHash produces the stable hash stored with an idempotency key so a retry with the SAME key but a DIFFERENT request is rejected (409). Callers pass the canonical bytes of the decoded+validated command (the raw body is already consumed by decoding), which the kernel binds to method, path, AND query string (SEC-19: two requests differing only in query params must not share a stored response). Callers MUST pass deterministic, non-empty canonical bytes; an empty canonical weakens the different-request check.

func RequestIDFrom

func RequestIDFrom(ctx context.Context) string

RequestIDFrom returns the request id, or "" if none was set.

func RequireIfMatch

func RequireIfMatch(r *http.Request) (int, error)

RequireIfMatch parses the mandatory If-Match header into the version the client last saw. A missing header is KindValidation (the client must opt into optimistic concurrency); a malformed one is likewise a client error. The caller compares the returned version against the row's version and returns KindVersionConflict (412) on mismatch.

The wildcard "If-Match: *" is intentionally NOT accepted: wowapi's optimistic concurrency requires the client to assert the concrete version it observed, so "match any" would defeat the guard it opts into (review finding ARCH-34).

func WithActor

func WithActor(ctx context.Context, a authz.Actor) context.Context

WithActor returns a context carrying the full authenticated principal. The authz gate sets it after AuthN so downstream middleware/helpers (e.g. KeyByActor) can identify the caller by its strongest identifier — not just the audit CapacityID bound via database.WithActorID, which is uuid.Nil for every machine principal.

func WithIdempotency

func WithIdempotency(ctx context.Context, w http.ResponseWriter, r *http.Request, tx database.TxManager, cfg IdempotencyConfig, requestHash string, op Operation)

WithIdempotency executes op at most once per (tenant, actor, Idempotency-Key) and writes the response. Without the header it simply runs op in one tenant transaction. With the header it replays a stored response on retry, rejects a reused key carrying a different request (409 conflict), and rejects a still in-flight duplicate (409 retry_later) — the key claim, the business writes, and the stored response all commit in the same transaction (blueprint 05 §1–2). It owns the response so replay is transparent to the handler.

func WithRequestID

func WithRequestID(ctx context.Context, id string) context.Context

WithRequestID returns a context carrying the request correlation id.

func WriteError

func WriteError(ctx context.Context, w http.ResponseWriter, err error)

WriteError translates any error into a problem-details response. An *Error contributes its Kind (→ status/code), user-safe Msg, and field errors; any other error is rendered as an opaque 500 whose cause never reaches the wire (blueprint 04 §5). Observability logging is wired by the recover/log middleware; this function only shapes the response.

func WriteJSON

func WriteJSON[T any](w http.ResponseWriter, status int, body T)

WriteJSON serializes body as JSON with the given status. It sets the content type before writing the status line. A marshal failure (a programming bug — DTOs must be marshalable) degrades to a 500 problem body.

Types

type APIResponse

type APIResponse[T any] struct {
	Data T     `json:"data"`
	Meta *Meta `json:"meta,omitempty"`
}

APIResponse is the success envelope. Data is the resource DTO; Meta is optional (request id, audit info).

func OK

func OK[T any](data T) APIResponse[T]

OK wraps a DTO in the success envelope with no meta.

func OKWithMeta

func OKWithMeta[T any](data T, meta *Meta) APIResponse[T]

OKWithMeta wraps a DTO with meta.

type AuditMeta

type AuditMeta struct {
	CreatedAt time.Time  `json:"created_at"`
	CreatedBy uuid.UUID  `json:"created_by"`
	UpdatedAt *time.Time `json:"updated_at,omitempty"`
	Version   int        `json:"version"`
}

AuditMeta echoes the persisted audit columns; clients return Version via If-Match for optimistic concurrency.

func AuditMetaFrom

func AuditMetaFrom(a model.Auditable, version int) *AuditMeta

AuditMetaFrom builds response AuditMeta from a model.Auditable and version.

type Authenticator

type Authenticator interface {
	Authenticate(r *http.Request) (authz.Actor, error)
}

Authenticator resolves a request's authenticated actor (carrying its tenant). The identity/tenant strategy (OIDC verification, tenant resolution) is deployment-specific, so a product supplies the concrete implementation; the framework never hardcodes the identity source. A returned error becomes the HTTP response (return a KindUnauthenticated error for a 401).

func Composite

func Composite(auths ...Authenticator) Authenticator

Composite tries each authenticator in order and returns the first successful actor — the way a product runs API-key and OIDC auth side by side (roadmap S1/CA-2). An authenticator that returns a KindUnauthenticated error is treated as "not my scheme" and the next is tried; any OTHER error (e.g. the key store is unreachable) short-circuits so a transient fault is not misreported as a clean 401. If every authenticator declines, the last unauthenticated error is returned. With no authenticators it fails closed.

type CORSPolicy

type CORSPolicy struct {
	AllowedOrigins   []string // exact-match origins; no wildcards (deny-by-default)
	AllowedMethods   []string // default: GET, POST, PUT, PATCH, DELETE, OPTIONS
	AllowedHeaders   []string // default: Authorization, Content-Type, X-Request-Id, Idempotency-Key
	ExposedHeaders   []string // response headers the browser may read
	AllowCredentials bool     // send Access-Control-Allow-Credentials
	MaxAge           time.Duration
}

CORSPolicy is the per-environment CORS allowlist (blueprint 07 §1). The zero policy allows no origins — CORS is deny-by-default, consistent with the rest of the framework. Products load the allowlist from their env config.

type DenyAllAuthenticator

type DenyAllAuthenticator struct{}

DenyAllAuthenticator rejects every request as unauthenticated. It is the SECURE DEFAULT: a freshly-scaffolded API enforces deny-by-default (every non-Public route → 401) until a real Authenticator is wired, rather than serving business routes unguarded.

func (DenyAllAuthenticator) Authenticate

func (DenyAllAuthenticator) Authenticate(*http.Request) (authz.Actor, error)

Authenticate always fails closed.

type Health

type Health struct {
	// contains filtered or unexported fields
}

Health aggregates readiness checks and serves the two health endpoints.

func NewHealth

func NewHealth(fingerprint string) *Health

NewHealth builds a health aggregator. fingerprint is the redacted config fingerprint (a hash — safe to expose) reported by /readyz.

func (*Health) Liveness

func (h *Health) Liveness() http.HandlerFunc

Liveness answers 200 as long as the process is running — it runs NO checks (a failing dependency must not make the process get killed by a liveness probe; that is readiness' job).

func (*Health) Readiness

func (h *Health) Readiness() http.HandlerFunc

Readiness runs every check (each bounded by checkTimeout) and returns 200 when all pass, 503 otherwise, with a per-check status map + the config fingerprint.

func (*Health) Register

func (h *Health) Register(name string, c HealthCheck) *Health

Register adds a named readiness check (chainable). A nil check is ignored.

type HealthCheck

type HealthCheck func(context.Context) error

HealthCheck reports readiness for one subsystem; a non-nil error = not ready.

type IdempotencyConfig

type IdempotencyConfig struct {
	Store      database.IdemStore
	ActorScope string        // capacity id / system actor; scopes the key
	TTL        time.Duration // how long a stored response is replayable
}

IdempotencyConfig configures WithIdempotency.

type Meta

type Meta struct {
	RequestID string     `json:"request_id"`
	Audit     *AuditMeta `json:"audit,omitempty"`
}

Meta carries response metadata.

type Middleware

type Middleware func(http.Handler) http.Handler

Middleware wraps an http.Handler. The kernel provides the cross-cutting concerns (request id, panic recovery, error-safe logging); auth/tenant middleware is added in Phase 4.

func BodyLimit

func BodyLimit(maxBytes int64) Middleware

BodyLimit caps the request body at maxBytes by wrapping r.Body in an http.MaxBytesReader. Any read past the limit fails with *http.MaxBytesError, which DecodeJSON maps to 413; handlers that read the body directly see the same error. maxBytes <= 0 disables the cap.

func CORS

func CORS(p CORSPolicy) Middleware

CORS enforces an origin allowlist. A request whose Origin is on the list gets that exact origin echoed back (never "*", so it composes with credentials); a preflight (OPTIONS + Access-Control-Request-Method) is answered with 204 and never reaches the handler. Requests from disallowed origins are served without CORS headers — the browser, not the server, enforces the block.

func RateLimit

func RateLimit(limiter RateLimiter, keyFn func(*http.Request) string, opts ...RateLimitOption) Middleware

RateLimit rejects requests that exceed the limiter with 429 + Retry-After. The keyFn derives the bucket key from the request (see KeyByIP / KeyByActor). A nil keyFn defaults to KeyByIP.

func Recover

func Recover(logger *slog.Logger) Middleware

Recover converts a panic into a 500 problem response: the stack goes to the logger and a panic metric, never to the wire (blueprint 04 §5). It must be the outermost middleware around handlers.

func RequestID

func RequestID() Middleware

RequestID assigns a correlation id (honoring an inbound X-Request-Id when present) and stores it in the context and the response header, so every log line and problem body can be correlated.

func SecureHeaders

func SecureHeaders(opts ...SecureHeadersOption) Middleware

SecureHeaders sets the baseline response security headers on every response (blueprint 07 §1): nosniff, frame-ancestors 'none', HSTS, and a strict Referrer-Policy. Headers are set before the handler runs so they are present even on handler-written error responses.

func Timeout

func Timeout(d time.Duration) Middleware

Timeout enforces a per-request deadline. On expiry the request context is cancelled (so in-flight DB work aborts) and a 503 is written. d <= 0 disables the timeout. Built on http.TimeoutHandler, which buffers the handler's response so a late write cannot corrupt the timeout reply.

type Operation

type Operation func(ctx context.Context, db database.TenantDB) (status int, body any, err error)

Operation is a mutating handler body: it runs inside a tenant transaction and returns the HTTP status and response DTO to write (and store, when idempotent). Returning an error rolls the transaction back and nothing is stored.

type ProblemError

type ProblemError struct {
	Type      string              `json:"type"`
	Title     string              `json:"title"`
	Status    int                 `json:"status"`
	Detail    string              `json:"detail,omitempty"`
	Instance  string              `json:"instance,omitempty"`
	Code      string              `json:"code"`
	RequestID string              `json:"request_id"`
	Errors    []errors.FieldError `json:"errors,omitempty"`
}

ProblemError is the RFC 9457 problem-details body — the ONLY error shape the API emits (blueprint 04 §4). It never carries internal detail: Op, wrapped causes, and stack traces stay in logs.

type RateLimitOption

type RateLimitOption func(*rateLimitCfg)

RateLimitOption customizes the RateLimit middleware.

func OnRateLimitDrop

func OnRateLimitDrop(fn func(route string)) RateLimitOption

OnRateLimitDrop registers a callback fired whenever a request is rejected (429). The composition root wires this to a metrics counter — httpx must not import kernel/observability (observability imports httpx), so the emission is injected as a plain callback (roadmap CA-1). route is r.Pattern.

type RateLimiter

type RateLimiter interface {
	Allow(key string) (allowed bool, retryAfter time.Duration)
}

RateLimiter decides whether a request keyed by `key` may proceed now. When it may not, retryAfter is a hint for the Retry-After header.

type Route

type Route struct {
	Method  string
	Pattern string
	Meta    RouteMeta
	Handler http.HandlerFunc
}

Route is a registered route with its metadata, exposed for permission-sync and OpenAPI generation (later phases).

type RouteMeta

type RouteMeta struct {
	// Permission is the permission key required to call the route. Empty only
	// when Public is true.
	Permission string
	// Public opts a route out of authz (health, pre-verification webhooks).
	Public bool
	// Scope derives the authz target from the request (optional).
	Scope ScopeExtractor
	// Idempotent enables idempotency-key handling for unsafe methods.
	Idempotent bool
	// Sensitive forces an audit record even for reads.
	Sensitive bool
}

RouteMeta is mandatory metadata for every route. There is deliberately no registration path without it: a route is either guarded by a Permission or explicitly Public, never neither and never both (blueprint 05 §1).

type Router

type Router struct {
	// contains filtered or unexported fields
}

Router collects routes with enforced metadata. Registration errors are accumulated and surfaced by Err/Build, so application boot fails with the full list (consistent with config/app validation).

func NewRouter

func NewRouter() *Router

NewRouter returns an empty Router.

func (*Router) Err

func (r *Router) Err() error

Err returns the accumulated registration errors joined, or nil. Callers (app boot) must check this before serving.

func (*Router) Handle

func (r *Router) Handle(method, pattern string, meta RouteMeta, h http.HandlerFunc)

Handle registers a route. Invalid metadata (or a duplicate method+pattern) records an error retrievable via Err() — it does not panic, so a module's whole route set is validated at once.

func (*Router) Permissions

func (r *Router) Permissions() []string

Permissions returns the set of non-empty permission keys the routes require, sorted — the input to the Phase 4 permission-registration sync.

func (*Router) Routes

func (r *Router) Routes() []Route

Routes returns the registered routes in a deterministic order (for permission sync, OpenAPI, and tests).

func (*Router) SecureHandler

func (r *Router) SecureHandler(auth Authenticator, eval authz.Evaluator, txm database.TxManager) *http.ServeMux

SecureHandler builds the serving mux with every route wrapped by the authN→authZ(RouteMeta) gate. Public routes are served directly; every other route is authenticated and authorized against its declared permission before its handler runs. Health endpoints (added by the caller after this) are Public infrastructure and are mounted directly.

type ScopeExtractor

type ScopeExtractor func(r *http.Request) (authz.Target, error)

ScopeExtractor derives the authorization target (org/resource id) from a request; the auth middleware (Phase 5) passes it to authz.Evaluate. Returning an error fails the request as a client error (review finding ARCH-43 — now typed to authz.Target).

type SecureHeadersOption

type SecureHeadersOption func(*secureHeadersConfig)

SecureHeadersOption tunes the SecureHeaders middleware.

func WithCSP

func WithCSP(value string) SecureHeadersOption

WithCSP overrides the Content-Security-Policy value. The default only asserts frame-ancestors 'none' (clickjacking defense for a JSON API); products that serve HTML should set a fuller policy.

func WithHSTS

func WithHSTS(value string) SecureHeadersOption

WithHSTS overrides the Strict-Transport-Security value.

func WithoutHSTS

func WithoutHSTS() SecureHeadersOption

WithoutHSTS disables the Strict-Transport-Security header — use only where TLS is not terminated in front of the service (e.g. local plain-HTTP).

type TokenBucket

type TokenBucket struct {
	// contains filtered or unexported fields
}

TokenBucket is an in-memory per-key token-bucket RateLimiter. Each key refills at `rate` tokens/sec up to `burst`. Idle buckets are swept so the map cannot grow without bound under a spray of distinct keys.

func NewTokenBucket

func NewTokenBucket(ratePerSec float64, burst int) *TokenBucket

NewTokenBucket builds a limiter of rate tokens/sec with the given burst.

func NewTokenBucketWithClock

func NewTokenBucketWithClock(ratePerSec float64, burst int, now func() time.Time) *TokenBucket

NewTokenBucketWithClock is NewTokenBucket with an injectable clock (tests).

func (*TokenBucket) Allow

func (tb *TokenBucket) Allow(key string) (bool, time.Duration)

Allow consumes one token for key, refilling first. On denial it returns the time until one token is available.

Jump to

Keyboard shortcuts

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