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 ¶
- Constants
- func CountValidOriginPatterns(origins []string) int
- func ParseTrustedProxies(cidrs []string) ([]*net.IPNet, error)
- func RealIPFromContext(ctx context.Context) (net.IP, bool)
- func RealIPFromRequest(r *http.Request) (net.IP, bool)
- func RequestIDFromContext(ctx context.Context) (string, bool)
- func RequestIDFromRequest(r *http.Request) (string, bool)
- func ValidateOriginPatterns(origins []string) error
- func WithRealIP(ctx context.Context, ip net.IP) context.Context
- func WithRequestID(ctx context.Context, id string) context.Context
- func Wrap(h http.Handler, mws ...Middleware) http.Handler
- type AccessGuardOption
- func WithCheck(fn func(r *http.Request) bool) AccessGuardOption
- func WithDenyStatus(code int) AccessGuardOption
- func WithIPAllowList(cidrsOrIPs []string) AccessGuardOption
- func WithIPAllowSet(set IPAllowSetLike) AccessGuardOption
- func WithIPResolver(fn func(r *http.Request) (net.IP, bool)) AccessGuardOption
- func WithOnDeny(fn func(r *http.Request, reason DenyReason)) AccessGuardOption
- func WithOr() AccessGuardOption
- func WithTokenCheck(fn func(token string) bool) AccessGuardOption
- func WithTokenHeader(name string) AccessGuardOption
- func WithTokenSet(set TokenSetLike) AccessGuardOption
- func WithTokens(tokens []string) AccessGuardOption
- type AtomicIPAllowList
- type AtomicTokenSet
- type BodyLimitFunc
- type BodyLimitHandler
- type BodyLimitInfo
- type BodyLimitOption
- type BodyLimitSource
- type CORSOption
- func WithAllowCredentials(v bool) CORSOption
- func WithAllowNullOrigin(v bool) CORSOption
- func WithAllowedHeaders(headers []string) CORSOption
- func WithAllowedMethods(methods []string) CORSOption
- func WithAllowedOrigins(origins []string) CORSOption
- func WithEnabledFunc(fn func(r *http.Request) bool) CORSOption
- func WithExposeHeaders(headers []string) CORSOption
- func WithExposeHeadersAppend(headers []string) CORSOption
- func WithMatchFunc(fn func(r *http.Request) bool) CORSOption
- func WithMaxAge(d time.Duration) CORSOption
- func WithPreflightStatus(code int) CORSOption
- type ChainHandler
- type DenyReason
- type IPAllowSetLike
- type Middleware
- func AccessGuard(opts ...AccessGuardOption) Middleware
- func BodyLimit(maxBytes int64, opts ...BodyLimitOption) Middleware
- func CORS(opts ...CORSOption) Middleware
- func RealIP(opts ...RealIPOption) Middleware
- func Recover(opts ...RecoverOption) Middleware
- func RequestID(opts ...RequestIDOption) Middleware
- func Timeout(timeout time.Duration, opts ...TimeoutOption) Middleware
- type Middlewares
- type PanicHandler
- type RealIPOption
- type RecoverInfo
- type RecoverOption
- type RequestIDGenerator
- type RequestIDOption
- func WithGenerator(fn RequestIDGenerator) RequestIDOption
- func WithIncomingHeaders(headers []string) RequestIDOption
- func WithMaxLen(n int) RequestIDOption
- func WithSetResponseHeader(v bool) RequestIDOption
- func WithTrustIncoming(v bool) RequestIDOption
- func WithValidator(fn func(string) bool) RequestIDOption
- type TimeoutFunc
- type TimeoutHandler
- type TimeoutInfo
- type TimeoutOption
- type TokenSetLike
- type XFFInvalidPolicy
Examples ¶
Constants ¶
const DefaultAccessGuardTokenHeader = "X-Access-Token"
DefaultAccessGuardTokenHeader is the default header name for AccessGuard token validation.
const DefaultRequestIDHeader = "X-Request-ID"
DefaultRequestIDHeader is the default header used for request ID propagation.
Variables ¶
This section is empty.
Functions ¶
func CountValidOriginPatterns ¶
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 ¶
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 ¶
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 ¶
RealIPFromRequest extracts the real IP from r.Context().
func RequestIDFromContext ¶
RequestIDFromContext extracts the request id from ctx.
func RequestIDFromRequest ¶
RequestIDFromRequest extracts the request id from r.Context().
func ValidateOriginPatterns ¶
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 ¶
WithRealIP returns a derived context with ip stored as the real IP.
If ip is nil, it returns ctx unchanged.
func WithRequestID ¶
WithRequestID returns a derived context with id stored as the request id.
If id is empty, it returns ctx unchanged.
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 )