httpx

package
v0.1.2 Latest Latest
Warning

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

Go to latest
Published: Feb 13, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package httpx provides small, stable and standard-library flavored net/http helpers.

This package focuses on net/http handler composition (middleware chains). It intentionally does not provide a router.

Middleware chain

A middleware is a standard net/http wrapper:

type Middleware func(http.Handler) http.Handler

The chain builder is just a slice:

type Middlewares []Middleware

Order:

  • Chain(a, b, c).Handler(h) returns a(b(c(h))).

Behavior:

  • Nil middlewares are ignored.
  • Handler(nil) and Wrap(nil, ...) panic: nil endpoint is an assembly/config error.

Example: build a chain and apply to a handler

base := httpx.Chain(mwRecover, mwRequestID, mwAccessLog)
h := base.Handler(finalHandler) // == mwRecover(mwRequestID(mwAccessLog(finalHandler)))

Example: derive sub-chains

base := httpx.Chain(mwRecover, mwRequestID, mwAccessLog)
admin := base.With(mwAdminAuth)    // base + admin auth
download := base.With(mwBodyLimit) // base + body limit

With never mutates the receiver; it returns a new derived chain.

Positioning note

httpx (its chain and built-in middlewares) primarily serves zkit's admin/ops surfaces. You can ignore it entirely and use your existing middleware stack and router (chi, gorilla/mux, etc.).

If you prefer a standard-library-only toolset with explicit, low-magic semantics, httpx is also usable in your own business HTTP serving without introducing external dependencies.

Apply to net/http ServeMux (no router required)

mux := http.NewServeMux()
mux.Handle("/-/healthz", healthHandler)
mux.Handle("/api/", apiHandler)

base := httpx.Chain(mwRecover, mwRequestID, mwAccessLog)
root := base.Handler(mux) // wrap the whole mux

Observability: ChainHandler

Middlewares.Handler / HandlerFunc returns a *httpx.ChainHandler, which keeps:

  • Endpoint: the final handler

  • Middlewares: the (nil-filtered) middleware snapshot used to build the chain

    ch := httpx.Chain(mwRecover).Handler(finalHandler).(*httpx.ChainHandler) _ = ch.Endpoint _ = ch.Middlewares

Built-in middlewares

  • Recover: recover panics and report (stderr by default).
  • RequestID: propagate/generate X-Request-ID and store it in context.
  • RealIP: extract client IP from trusted proxy headers (default-safe).
  • AccessGuard: allow/deny requests by token/IP/custom check (fail-closed defaults).
  • Timeout: derive request context with deadline (does not write response).
  • BodyLimit: enforce request body size (early reject on Content-Length + MaxBytesReader).
  • CORS: write CORS headers and short-circuit preflight (debug-friendly defaults).

Middleware options quick reference (admin-oriented)

This section summarizes the exported options/parameters for each built-in middleware so that assembly layers (such as github.com/evan-idocoding/zkit/admin) can wire them explicitly without missing knobs.

Recover (RecoverOption):

  • WithOnPanic(PanicHandler): override panic reporting (default: stderr).

RequestID (RequestIDOption):

  • WithIncomingHeaders([]string): header priority for incoming id (default: ["X-Request-ID"]).
  • WithTrustIncoming(bool): whether to accept incoming ids (default: true).
  • WithSetResponseHeader(bool): whether to set X-Request-ID on the response (default: true).
  • WithMaxLen(int): max allowed length for incoming id (default: 128).
  • WithValidator(func(string) bool): validate incoming id (default: conservative [A-Za-z0-9._-] + length).
  • WithGenerator(RequestIDGenerator): custom generator (fallback to internal generator).

Helpers:

  • RequestIDFromRequest / RequestIDFromContext
  • WithRequestID

RealIP (RealIPOption):

  • WithTrustedProxies([]string): trusted proxy CIDRs/IPs; without it, headers are ignored (default-safe).
  • WithTrustedHeaders([]string): header priority (default: ["X-Forwarded-For", "X-Real-IP"]).
  • WithXFFInvalidPolicy(XFFInvalidPolicy): how XFF scanning handles invalid tokens (default: stop).

Helpers:

  • ParseTrustedProxies (strict parse; returns partial result + error)
  • RealIPFromRequest / RealIPFromContext
  • WithRealIP

AccessGuard (AccessGuardOption): Token branch (optional):

  • WithTokenHeader(string): token header name (default: "X-Access-Token").
  • WithTokens([]string): static token allowlist (empty => deny-all, fail-closed).
  • WithTokenSet(TokenSetLike): hot-update token set.
  • WithTokenCheck(func(string) bool): fully custom token predicate.

IP branch (optional):

  • WithIPAllowList([]string): static IP allowlist (empty => deny-all, fail-closed).
  • WithIPAllowSet(IPAllowSetLike): hot-update allow set.
  • WithIPResolver(func(*http.Request) (net.IP, bool)): override IP extraction (default: RealIPFromRequest when present, else RemoteAddr).

Composition / hooks:

  • WithOr(): combine token+IP with OR instead of default AND.
  • WithCheck(func(*http.Request) bool): exclusive custom validator (cannot combine with token/IP options).
  • WithDenyStatus(int): override deny HTTP status (default: 403).
  • WithOnDeny(func(*http.Request, DenyReason)): observability hook on deny (must not write response).

Helper types (for hot updates):

  • AtomicTokenSet (implements TokenSetLike)
  • AtomicIPAllowList (implements IPAllowSetLike)

Timeout (TimeoutOption):

  • Timeout(timeout time.Duration, ...): base timeout parameter; <= 0 means "skip".
  • WithTimeoutFunc(TimeoutFunc): per-request timeout decision (can skip).
  • WithOnTimeout(TimeoutHandler): observability hook when derived context ends with DeadlineExceeded.
  • WithNow(func() time.Time): custom clock (tests).

BodyLimit (BodyLimitOption):

  • BodyLimit(maxBytes int64, ...): base limit; <= 0 means "skip".
  • WithLimitFunc(BodyLimitFunc): per-request limit decision (can skip).
  • WithOnReject(BodyLimitHandler): observability hook on early reject (Content-Length) or read-time exceed (*http.MaxBytesError).

CORS (CORSOption):

  • WithEnabledFunc(func(*http.Request) bool): per-request enable toggle (skip entirely when false).
  • WithMatchFunc(func(*http.Request) bool): per-request matcher (skip entirely when false).
  • WithAllowCredentials(bool): default true.
  • WithAllowNullOrigin(bool): default false when allowlist configured; Origin:"null" handling.
  • WithMaxAge(time.Duration): preflight Access-Control-Max-Age (default 10m; <=0 disables header).
  • WithPreflightStatus(int): 200 or 204 (default 204).
  • WithExposeHeaders([]string) / WithExposeHeadersAppend([]string): default exposes X-Request-ID.
  • WithAllowedMethods([]string): restrict methods (invalid non-empty config => deny-all).
  • WithAllowedHeaders([]string): restrict request headers for preflight (invalid non-empty config => deny-all).
  • WithAllowedOrigins([]string): origin hostname allowlist (invalid non-empty config => deny-all).

Helpers:

  • ValidateOriginPatterns / CountValidOriginPatterns

AccessGuard middleware.

AccessGuard denies requests unless they pass configured checks. It supports:

  • token validation (from a header, default: X-Access-Token)
  • client IP allowlist (RealIP middleware when present; otherwise RemoteAddr)
  • a fully custom WithCheck predicate (exclusive)

Security defaults are fail-closed: enabling token/IP validation with an empty set denies all.

Minimal usage (static token allowlist):

h := httpx.Wrap(finalHandler, httpx.AccessGuard(httpx.WithTokens([]string{"t1", "t2"})))

Minimal usage (IP allowlist):

h := httpx.Wrap(finalHandler, httpx.AccessGuard(httpx.WithIPAllowList([]string{"10.0.0.0/8"})))

BodyLimit middleware.

BodyLimit enforces a maximum request body size:

  • It rejects early with 413 if Content-Length is known and exceeds the limit.
  • Otherwise it wraps r.Body with http.MaxBytesReader, so reads past the limit return *http.MaxBytesError.

Note: BodyLimit does not translate read-time errors into a response; downstream handlers that read the body must treat *http.MaxBytesError as 413 and stop processing.

Minimal usage:

h := httpx.Wrap(finalHandler, httpx.BodyLimit(1<<20)) // 1 MiB

CORS middleware.

CORS adds CORS headers for browser clients and may short-circuit successful preflight requests (OPTIONS + Access-Control-Request-Method).

Defaults are intentionally debug-friendly (allow any Origin). For production, you typically want to configure WithAllowedOrigins and optionally restrict methods/headers.

Ordering note: Place CORS before authentication/authorization middleware when you want preflight requests to succeed; otherwise browsers may fail cross-origin requests.

Minimal usage:

h := httpx.Wrap(finalHandler, httpx.CORS())

RealIP middleware.

RealIP extracts the real client IP address and stores it in the request context for downstream handlers (RealIPFromRequest).

Security model:

  • Default-safe: without trusted proxies configured, it ignores proxy headers and uses RemoteAddr.
  • When trusted proxies are configured, it trusts headers only when the direct client IP is trusted.

Minimal usage (behind a known load balancer):

h := httpx.Wrap(finalHandler,
	httpx.RealIP(httpx.WithTrustedProxies([]string{"10.0.0.0/8"})),
)

Recover middleware.

Recover returns a middleware that recovers panics from downstream handlers and keeps the server alive.

Behavior summary:

  • It re-panics http.ErrAbortHandler to preserve net/http semantics.
  • If the response has not started, it writes 500 Internal Server Error.
  • Panics are reported via PanicHandler (WithOnPanic) or to stderr by default.

Minimal usage:

h := httpx.Wrap(finalHandler, httpx.Recover())

RequestID middleware.

RequestID ensures each request has a request id, stored in context and (by default) echoed in the response header.

It is designed to be safe-by-default:

  • Incoming values are validated to avoid header/log pollution.
  • If incoming ids are not trusted/valid, it generates a new one.

Minimal usage:

h := httpx.Wrap(finalHandler, httpx.RequestID())

Extracting:

id, _ := httpx.RequestIDFromRequest(r)

Timeout middleware.

Timeout derives a request context with a deadline and passes it downstream. It is cooperative: it does not write a response body, and it does not start goroutines; downstream code must respect context cancellation.

When a derived context times out (DeadlineExceeded) after downstream returns, the optional TimeoutHandler (WithOnTimeout) can record observability signals (metrics/logs).

Minimal usage:

h := httpx.Wrap(finalHandler, httpx.Timeout(2*time.Second))

Index

Examples

Constants

View Source
const DefaultAccessGuardTokenHeader = "X-Access-Token"

DefaultAccessGuardTokenHeader is the default header name for AccessGuard token validation.

View Source
const DefaultRequestIDHeader = "X-Request-ID"

DefaultRequestIDHeader is the default header used for request ID propagation.

Variables

This section is empty.

Functions

func CountValidOriginPatterns

func CountValidOriginPatterns(origins []string) int

CountValidOriginPatterns counts how many entries in origins are valid origin patterns.

It follows the same parsing rules as WithAllowedOrigins:

  • empty/blank entries are ignored
  • entries may be hostname patterns (example.com, *.example.com) or full origins (https://example.com:8443), where only hostname is used

The returned count is typically used for strict, fail-fast validation in assembly layers.

func ParseTrustedProxies

func ParseTrustedProxies(cidrs []string) ([]*net.IPNet, error)

ParseTrustedProxies parses trusted proxy CIDRs/IPs.

It accepts CIDR notation (e.g., "10.0.0.0/8") or single IPs (e.g., "192.168.1.1"). Single IPs are treated as /32 (IPv4) or /128 (IPv6).

It returns the successfully parsed nets and an error if any entries were invalid. Empty/blank entries are ignored.

func RealIPFromContext

func RealIPFromContext(ctx context.Context) (net.IP, bool)

RealIPFromContext extracts the real IP from ctx.

Returns nil and false if the context does not contain a real IP (e.g., request did not pass through the RealIP middleware).

func RealIPFromRequest

func RealIPFromRequest(r *http.Request) (net.IP, bool)

RealIPFromRequest extracts the real IP from r.Context().

func RequestIDFromContext

func RequestIDFromContext(ctx context.Context) (string, bool)

RequestIDFromContext extracts the request id from ctx.

func RequestIDFromRequest

func RequestIDFromRequest(r *http.Request) (string, bool)

RequestIDFromRequest extracts the request id from r.Context().

func ValidateOriginPatterns

func ValidateOriginPatterns(origins []string) error

ValidateOriginPatterns performs a strict validation for origins used by WithAllowedOrigins.

It returns nil when:

  • origins is empty (meaning "allow any Origin"), or
  • at least one valid origin pattern is parsed.

It returns an error when origins is non-empty but no valid patterns can be parsed after ignoring blank entries. This is intended for fail-fast assembly layers to avoid accidentally denying all origins due to typos.

func WithRealIP

func WithRealIP(ctx context.Context, ip net.IP) context.Context

WithRealIP returns a derived context with ip stored as the real IP.

If ip is nil, it returns ctx unchanged.

func WithRequestID

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

WithRequestID returns a derived context with id stored as the request id.

If id is empty, it returns ctx unchanged.

func Wrap

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

Wrap applies middlewares to h and returns the wrapped handler.

Nil middlewares are ignored.

Types

type AccessGuardOption

type AccessGuardOption func(*accessGuardConfig)

AccessGuardOption configures the AccessGuard middleware.

func WithCheck

func WithCheck(fn func(r *http.Request) bool) AccessGuardOption

WithCheck sets a fully custom single-step validator.

Strong semantics:

  • If WithCheck is set, it MUST be the only validation mechanism. Combining it with token/IP options is an assembly/config error (panic).
  • fn must be fast and must not block; it must not do I/O.

func WithDenyStatus

func WithDenyStatus(code int) AccessGuardOption

WithDenyStatus sets the HTTP status code used for denied requests.

Default is 403 (Forbidden). If code <= 0, it leaves the default unchanged.

func WithIPAllowList

func WithIPAllowList(cidrsOrIPs []string) AccessGuardOption

WithIPAllowList enables IP validation with a static allowlist.

Entries may be CIDRs (e.g. "10.0.0.0/8", "fd00::/8") or single IPs (e.g. "192.168.1.1", "2001:db8::1"), where single IPs are treated as /32 or /128.

Semantics:

  • cidrsOrIPs == nil: enabled, but deny-all (fail-closed)
  • len(cidrsOrIPs) == 0: enabled, but deny-all (fail-closed)
  • invalid entries are ignored; if no valid entries remain, deny-all (fail-closed)

To disable IP validation, do NOT configure any IP-related option.

func WithIPAllowSet

func WithIPAllowSet(set IPAllowSetLike) AccessGuardOption

WithIPAllowSet enables IP validation with a user-provided allow set.

set must be non-nil. To disable IP validation, do NOT configure any IP-related option.

func WithIPResolver

func WithIPResolver(fn func(r *http.Request) (net.IP, bool)) AccessGuardOption

WithIPResolver sets a custom IP resolver.

Default is:

  • RealIPFromRequest(r) when present
  • otherwise parseIP(r.RemoteAddr)

If fn is nil, the option is ignored.

func WithOnDeny

func WithOnDeny(fn func(r *http.Request, reason DenyReason)) AccessGuardOption

WithOnDeny sets a hook called when AccessGuard denies a request.

Observability-only: implementations must be fast and must not panic. Implementations must NOT write the response. If the hook panics, AccessGuard will swallow the panic and report it to stderr (style aligned with Timeout / BodyLimit).

func WithOr

func WithOr() AccessGuardOption

WithOr switches the combination logic from the default AND to OR.

When both token and IP are enabled:

  • default (AND): ok = tokenOK && ipOK
  • WithOr (OR): ok = tokenOK || ipOK

func WithTokenCheck

func WithTokenCheck(fn func(token string) bool) AccessGuardOption

WithTokenCheck enables token validation with a fully custom checker.

fn must be fast and must not block; it must not do I/O. If fn is nil, the option is ignored.

func WithTokenHeader

func WithTokenHeader(name string) AccessGuardOption

WithTokenHeader sets the header name used for token validation.

Default is DefaultAccessGuardTokenHeader. Empty/blank names are ignored.

func WithTokenSet

func WithTokenSet(set TokenSetLike) AccessGuardOption

WithTokenSet enables token validation with a user-provided token set.

set must be non-nil. To disable token validation, do NOT configure any token-related option.

func WithTokens

func WithTokens(tokens []string) AccessGuardOption

WithTokens enables token validation with a static token set.

Semantics:

  • tokens == nil: enabled, but deny-all (fail-closed)
  • len(tokens) == 0: enabled, but deny-all (fail-closed)
  • blank/whitespace tokens are ignored; if none remain, deny-all (fail-closed)

To disable token validation, do NOT configure any token-related option.

type AtomicIPAllowList

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

AtomicIPAllowList is an updateable IP allowlist intended for hot changes.

Read path (Contains) is lock-free and non-blocking. Write path (Update/AllowAll) is atomic and may allocate.

func NewAtomicIPAllowList

func NewAtomicIPAllowList() *AtomicIPAllowList

NewAtomicIPAllowList creates a new allowlist in the deny-all state.

func (*AtomicIPAllowList) AllowAll

func (a *AtomicIPAllowList) AllowAll()

AllowAll sets this allowlist to allow all IPs.

func (*AtomicIPAllowList) Contains

func (a *AtomicIPAllowList) Contains(ip net.IP) bool

Contains reports whether ip is allowed.

It is safe for concurrent use.

func (*AtomicIPAllowList) Update

func (a *AtomicIPAllowList) Update(cidrsOrIPs []string)

Update parses and replaces the current allowlist snapshot.

Semantics:

  • cidrsOrIPs == nil: sets to empty (deny-all)
  • entries may be CIDRs or single IPs
  • invalid/blank entries are ignored
  • if no valid entries remain, it becomes empty (deny-all)

type AtomicTokenSet

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

AtomicTokenSet is an updateable token set intended for hot changes.

Read path (Contains) is lock-free and non-blocking. Write path (Update/AllowAll) is atomic and may allocate.

func NewAtomicTokenSet

func NewAtomicTokenSet() *AtomicTokenSet

NewAtomicTokenSet creates a new token set in the deny-all state.

func (*AtomicTokenSet) AllowAll

func (s *AtomicTokenSet) AllowAll()

AllowAll sets this token set to allow all tokens.

func (*AtomicTokenSet) Contains

func (s *AtomicTokenSet) Contains(token string) bool

Contains reports whether token is accepted.

It is safe for concurrent use.

func (*AtomicTokenSet) Update

func (s *AtomicTokenSet) Update(tokens []string)

Update replaces the current token snapshot.

Semantics:

  • tokens == nil: sets to empty (deny-all)
  • blank/whitespace tokens are ignored
  • if no valid tokens remain, it becomes empty (deny-all)

type BodyLimitFunc

type BodyLimitFunc func(r *http.Request) (n int64, ok bool)

BodyLimitFunc returns a per-request body size limit decision.

If ok is false, BodyLimit is skipped for that request. If ok is true, n is used as the limit; if n <= 0, BodyLimit is also skipped.

type BodyLimitHandler

type BodyLimitHandler func(r *http.Request, info BodyLimitInfo)

BodyLimitHandler is called when BodyLimit rejects or detects an exceeded limit.

Observability-only: implementations must be fast and must not panic. Implementations must NOT write the response. If it panics, BodyLimit will swallow the panic and report it to stderr.

type BodyLimitInfo

type BodyLimitInfo struct {
	Limit         int64
	ContentLength int64
	Source        BodyLimitSource
}

BodyLimitInfo describes a body limit rejection or exceed event.

type BodyLimitOption

type BodyLimitOption func(*bodyLimitConfig)

BodyLimitOption configures the BodyLimit middleware.

func WithLimitFunc

func WithLimitFunc(fn BodyLimitFunc) BodyLimitOption

WithLimitFunc sets a per-request BodyLimitFunc.

If fn is nil, it leaves the default unchanged.

func WithOnReject

func WithOnReject(fn BodyLimitHandler) BodyLimitOption

WithOnReject sets a BodyLimitHandler called when a request is rejected due to Content-Length exceeding the limit, or when downstream reads exceed the limit.

If fn is nil, it leaves the default unchanged.

type BodyLimitSource

type BodyLimitSource string

BodyLimitSource identifies where the body limit decision/rejection came from.

const (
	// BodyLimitSourceContentLength indicates an early rejection based on Content-Length.
	BodyLimitSourceContentLength BodyLimitSource = "content-length"
	// BodyLimitSourceRead indicates a read-time detection via MaxBytesReader.
	BodyLimitSourceRead BodyLimitSource = "read"
)

type CORSOption

type CORSOption func(*corsConfig)

CORSOption configures the CORS middleware.

func WithAllowCredentials

func WithAllowCredentials(v bool) CORSOption

WithAllowCredentials controls whether Access-Control-Allow-Credentials is set.

Default is true (debug-friendly).

func WithAllowNullOrigin

func WithAllowNullOrigin(v bool) CORSOption

WithAllowNullOrigin controls whether Origin: "null" is allowed when an allowlist is configured via WithAllowedOrigins.

Default is false (when allowlist is configured, Origin:"null" is rejected unless enabled).

func WithAllowedHeaders

func WithAllowedHeaders(headers []string) CORSOption

WithAllowedHeaders restricts allowed request headers for CORS preflight.

If headers is nil or empty, headers are not restricted (default; reflected from request). If headers is non-empty but no valid headers are parsed, it denies all headers (fail-closed).

Enforcement:

  • For preflight: all headers in Access-Control-Request-Headers must be allowed, otherwise CORS is skipped.

func WithAllowedMethods

func WithAllowedMethods(methods []string) CORSOption

WithAllowedMethods restricts allowed methods for CORS.

If methods is nil or empty, methods are not restricted (default). If methods is non-empty but no valid methods are parsed, it denies all methods (fail-closed).

Enforcement:

  • For preflight: Access-Control-Request-Method must be allowed, otherwise CORS is skipped.
  • For non-preflight: the request method must be allowed, otherwise CORS is skipped.

func WithAllowedOrigins

func WithAllowedOrigins(origins []string) CORSOption

WithAllowedOrigins sets the allowlist of allowed origins/host patterns.

If origins is nil or empty, it means "allow any Origin" (default; debug-friendly). Empty/blank entries are ignored.

Safety note:

  • If origins is non-empty but none of the entries are valid patterns after parsing, the middleware will deny all origins (fail-closed) to avoid accidental "allow any" due to configuration typos.

Matching is based on the request Origin's hostname only (scheme and port are ignored). Supported patterns:

  • "example.com": matches "example.com" and any subdomain.
  • "*.example.com": matches any subdomain under "example.com", but not "example.com" itself.
  • "*.a.example.com": matches any subdomain under "a.example.com", but not "a.example.com" itself.

For convenience, entries may also be full origins like "https://example.com:8443"; only the hostname part is used.

func WithEnabledFunc

func WithEnabledFunc(fn func(r *http.Request) bool) CORSOption

WithEnabledFunc sets a per-request toggle for CORS.

If fn is nil, it leaves the default unchanged.

When fn returns false, CORS is skipped entirely:

  • no CORS headers are written
  • preflight requests are not short-circuited

func WithExposeHeaders

func WithExposeHeaders(headers []string) CORSOption

WithExposeHeaders sets Access-Control-Expose-Headers for non-preflight responses.

Default is exposing X-Request-ID (httpx DefaultRequestIDHeader), so browser clients can read it via fetch/XHR (useful for correlating logs and debugging).

If headers is nil or empty, Access-Control-Expose-Headers is not set (disabled). Empty/blank entries are ignored; duplicates are removed (case-insensitive).

func WithExposeHeadersAppend

func WithExposeHeadersAppend(headers []string) CORSOption

WithExposeHeadersAppend appends headers to Access-Control-Expose-Headers.

It appends to the current expose list (default starts with X-Request-ID). If the expose header was disabled via WithExposeHeaders(nil), Append will start from empty. Empty/blank entries are ignored; duplicates are removed (case-insensitive).

func WithMatchFunc

func WithMatchFunc(fn func(r *http.Request) bool) CORSOption

WithMatchFunc sets a per-request matcher for CORS.

If fn is nil, it leaves the default unchanged.

When fn returns false, CORS is skipped entirely:

  • no CORS headers are written
  • preflight requests are not short-circuited

func WithMaxAge

func WithMaxAge(d time.Duration) CORSOption

WithMaxAge controls Access-Control-Max-Age for preflight responses.

Default is 10 minutes (debug-friendly, reduces repeated preflight requests). If d <= 0, Access-Control-Max-Age is not set.

func WithPreflightStatus

func WithPreflightStatus(code int) CORSOption

WithPreflightStatus sets the status code for successful preflight responses.

Allowed values are 200 (OK) and 204 (No Content). Default is 204. Invalid values are ignored and keep the default unchanged.

type ChainHandler

type ChainHandler struct {
	// Endpoint is the final handler.
	Endpoint http.Handler

	// Middlewares is the middleware snapshot used to build chain (nil filtered).
	Middlewares Middlewares
	// contains filtered or unexported fields
}

ChainHandler is an http.Handler with support for handler composition and observation.

ChainHandler is returned by Middlewares.Handler / Middlewares.HandlerFunc.

func (*ChainHandler) ServeHTTP

func (c *ChainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)

type DenyReason

type DenyReason string

DenyReason describes why AccessGuard denied a request.

It is intended for observability only (metrics/logs). It must NOT include sensitive values (e.g., tokens, raw header contents).

const (
	DenyReasonTokenMissing      DenyReason = "token-missing"
	DenyReasonTokenAmbiguous    DenyReason = "token-ambiguous"
	DenyReasonTokenEmpty        DenyReason = "token-empty"
	DenyReasonTokenSetEmpty     DenyReason = "token-set-empty"
	DenyReasonTokenNotAllowed   DenyReason = "token-not-allowed"
	DenyReasonIPParseFailed     DenyReason = "ip-parse-failed"
	DenyReasonIPAllowListEmpty  DenyReason = "ip-allowlist-empty"
	DenyReasonIPNotAllowed      DenyReason = "ip-not-allowed"
	DenyReasonCustomCheckDenied DenyReason = "check-denied"
)

type IPAllowSetLike

type IPAllowSetLike interface {
	Contains(ip net.IP) bool
}

IPAllowSetLike is an IP allow set used by AccessGuard.

Implementations must be safe for concurrent use. The request path must be fast and must not block.

type Middleware

type Middleware func(http.Handler) http.Handler

Middleware is a standard net/http middleware.

A middleware wraps the next handler and returns a new handler.

func AccessGuard

func AccessGuard(opts ...AccessGuardOption) Middleware

AccessGuard returns a middleware that enforces an access guard based on:

  • optional token validation (token from a header + validator)
  • optional client IP allowlist (real IP from RealIP middleware when present)

Rules:

  • If IP allowlist is enabled, the client IP must match one of the allowlisted CIDRs/IPs. Client IP is taken from RealIP middleware when present; otherwise it falls back to RemoteAddr.
  • If token validation is enabled, the request must provide exactly one non-empty token header value that matches the configured token validator.
  • If both are enabled, both checks must pass (AND).
  • If neither is enabled, it panics (configuration/assembly error).

Security defaults:

  • When token validation is enabled but the token set is empty, it denies all (fail-closed).
  • When IP validation is enabled but the allowlist is empty, it denies all (fail-closed).
  • OR must be explicitly enabled via WithOr().
Example (TokenAndIP)
// Token + IP allowlist.
h := Chain(
	RealIP(WithTrustedProxies([]string{"10.0.0.0/8"})),
	AccessGuard(
		WithTokens([]string{"tokenA"}),
		WithIPAllowList([]string{"203.0.113.0/24"}),
	),
).Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
req.RemoteAddr = "10.0.0.1:12345"                 // trusted proxy
req.Header.Set("X-Forwarded-For", "203.0.113.50") // real client
req.Header.Set(DefaultAccessGuardTokenHeader, "tokenA")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
_ = rr.Result()
Example (TokenOnly)
// Token-only guard: no RealIP middleware required.
set := NewAtomicTokenSet()
set.Update([]string{"tokenA"})

h := AccessGuard(
	WithTokenSet(set),
)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
req.Header.Set(DefaultAccessGuardTokenHeader, "tokenA")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
_ = rr.Result()

func BodyLimit

func BodyLimit(maxBytes int64, opts ...BodyLimitOption) Middleware

BodyLimit returns a middleware that enforces a maximum request body size.

Behavior:

  • It computes a per-request limit from maxBytes and WithLimitFunc.
  • If maxBytes <= 0, or BodyLimitFunc returns ok=false, or ok=true with n <= 0, BodyLimit is skipped for that request.
  • If Content-Length is known and exceeds the limit, it rejects early with 413 Request Entity Too Large, without calling downstream. In this case, it also hints the client/proxy to close the connection to avoid keep-alive reuse with an unread request body.
  • Otherwise, it wraps r.Body with http.MaxBytesReader so downstream reads past the limit fail with *http.MaxBytesError.

OnReject trigger:

  • If WithOnReject is provided, it is called for observability when:
  • the request is rejected early (Source=content-length), or
  • downstream reads exceed the limit (Source=read).
  • For Source=read, OnReject is called after downstream returns, and only if downstream actually read past the limit (i.e., observed a *http.MaxBytesError).
  • If BodyLimit is skipped for a request, OnReject will not be called.

Ordering:

  • Place BodyLimit before any handler/middleware that may read or buffer the request body (e.g., decompression, request body logging, multipart parsing, JSON decoding).

Tuning/ops:

  • WithLimitFunc is designed to work naturally with runtime-tunable configs. A common convention is returning n <= 0 to temporarily disable the limit for a request.

Downstream handling:

BodyLimit does NOT translate read-time errors into a response. Downstream handlers that read r.Body should treat *http.MaxBytesError as 413 Request Entity Too Large (or the equivalent in your API) and must stop processing immediately to avoid partial-body bugs.

Example:

b, err := io.ReadAll(r.Body)
if err != nil {
	var mbe *http.MaxBytesError
	if errors.As(err, &mbe) {
		http.Error(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
		return
	}
	http.Error(w, "bad request", http.StatusBadRequest)
	return
}
Example
// Global default limit (e.g., for API handlers).
apiLimit := BodyLimit(1<<20 /* 1 MiB */, WithOnReject(func(r *http.Request, info BodyLimitInfo) {
	_ = r
	_ = info
	// Record metrics / logs here (do NOT write response).
}))

// Per-request override/skip (e.g., tune at runtime; skip long-lived or special endpoints).
conditional := BodyLimit(1<<20, WithLimitFunc(func(r *http.Request) (int64, bool) {
	if r.URL != nil && r.URL.Path == "/upload/large" {
		return 0, false // skip
	}
	return 1 << 20, true
}))

_ = apiLimit
_ = conditional

func CORS

func CORS(opts ...CORSOption) Middleware

CORS returns a middleware that adds CORS headers for browser clients.

Defaults are intentionally "debug-friendly":

  • Any Origin is allowed by default (Origin is reflected).
  • Access-Control-Allow-Credentials is enabled by default.
  • Preflight (OPTIONS + Access-Control-Request-Method) is short-circuited by default with 204 No Content.
  • Preflight responses include Access-Control-Max-Age by default (10 minutes).
  • Access-Control-Expose-Headers is set by default to expose X-Request-ID.

Safety and predictability:

  • CORS is applied only when the request has exactly one non-empty Origin header value.
  • If WithAllowedOrigins is set to a non-empty list, the Origin hostname must match one of the allowed patterns (scheme and port are ignored).
  • When the origin is not allowed (or the Origin header is invalid), the middleware does not write any CORS headers.

Preflight behavior:

  • A request is considered preflight when method is OPTIONS and the request has a non-empty Access-Control-Request-Method header.
  • For allowed preflight requests, it writes CORS preflight response headers and returns 204 without calling downstream.
  • For disallowed preflight requests, it does not short-circuit; downstream decides.

Ordering:

  • Place CORS before authentication/authorization middleware when you want preflight requests to succeed. If a preflight request reaches an auth middleware first, it may be rejected, causing browsers to fail the actual cross-origin request.

Tuning:

  • WithEnabledFunc and WithMatchFunc are designed to work naturally with runtime tuning flags: return false to temporarily disable CORS for a request.
Example (Default)
// Default: allow any Origin (reflected), allow credentials, short-circuit preflight.
h := CORS()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
req.Header.Set("Origin", "https://frontend.example")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
_ = rr.Result()
Example (EnabledFunc)
// Runtime toggle (e.g. wired to tuning/feature flag).
var enabled int32
atomic.StoreInt32(&enabled, 1)

h := CORS(WithEnabledFunc(func(r *http.Request) bool {
	return atomic.LoadInt32(&enabled) == 1
}))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodGet, "http://example.test/", nil)
req.Header.Set("Origin", "https://frontend.example")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
_ = rr.Result()
Example (MatchFunc)
// Apply CORS only to a subtree (e.g. debug endpoints).
h := CORS(WithMatchFunc(func(r *http.Request) bool {
	return strings.HasPrefix(r.URL.Path, "/debug/")
}))(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	w.WriteHeader(http.StatusOK)
}))

req := httptest.NewRequest(http.MethodGet, "http://example.test/debug/ping", nil)
req.Header.Set("Origin", "https://frontend.example")
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
_ = rr.Result()

func RealIP

func RealIP(opts ...RealIPOption) Middleware

RealIP returns a middleware that extracts the real client IP from the request.

Behavior:

  • By default, it does NOT trust any headers and uses RemoteAddr directly.
  • If trusted proxies are configured (via WithTrustedProxies), and the direct client IP is within a trusted CIDR, it extracts the real IP from headers.
  • For X-Forwarded-For, it scans right-to-left, skipping trusted proxies, and returns the first untrusted IP. If an invalid entry is encountered, behavior depends on WithXFFInvalidPolicy (default: XFFInvalidStop).
  • For X-Real-IP (and other single-value headers), it uses the value only when the header has exactly one non-empty value; multiple values are treated as invalid/ambiguous.
  • The extracted IP is stored in the request context for downstream handlers. If no IP can be extracted (e.g., unparseable RemoteAddr), the context is left unchanged.

Security:

  • Default-safe: without trusted proxies configured, headers are ignored.
  • Only configure trusted proxies for IPs you actually trust (e.g., your load balancer).

func Recover

func Recover(opts ...RecoverOption) Middleware

Recover returns a middleware that recovers from panics in downstream handlers.

It re-panics http.ErrAbortHandler to preserve net/http semantics.

If the response has not been written yet, it writes a 500 Internal Server Error. If the response has already started, it does not modify the response.

Observability:

  • If WithOnPanic is provided, it is called with the panic value and stack.
  • Otherwise, panics are reported to stderr by default.

func RequestID

func RequestID(opts ...RequestIDOption) Middleware

RequestID returns a middleware that ensures each request has a request id.

Behavior:

  • Incoming: by default, it reads X-Request-ID (or the configured incoming headers), validates it, and uses the first valid value (in configured order).
  • If no valid incoming id is found (or trustIncoming is false), it generates a new id.
  • It stores the request id in the request context for downstream handlers.
  • It sets the response header X-Request-ID (DefaultRequestIDHeader) by default.

Defaults are chosen to be explicit and safe:

  • Only X-Request-ID is checked by default; users can extend via WithIncomingHeaders.
  • Incoming ids are validated (length + allowed characters) to avoid log/header pollution.
  • The response header is set by default for easier debugging.

func Timeout

func Timeout(timeout time.Duration, opts ...TimeoutOption) Middleware

Timeout returns a middleware that derives a request context with a timeout.

Behavior:

  • It derives a request context with deadline = now + timeout, unless:
  • timeout <= 0, or
  • TimeoutFunc returns ok=false, or d <= 0, or
  • the incoming request context already has an earlier (or equal) deadline (i.e., it never extends an existing deadline).
  • It calls the downstream handler with the derived context when applied.
  • After downstream returns, if the derived context ended with context.DeadlineExceeded, it calls the TimeoutHandler (if provided) for observability.

OnTimeout trigger:

  • OnTimeout is called only when Timeout actually derived a context for the request. If Timeout is skipped (by timeout <= 0 or TimeoutFunc), or if it keeps the parent context due to an existing earlier/equal deadline, OnTimeout will not be called.

Notes:

  • Timeout does NOT write any response on timeout (standard-library flavored, low-risk).
  • Timeout does NOT start a goroutine; it relies on cooperative cancellation via context.
Example
// Global default timeout (e.g., for API handlers).
apiTimeout := Timeout(2*time.Second, WithOnTimeout(func(r *http.Request, info TimeoutInfo) {
	_ = r
	_ = info
	// Record metrics / logs here (do NOT write response).
}))

// Per-request override/skip (e.g., skip long-lived streaming endpoints).
conditional := Timeout(2*time.Second, WithTimeoutFunc(func(r *http.Request) (time.Duration, bool) {
	if r.URL != nil && r.URL.Path == "/sse" {
		return 0, false // skip
	}
	return 2 * time.Second, true
}))

_ = apiTimeout
_ = conditional

type Middlewares

type Middlewares []Middleware

Middlewares is a middleware chain builder.

Order:

  • Chain(a, b, c).Handler(h) returns a(b(c(h))).

func Chain

func Chain(mws ...Middleware) Middlewares

Chain creates a middleware chain from the provided middlewares.

Nil middlewares are ignored.

func (Middlewares) Handler

func (mws Middlewares) Handler(h http.Handler) http.Handler

Handler builds and returns an http.Handler from the chain of middlewares, with h as the final handler.

It panics if h is nil (a configuration/assembly error).

func (Middlewares) HandlerFunc

func (mws Middlewares) HandlerFunc(h http.HandlerFunc) http.Handler

HandlerFunc builds and returns an http.Handler from the chain of middlewares, with h as the final handler.

It panics if h is nil (a configuration/assembly error).

func (Middlewares) With

func (mws Middlewares) With(more ...Middleware) Middlewares

With returns a new chain by appending more middlewares to the current chain.

Nil middlewares are ignored. With never mutates the receiver, and the returned chain does not share the underlying array with the receiver.

type PanicHandler

type PanicHandler func(r *http.Request, info RecoverInfo)

PanicHandler is called when the wrapped handler panics (except http.ErrAbortHandler).

Implementations must be fast and must not panic. If a PanicHandler panics, Recover will swallow the secondary panic and report it to stderr as a fallback.

type RealIPOption

type RealIPOption func(*realIPConfig)

RealIPOption configures the RealIP middleware.

func WithTrustedHeaders

func WithTrustedHeaders(headers []string) RealIPOption

WithTrustedHeaders sets the list of header names to check for the real IP, in order.

Default is ["X-Forwarded-For", "X-Real-IP"]. Empty/blank names are ignored. If the resulting list is empty, it falls back to the default.

func WithTrustedProxies

func WithTrustedProxies(cidrs []string) RealIPOption

WithTrustedProxies sets the list of trusted proxy CIDRs.

Only when the direct client IP (from RemoteAddr) is within one of these CIDRs, the middleware will trust and extract IP from headers.

Accepts CIDR notation (e.g., "10.0.0.0/8") or single IPs (e.g., "192.168.1.1"). Single IPs are treated as /32 (IPv4) or /128 (IPv6). Invalid entries are silently ignored.

For strict validation (e.g., fail-fast at startup), use ParseTrustedProxies.

func WithXFFInvalidPolicy

func WithXFFInvalidPolicy(p XFFInvalidPolicy) RealIPOption

WithXFFInvalidPolicy sets how X-Forwarded-For parsing handles invalid entries.

Default is XFFInvalidStop (most conservative).

type RecoverInfo

type RecoverInfo struct {
	Value any
	Stack []byte
}

RecoverInfo describes a recovered panic.

type RecoverOption

type RecoverOption func(*recoverConfig)

RecoverOption configures the Recover middleware.

func WithOnPanic

func WithOnPanic(fn PanicHandler) RecoverOption

WithOnPanic sets a PanicHandler. If not set, Recover reports panics to stderr by default.

type RequestIDGenerator

type RequestIDGenerator func() (string, error)

RequestIDGenerator generates a new request id.

Implementations must be fast and should avoid allocations. If it returns an error or an invalid/empty id, RequestID falls back to an internal generator.

type RequestIDOption

type RequestIDOption func(*requestIDConfig)

RequestIDOption configures the RequestID middleware.

func WithGenerator

func WithGenerator(fn RequestIDGenerator) RequestIDOption

WithGenerator sets a custom request id generator.

If fn is nil, it leaves the default generator unchanged.

func WithIncomingHeaders

func WithIncomingHeaders(headers []string) RequestIDOption

WithIncomingHeaders sets the list of header names to look for an incoming request id, in order.

Empty/blank names are ignored. If the resulting list is empty, it falls back to the default (X-Request-ID).

func WithMaxLen

func WithMaxLen(n int) RequestIDOption

WithMaxLen sets the maximum allowed length for an incoming request id.

If n <= 0, it leaves the default unchanged.

func WithSetResponseHeader

func WithSetResponseHeader(v bool) RequestIDOption

WithSetResponseHeader controls whether RequestID sets DefaultRequestIDHeader on the response.

Default is true.

func WithTrustIncoming

func WithTrustIncoming(v bool) RequestIDOption

WithTrustIncoming controls whether RequestID trusts and uses incoming request id headers.

Default is true.

func WithValidator

func WithValidator(fn func(string) bool) RequestIDOption

WithValidator sets a custom validator for incoming request ids.

If fn is nil, it leaves the default validator unchanged.

type TimeoutFunc

type TimeoutFunc func(r *http.Request) (d time.Duration, ok bool)

TimeoutFunc returns a per-request timeout decision.

If ok is false, Timeout will not derive a new context for this request. If ok is true, d is used as the timeout; if d <= 0, Timeout is also skipped.

type TimeoutHandler

type TimeoutHandler func(r *http.Request, info TimeoutInfo)

TimeoutHandler is called when a request times out (context deadline exceeded) and Timeout has derived the request context.

Observability-only: implementations must be fast and must not panic. Implementations must NOT write the response. If a TimeoutHandler panics, Timeout will swallow the panic and report it to stderr.

type TimeoutInfo

type TimeoutInfo struct {
	Timeout  time.Duration
	Deadline time.Time
	Elapsed  time.Duration
}

TimeoutInfo describes a timeout event.

type TimeoutOption

type TimeoutOption func(*timeoutConfig)

TimeoutOption configures the Timeout middleware.

func WithNow

func WithNow(fn func() time.Time) TimeoutOption

WithNow sets a custom clock function used by Timeout.

This is primarily intended for tests. If fn is nil, it leaves the default unchanged.

func WithOnTimeout

func WithOnTimeout(fn TimeoutHandler) TimeoutOption

WithOnTimeout sets a TimeoutHandler called when the derived context ends with context.DeadlineExceeded.

If fn is nil, it leaves the default unchanged.

func WithTimeoutFunc

func WithTimeoutFunc(fn TimeoutFunc) TimeoutOption

WithTimeoutFunc sets a per-request TimeoutFunc.

If fn is nil, it leaves the default unchanged.

type TokenSetLike

type TokenSetLike interface {
	Contains(token string) bool
}

TokenSetLike is a token set used by AccessGuard.

Implementations must be safe for concurrent use. The request path must be fast and must not block.

type XFFInvalidPolicy

type XFFInvalidPolicy int

XFFInvalidPolicy controls how X-Forwarded-For parsing handles invalid entries.

When scanning XFF right-to-left, an "invalid entry" is any token that cannot be parsed as an IP (optionally with a port).

const (
	// XFFInvalidStop stops processing and returns nil immediately when an invalid entry
	// is encountered. This is the most conservative behavior and the default.
	XFFInvalidStop XFFInvalidPolicy = iota
	// XFFInvalidSkip skips invalid entries and continues scanning left.
	// This is more compatible with real-world chains that may include garbage tokens,
	// but it is less strict.
	XFFInvalidSkip
	// XFFInvalidSkipUnknown skips entries equal to "unknown" (case-insensitive),
	// but still stops on other invalid values.
	XFFInvalidSkipUnknown
)

Directories

Path Synopsis
Package client provides a small, stable and standard-library flavored HTTP client builder.
Package client provides a small, stable and standard-library flavored HTTP client builder.

Jump to

Keyboard shortcuts

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