Skip to content

simp-lee/ginx

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Ginx - Functional Middleware for Gin

Minimal, composable, and high-performance middleware toolkit for Gin, with conditional execution and functional chaining.

Features

  • Functional composition: Chain + Condition to precisely control execution
  • Production-ready: recovery, logging, timeout, CORS, auth, RBAC, cache, rate limit
  • Unified error formatting: one ErrorFormatter controls all middleware error responses
  • High performance: zero-allocation conditions, token-bucket & time-window rate limiting, sharded cache
  • Clean API: unified Option/Condition pattern, easy to extend

Installation

go get github.com/simp-lee/ginx

Quick Start

package main

import (
    "time"
    "github.com/gin-gonic/gin"
    "github.com/simp-lee/ginx"
)

func main() {
    r := gin.New()

    // Basic middleware stack (recommended order)
    r.Use(ginx.NewChain().
        Use(ginx.RequestID()).                // Correlation id first
        Use(ginx.Recovery()).                 // Panic protection with logging
        Use(ginx.Logger()).                   // Structured request logging
        Use(ginx.Timeout()).                  // 30s timeout protection
        Use(ginx.CORS(ginx.WithAllowOrigins("*"))). // CORS for development
        Use(ginx.RateLimit(100, 200)).        // 100 RPS, 200 burst per IP
        Build())

    r.GET("/", func(c *gin.Context) {
        c.JSON(200, gin.H{"message": "Hello World"})
    })
    
    r.GET("/slow", func(c *gin.Context) {
        // This will timeout after 30 seconds due to Timeout middleware
        time.Sleep(35 * time.Second)
        c.JSON(200, gin.H{"message": "This won't be reached"})
    })

    r.Run(":8080")
}

Conditional Middleware

// Build conditional middleware chain
chain := ginx.NewChain().
    Use(ginx.RequestID()).
    Use(ginx.Recovery()).
    Use(ginx.Logger()).
    // Apply rate limiting only to API routes
    When(ginx.PathHasPrefix("/api/"), ginx.RateLimit(100, 200)).
    // Add hourly quota for API routes
    When(ginx.PathHasPrefix("/api/"), ginx.RateLimitPerHour(10000)).
    // Apply CORS only to browser requests  
    When(ginx.HeaderExists("Origin"), ginx.CORS(ginx.WithAllowOrigins("*"))).
    // Longer timeout for heavy operations
    When(ginx.PathHasPrefix("/api/heavy/"), ginx.Timeout(ginx.WithTimeout(60*time.Second)))

r.Use(chain.Build())

Core Concepts

type Middleware      func(gin.HandlerFunc) gin.HandlerFunc
type Condition       func(*gin.Context) bool
type Option[T any]   func(*T)
type ErrorHandler    func(*gin.Context, error)
type ErrorFormatter  func(status int, message string) any

Chain (functional composition)

Chain provides fluent API for building middleware chains with conditional execution and error handling.

Chain methods:

  • NewChain() - Create new chain builder
  • Use(m Middleware) - Add middleware unconditionally
  • When(cond Condition, m Middleware) - Add middleware if condition is true
  • Unless(cond Condition, m Middleware) - Add middleware if condition is false
  • OnError(handler ErrorHandler) - Set error handler for chain execution
  • WithErrorFormat(f ErrorFormatter) - Set unified error response format for all middleware in the chain
  • Build() - Build final gin.HandlerFunc

Note:

  • OnError is invoked only when c.Errors is non-empty. To have errors handled by the chain-level handler, call c.Error(err) in your middleware or handlers.

Example:

chain := ginx.NewChain().
  OnError(func(c *gin.Context, err error) { c.JSON(500, gin.H{"error": err.Error()}) }).
  Use(ginx.Recovery()).
  Use(ginx.Logger()).
  When(ginx.PathHasPrefix("/api/heavy"), ginx.Timeout(ginx.WithTimeout(60*time.Second))).
  Unless(ginx.PathIs("/health"), ginx.RateLimit(100, 200))

r.Use(chain.Build())

Conditions

Conditions are lightweight functions of type func(*gin.Context) bool used to decide whether middleware should execute. Most conditions are zero-allocation; ContentTypeIs parses MIME types (slight cost), and PathMatches compiles regex once at condition creation.

Logic combinators:

  • And(conds ...Condition) - All conditions must be true
  • Or(conds ...Condition) - At least one condition is true
  • Not(cond Condition) - Condition must be false

Path conditions:

  • PathIs(paths ...string) - Exact path match
  • PathHasPrefix(prefix string) - Path starts with prefix
  • PathHasSuffix(suffix string) - Path ends with suffix
  • PathMatches(pattern string) - Path matches regex pattern

HTTP conditions:

  • MethodIs(methods ...string) - HTTP method matches
  • HeaderExists(key string) - Request header exists
  • HeaderEquals(key, value string) - Header equals exact value
  • ContentTypeIs(types ...string) - Content-Type matches (MIME parsing)

Custom conditions:

  • Custom(fn func(*gin.Context) bool) - Custom condition function
  • OnTimeout() - Request has timed out
  • HasRequestID() - Request has a request ID set in context

RBAC conditions (require auth):

  • IsAuthenticated() - User is authenticated
  • HasPermission(service rbac.Service, resource, action string) - Combined role + user permissions
  • HasRolePermission(service rbac.Service, resource, action string) - Role-based permissions only
  • HasUserPermission(service rbac.Service, resource, action string) - Direct user permissions only

Middleware Overview

ErrorFormatter (unified error responses)

Unified error response formatting for all middleware. Instead of configuring error responses per-middleware, a single ErrorFormatter function controls how every middleware (auth, RBAC, timeout, rate limit, recovery) renders its error response.

Usage:

  • Chain.WithErrorFormat(f ErrorFormatter) - Set formatter for an entire chain
  • ErrorFormat(f ErrorFormatter) - Standalone middleware (for use without Chain)

Context helpers:

  • SetErrorFormatter(c *gin.Context, f ErrorFormatter) - Set formatter in context
  • GetErrorFormatter(c *gin.Context) ErrorFormatter - Get formatter from context (nil if not set)
  • AbortWithError(c *gin.Context, status int, message string) - Write error response using the formatter, or fall back to {"error": "<message>"}

Default behavior (no formatter set):

{"error": "request timeout"}

Example with Chain:

r.Use(ginx.NewChain().
    WithErrorFormat(func(status int, msg string) any {
        return gin.H{
            "code":    status,
            "message": msg,
            "success": false,
        }
    }).
    Use(ginx.Recovery()).
    Use(ginx.Logger()).
    Use(ginx.Timeout(ginx.WithTimeout(10 * time.Second))).
    Use(ginx.RateLimit(100, 200)).
    Build())

// All middleware errors now return:
// {"code": 429, "message": "rate limit exceeded", "success": false}
// {"code": 408, "message": "request timeout", "success": false}
// etc.

Example with standalone middleware (no Chain):

// Works with standard Gin middleware registration
r.Use(ginx.ErrorFormat(func(status int, msg string) any {
    return gin.H{"code": status, "message": msg}
})(func(c *gin.Context) { c.Next() }))

r.Use(ginx.Timeout(ginx.WithTimeout(10 * time.Second))(func(c *gin.Context) { c.Next() }))

Example per route group:

// Different formats for different API versions
v1 := r.Group("/api/v1")
v1.Use(ginx.NewChain().
    WithErrorFormat(func(status int, msg string) any {
        return gin.H{"error": msg, "status": status}
    }).
    Use(ginx.RateLimit(100, 200)).
    Build())

v2 := r.Group("/api/v2")
v2.Use(ginx.NewChain().
    WithErrorFormat(func(status int, msg string) any {
        return gin.H{"code": status, "message": msg, "ok": false}
    }).
    Use(ginx.RateLimit(100, 200)).
    Build())

Notes:

  • One ErrorFormatter replaces the need for per-middleware response options
  • All middleware use AbortWithError internally, so the formatter applies uniformly
  • When no formatter is set, the default response is {"error": "<message>"}

RequestID (correlation id)

Lightweight request correlation ID middleware. It sets/propagates a unique ID via header (default: X-Request-ID), stores it in Gin context, and can optionally inject the ID into Go's standard context.Context.

Usage:

  • RequestID(options...) - Adds/propagates request id

Options:

  • WithRequestIDHeader(name) - Change header name (default: X-Request-ID)
  • WithRequestIDGenerator(func() string) - Custom ID generator
  • WithContextInjector(func(ctx context.Context, requestID string) context.Context) - Inject request metadata into Go context (for service/repository logging with slog.InfoContext etc.)
  • Default respects incoming header if present; use WithIgnoreIncoming() to always generate a new ID

Type:

type ContextInjector func(ctx context.Context, requestID string) context.Context

Context helpers:

  • SetRequestID(c, id string) - Set request ID in context
  • GetRequestID(c) (string, bool) - Get request ID from context

Utility:

  • GetRequestIDFromHeader(r *http.Request, header string) string - Extract request ID from HTTP request header

Condition:

  • HasRequestID() - Check if request has a request ID set in context

Notes:

  • Logging and Recovery middlewares automatically include request_id if present
  • Place RequestID early in the chain (before Logger/Recovery) so all logs include the id
  • The middleware also echoes the ID back in the response header

Example: inject into Go context (for context-aware slog):

package main

import (
    "context"
    "log/slog"

    "github.com/gin-gonic/gin"
    "github.com/simp-lee/ginx"
    "github.com/simp-lee/logger"
)

func main() {
    r := gin.New()

    r.Use(ginx.RequestID(
        ginx.WithContextInjector(func(ctx context.Context, id string) context.Context {
            return logger.WithContextAttrs(ctx, slog.String("request_id", id))
        }),
    ))

    r.GET("/users", func(c *gin.Context) {
        // Service/repository layers can now read request_id from c.Request.Context()
        // and emit it automatically with slog.InfoContext.
        slog.InfoContext(c.Request.Context(), "list users")
        c.JSON(200, gin.H{"ok": true})
    })
}

Recovery (panic protection)

Graceful panic recovery middleware with intelligent error handling and structured logging.

Usage:

  • Recovery(loggerOptions...) - Basic recovery with default handler
  • RecoveryWith(handler RecoveryHandler, loggerOptions...) - Custom recovery handler

Types:

type RecoveryHandler func(*gin.Context, any)

Features:

  • Smart error detection: Distinguishes between panics and broken pipe errors
  • Structured logging: Uses github.com/simp-lee/logger with configurable options
  • Clean stack traces: Filters out recovery middleware frames and runtime panic calls
  • Broken pipe handling: Special treatment for client disconnections (warns without stack trace)
  • Custom responses: Configurable error response format via recovery handler

Default behavior:

  • Panics: Logs error + full stack trace, returns 500 JSON response
  • Broken pipes: Logs warning without stack trace, aborts connection gracefully

Example:

// Basic recovery with default handler
ginx.Recovery()

// Custom recovery handler with structured response
ginx.RecoveryWith(func(c *gin.Context, err any) {
    rid, _ := ginx.GetRequestID(c)
    c.JSON(500, gin.H{
        "error": "Internal Server Error", 
        "request_id": rid,
        "timestamp": time.Now().Unix(),
    })
}, logger.WithLevel(slog.LevelError), logger.WithConsole(true))

Logger (structured logs)

Structured HTTP request logging middleware with configurable log levels and comprehensive request metadata.

Usage:

  • Logger(loggerOptions...) - HTTP request logger with configurable options

Features:

  • Smart log levels: Automatic level based on status code (5xx=Error, 4xx=Warn, others=Info)
  • Rich metadata: Method, path, query, status, latency, IP, user agent, size, protocol, referer
  • Query sanitization: Automatically redacts sensitive query parameters (token, access_token, id_token, jwt, authorization, auth, password, secret)
  • Error tracking: Separate error logging for gin context errors (when present)
  • Structured format: Uses github.com/simp-lee/logger with key-value pairs
  • Performance optimized: Single timer measurement, minimal allocations
  • Client IP detection: Uses Gin's ClientIP() method (supports proxy headers)

Example:

// Basic logging with default configuration
ginx.Logger()

// Custom log level configuration
ginx.Logger(logger.WithLevel(slog.LevelDebug), logger.WithConsole(true))

Timeout

Context-based request timeout middleware with buffered response handling to prevent partial responses.

Usage:

  • Timeout(options...) - Request timeout middleware with configurable options

Options:

  • WithTimeout(duration) - Set timeout duration (default: 30 seconds)
  • WithMaxBufferSize(size int) - Set maximum response buffer size in bytes (default: 0 = unlimited)

Features:

  • Atomic response handling: Buffered writer prevents partial responses during timeout
  • Context cancellation: Proper request context timeout with cancellation
  • Timeout detection: Sets X-Timeout: true header for conditional middleware
  • Zero timeout support: Immediate timeout response for zero/negative durations

Helpers:

  • IsTimeout(c *gin.Context) bool - Check if request timed out
  • Condition OnTimeout() - Check X-Timeout header (pre-execution condition; not suitable for post-result timeout detection)

Important timing note:

  • OnTimeout() is evaluated before the wrapped middleware executes, so it cannot reliably detect timeouts that are decided later in the request lifecycle.
  • To detect timeout outcomes, use IsTimeout(c) after c.Next() in outer middleware.
  • Inside a timeout-protected handler, use c.Request.Context().Done() / c.Request.Context().Err() to stop work early.

Example:

// Different timeouts for different endpoints
chain := ginx.NewChain().
    When(ginx.PathHasPrefix("/api/heavy"), 
        ginx.Timeout(ginx.WithTimeout(60*time.Second))).
    Unless(ginx.PathIs("/health"), 
        ginx.Timeout(ginx.WithTimeout(5*time.Second)))

CORS

Cross-Origin Resource Sharing (CORS) middleware with security-first design and proper preflight handling.

Usage:

  • CORS(options...) - CORS middleware with explicit origin configuration (required)
  • CORSDefault() - Development-only helper (allows all origins)

Options:

  • WithAllowOrigins(origins...) - Set allowed origins (required, no default)
  • WithAllowMethods(methods...) - Set allowed HTTP methods (default: GET, POST, PUT, DELETE, OPTIONS)
  • WithAllowHeaders(headers...) - Set allowed request headers (default: Content-Type, Authorization, Cache-Control, X-Requested-With)
  • WithExposeHeaders(headers...) - Set headers exposed to client (default: none)
  • WithAllowCredentials(allow bool) - Allow credentials like cookies/auth headers (default: false)
  • WithMaxAge(duration) - Set preflight cache duration (default: 12 hours)

Security features:

  • Explicit origins required: No default origins for security
  • Credentials validation: Prevents wildcard origins with credentials (runtime panic)
  • Proper preflight handling: Full OPTIONS request validation
  • Vary headers: Prevents proxy cache pollution

Example:

// Development: Allow all origins (use with caution)
ginx.CORS(ginx.WithAllowOrigins("*"))

// Production: Explicit security configuration
ginx.CORS(
    ginx.WithAllowOrigins("https://example.com", "https://app.example.com"),
    ginx.WithAllowHeaders("Content-Type", "Authorization"),
    ginx.WithAllowCredentials(true),
)

Security note: WithAllowCredentials(true) cannot be used with wildcard origin "*" (enforced at runtime).

Auth (JWT)

JWT authentication middleware with secure-by-default token extraction and comprehensive context integration.

Usage:

  • Auth(jwtService jwt.Service, options ...Option[AuthConfig]) - JWT authentication middleware
  • WithAuthQueryToken(true) - Explicitly enable ?token= query fallback (disabled by default)

Features:

  • Secure default token extraction: Uses Authorization: Bearer <token> by default
  • Optional query fallback: ?token=<token> is only enabled with WithAuthQueryToken(true)
  • Automatic context population: Sets user ID, roles, and token metadata in gin context
  • Type-safe context keys: Uses typed context keys to prevent conflicts
  • Validation & parsing: Uses jwtService.ValidateAndParse() for comprehensive token validation

Context helpers (getters):

  • GetUserID(c) (string, bool) - Get authenticated user ID
  • GetUserRoles(c) ([]string, bool) - Get user roles from token
  • GetTokenID(c) (string, bool) - Get JWT token ID
  • GetTokenExpiresAt(c) (time.Time, bool) - Get token expiration time
  • GetTokenIssuedAt(c) (time.Time, bool) - Get token issued time
  • GetUserIDOrAbort(c) (string, bool) - Get user ID or abort with 401 if not authenticated

Context helpers (setters):

  • SetUserID(c, userID string) - Set user ID in context
  • SetUserRoles(c, roles []string) - Set user roles in context
  • SetTokenID(c, tokenID string) - Set token ID in context
  • SetTokenExpiresAt(c, expiresAt time.Time) - Set token expiration
  • SetTokenIssuedAt(c, issuedAt time.Time) - Set token issued time

Example:

jwtService, _ := jwt.New("secret-key", jwt.WithLeeway(5*time.Minute))

// Protect API routes with JWT
r.Use(ginx.NewChain().
    When(ginx.PathHasPrefix("/api/"), ginx.Auth(jwtService)).
    Build())

RBAC (Role-Based Access Control)

Role-based access control middleware with fine-grained permission checking and condition support.

Usage:

  • Middlewares (require authentication):
    • RequirePermission(service rbac.Service, resource, action string) - Check combined role + user permissions
    • RequireRolePermission(service rbac.Service, resource, action string) - Check role-based permissions only
    • RequireUserPermission(service rbac.Service, resource, action string) - Check direct user permissions only

Features:

  • Three permission models: Combined, role-only, and user-only permission checking
  • Automatic authentication check: Uses GetUserIDOrAbort() for user validation
  • Detailed error responses: Distinguishes between permission check failures (500) and access denied (403)
  • Integration with Auth: Seamlessly works with JWT authentication middleware

Conditions (for conditional middleware):

  • IsAuthenticated() - Check if user is authenticated (no service required)
  • HasPermission(service rbac.Service, resource, action string) - Check combined permissions
  • HasRolePermission(service rbac.Service, resource, action string) - Check role permissions
  • HasUserPermission(service rbac.Service, resource, action string) - Check user permissions

Error handling:

  • 500 Internal Server Error: Permission check failed (service error)
  • 403 Forbidden: Permission denied (access not allowed)
  • 401 Unauthorized: User not authenticated (handled by GetUserIDOrAbort)

Example:

rbacService, _ := rbac.New()

// Require admin permissions for admin routes
r.Use(ginx.NewChain().
    When(ginx.PathHasPrefix("/api/admin/"), 
        ginx.RequireRolePermission(rbacService, "admin", "access")).
    Build())

Cache (response caching)

HTTP-compliant response caching middleware with intelligent cache control and group support.

Usage:

  • Cache(cache shardedcache.CacheInterface) - Cache all cacheable responses (default group)
  • CacheWithGroup(cache shardedcache.CacheInterface, groupName string) - Cache with group prefix for isolation
  • CacheWithOptions(cache shardedcache.CacheInterface, opts ...CacheOption) - Cache with custom options (default group)
  • CacheWithGroupOptions(cache shardedcache.CacheInterface, groupName string, opts ...CacheOption) - Grouped cache with custom options

Features:

  • HTTP-compliant caching: Respects Cache-Control: no-store/private/no-cache/must-revalidate/max-age=0 directives
  • Smart exclusions: Automatically excludes responses with Set-Cookie headers to prevent user data leakage
  • Auth/session-safe default: Skips caching when request contains Authorization or Cookie header
  • Range-safe: Bypasses cache for Range requests and Content-Range responses (partial content)
  • 2xx-only caching: Only caches successful responses (200-299, excluding 206 Partial Content)
  • GET & HEAD support: Caches both GET and HEAD responses; only body is omitted on HEAD replay
  • Safer default cache keys: Generated from HTTP method + host + path + query (METHOD|HOST|PATH?QUERY)
  • Content negotiation safety: Default keys include Accept-Encoding variant when present to avoid representation mix-ups
  • Configurable key strategy: WithCacheKeyFunc(func(*gin.Context) string) supports custom variant/context dimensions
  • Configurable vary dimensions: WithCacheVaryHeaders(headers...) extends/overrides header dimensions used by default key generation
  • Response reconstruction: Preserves status code/body and replays full response headers including multi-value headers (Link, Vary, etc.); responses with Set-Cookie are not cached
  • Group isolation: Optional grouping for cache namespace separation

Cache key format:

GET|api.example.com|/api/users                         // No query parameters
GET|api.example.com|/api/search?q=test&limit=10        // With query parameters
GET|api.example.com|/api/users|h:Accept-Encoding=gzip  // Content-encoding variant

Example:

cache := shardedcache.NewCache(shardedcache.Options{
    MaxSize: 1000,
    DefaultExpiration: 5 * time.Minute,
})

// Cache GET requests with version-specific grouping
r.Use(ginx.NewChain().
    When(ginx.And(ginx.MethodIs("GET"), ginx.PathHasPrefix("/api/v1/")), 
        ginx.CacheWithGroup(cache, "api-v1")).
    When(ginx.And(ginx.MethodIs("GET"), ginx.PathHasPrefix("/api/v2/")), 
        ginx.CacheWithGroup(cache, "api-v2")).
    When(ginx.And(
        ginx.MethodIs("GET"), 
        ginx.PathHasPrefix("/api/"),
        ginx.Not(ginx.Or(
            ginx.PathHasPrefix("/api/v1/"),
            ginx.PathHasPrefix("/api/v2/"),
        )),
    ), ginx.Cache(cache)).
    Build())

Rate Limit (token bucket & time windows)

High-performance rate limiting middleware supporting both token bucket (RPS) and time-window strategies (per minute/hour/day).

Token Bucket Rate Limiting (RPS)

Smooth rate limiting using token bucket algorithm for requests per second.

Usage:

  • RateLimit(rps int, burst int, opts ...RateOption) - Token bucket rate limiting with configurable options

Key generation options:

  • WithIP() - IP-based rate limiting (default behavior)
  • WithUser() - Per-user rate limiting using authenticated context (user_id)
  • WithTrustedUserHeader(name) - Optional trusted header fallback for gateway-injected identity
  • WithPath() - Per-path rate limiting (different limits per endpoint)
  • WithKeyFunc(keyFunc func(*gin.Context) string) - Custom key generation function

Security note:

  • WithUser() does not trust client-provided headers.
  • Use WithTrustedUserHeader(name) only when the header is set by trusted infrastructure (API gateway/auth proxy) and cannot be spoofed by clients.

Control options:

  • WithSkipFunc(skipFunc func(*gin.Context) bool) - Skip certain requests
  • WithWait(timeout time.Duration) - Wait for tokens instead of immediate rejection
  • WithDynamicLimits(getLimits func(key string) (rps, burst int)) - Dynamic per-key limits
  • WithStore(store RateLimitStore) - Custom storage backend (default: shared memory; see Custom Storage Backends)

Header options:

  • WithoutRateLimitHeaders() - Disable X-RateLimit-* headers
  • WithoutRetryAfterHeader() - Disable Retry-After header (enabled by default)

Features:

  • Token bucket algorithm: Smooth rate limiting using golang.org/x/time/rate
  • Multiple key strategies: IP, user ID, path, or custom key generation
  • Dynamic limits: Per-key rate limits based on user plan, endpoint type, etc.
  • Wait middleware: Traffic smoothing by waiting for available tokens
  • HTTP compliance: Standard X-RateLimit-* and Retry-After headers
  • Thread-safe: Designed for high-concurrency environments

HTTP headers:

X-RateLimit-Limit: 100              // Requests per second
X-RateLimit-Remaining: 85            // Available tokens
X-RateLimit-Reset: 1234567890        // Token bucket full reset time (Unix timestamp)
Retry-After: 3                       // Seconds to wait (429 responses only)

Note:

  • In unlimited mode (both rps and burst are <= 0), no X-RateLimit-* headers are returned.

Example:

// Basic IP-based rate limiting: 100 rps, burst 200
r.Use(ginx.RateLimit(100, 200))

// Dynamic per-user limits with wait mode
r.Use(ginx.RateLimit(0, 0,
    ginx.WithUser(),
    ginx.WithWait(2*time.Second),
    ginx.WithDynamicLimits(func(key string) (int, int) {
        if strings.HasPrefix(key, "user:premium_") { 
            return 100, 200  // Premium users
        }
        return 10, 20        // Regular users
    }),
))

Time-Window Rate Limiting (Per Minute/Hour/Day)

Fixed window rate limiting for precise quota management.

Usage:

  • RateLimitPerMinute(limit int, opts ...RateOption) - Maximum requests per minute
  • RateLimitPerHour(limit int, opts ...RateOption) - Maximum requests per hour
  • RateLimitPerDay(limit int, opts ...RateOption) - Maximum requests per day

Supported options:

  • WithIP() - IP-based limiting (default)
  • WithUser() - Per-user limiting
  • WithPath() - Per-path limiting
  • WithKeyFunc() - Custom key function
  • WithSkipFunc() - Skip certain requests
  • WithWindowStore(store WindowCounterStore) - Custom storage backend (see Custom Storage Backends)
  • WithDynamicWindowLimits(getLimit func(key string) int) - Dynamic per-key limits
  • WithoutRateLimitHeaders() - Disable headers
  • WithoutRetryAfterHeader() - Disable Retry-After header

Note: Time-window rate limiting does not support WithWait() option.

Features:

  • Fixed window algorithm: Precise quota control within time windows
  • Window reset times:
    • Minute: At 0 seconds of each minute (e.g., 14:35:00)
    • Hour: At 0 minutes of each hour (e.g., 14:00:00)
    • Day: At midnight each day (00:00:00)
  • Independent counters: Each window maintains its own counter
  • Automatic cleanup: Expired counters are automatically removed
  • Thread-safe: Designed for high-concurrency environments

HTTP headers:

X-RateLimit-Limit-Minute: 60         // Maximum per minute
X-RateLimit-Remaining-Minute: 45     // Remaining this minute
X-RateLimit-Reset-Minute: 1234567890 // Window reset time (Unix timestamp)
Retry-After: 15                      // Seconds until window resets (429 only)

Example:

// Limit to 60 requests per minute
r.Use(ginx.RateLimitPerMinute(60))

// Limit to 1000 requests per hour per user
r.Use(ginx.RateLimitPerHour(1000, ginx.WithUser()))

// Limit to 10000 requests per day
r.Use(ginx.RateLimitPerDay(10000))

// Dynamic per-user limits based on user tier
r.Use(ginx.RateLimitPerHour(0, // Base limit ignored when using dynamic limits
    ginx.WithUser(),
    ginx.WithDynamicWindowLimits(func(key string) int {
        if strings.Contains(key, "user:premium_") {
            return 100000  // Premium: 100k per hour
        }
        if strings.Contains(key, "user:pro_") {
            return 10000   // Pro: 10k per hour
        }
        return 1000        // Free: 1k per hour
    }),
))

Combined Rate Limiting (Recommended)

Combine RPS and time-window rate limiting for multi-layer protection.

Usage:

// Two-layer protection: RPS + hourly quota
r.Use(ginx.NewChain().
    Use(ginx.RateLimit(10, 20)).       // Prevent instant spikes
    Use(ginx.RateLimitPerHour(1000)).  // Hourly quota management
    Build())

// Three-layer protection: RPS + hourly + daily quota
r.Use(ginx.NewChain().
    Use(ginx.RateLimit(5, 10)).        // Instant protection
    Use(ginx.RateLimitPerHour(1000)).  // Hourly quota
    Use(ginx.RateLimitPerDay(10000)).  // Daily quota
    Build())

Use cases:

  • Public APIs: Moderate RPS + daily quota
  • Premium users: High RPS + generous hourly/daily quota
  • Sensitive operations: Strict RPS + low hourly/daily quota
  • Heavy endpoints: Low RPS + low hourly quota

Response headers when combined:

X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Reset: 1700000001      // Unix timestamp when the bucket refills
X-RateLimit-Limit-Hour: 1000
X-RateLimit-Remaining-Hour: 850
X-RateLimit-Reset-Hour: 1700003600 // Unix timestamp when the hourly window resets
Retry-After: 30

Resource management:

  • Built-in shared memory stores with automatic cleanup
  • Call ginx.CleanupRateLimiters() on application shutdown for comprehensive cleanup

Custom Storage Backends

For advanced scenarios (e.g. Redis-backed rate limiting), implement the exported store interfaces:

Token bucket store:

// RateLimitStore defines the interface for storing and managing rate limiters.
type RateLimitStore interface {
    Get(key string) (*rate.Limiter, bool)
    Set(key string, limiter *rate.Limiter)
    Delete(key string)
    Clear()
    Close() error
}

Time-window counter store:

// WindowCounterStore defines the interface for storing time-window based counters.
type WindowCounterStore interface {
    Increment(key string, window time.Time) (int64, error)
    IncrementWithinLimit(key string, window time.Time, limit int64) (count int64, allowed bool, err error)
    Get(key string, window time.Time) (int64, error)
    Clear()
    Close() error
}

Built-in constructors:

  • NewMemoryLimiterStore(maxIdle time.Duration) RateLimitStore - In-memory token bucket store with auto-cleanup
  • NewMemoryWindowCounterStore(maxIdle time.Duration) WindowCounterStore - In-memory window counter store with auto-cleanup

Example:

// Use custom store for token bucket rate limiting
customStore := ginx.NewMemoryLimiterStore(30 * time.Minute)
r.Use(ginx.RateLimit(100, 200, ginx.WithStore(customStore)))

// Use custom store for window rate limiting
windowStore := ginx.NewMemoryWindowCounterStore(2 * time.Hour)
r.Use(ginx.RateLimitPerHour(1000, ginx.WithWindowStore(windowStore)))

Advanced Examples

Production API Server

package main

import (
    "time"
    "github.com/gin-gonic/gin"
    "github.com/simp-lee/ginx"
    "github.com/simp-lee/jwt"
    "github.com/simp-lee/rbac"
    shardedcache "github.com/simp-lee/cache"
)

func main() {
    r := gin.New()
    
    // Setup services with proper configuration
    jwtService, _ := jwt.New("your-super-secret-key-here",
        jwt.WithLeeway(5*time.Minute),
        jwt.WithIssuer("ginx-app"),
        jwt.WithMaxTokenLifetime(24*time.Hour),
    )
    rbacService, _ := rbac.New() // Default memory storage
    cache := shardedcache.NewCache(shardedcache.Options{
        MaxSize:           1000,
        DefaultExpiration: 5 * time.Minute,
        ShardCount:        16,              // Concurrent access optimization
        CleanupInterval:   1 * time.Minute, // Automatic cleanup
    })
    
    // Production middleware chain with conditional logic
    isAPIPath := ginx.PathHasPrefix("/api/")
    isPublicPath := ginx.Or(ginx.PathIs("/api/login", "/api/register"))
    isHealthPath := ginx.Or(ginx.PathIs("/health", "/metrics"))
    isAdminPath := ginx.PathHasPrefix("/api/admin/")
    
    r.Use(ginx.NewChain().
        OnError(func(c *gin.Context, err error) {
            c.JSON(500, gin.H{"error": "Internal server error"})
        }).
        // Base middleware for all requests
        Use(ginx.Recovery()).
        Use(ginx.Logger()).
        // CORS for web clients (production origins)
        Use(ginx.CORS(
            ginx.WithAllowOrigins("https://yourdomain.com", "https://app.yourdomain.com"),
            ginx.WithAllowMethods("GET", "POST", "PUT", "DELETE", "OPTIONS"),
            ginx.WithAllowHeaders("Content-Type", "Authorization"),
            ginx.WithAllowCredentials(true),
        )).
        // Different timeouts for different endpoint types
        When(ginx.PathHasPrefix("/api/heavy/"), 
            ginx.Timeout(ginx.WithTimeout(60*time.Second))).
        Unless(isHealthPath, 
            ginx.Timeout(ginx.WithTimeout(30*time.Second))).
        // Multi-layer rate limiting (skip health checks)
        When(ginx.Not(isHealthPath), 
            ginx.RateLimit(100, 200)).
        When(ginx.Not(isHealthPath), 
            ginx.RateLimitPerHour(10000)).
        When(ginx.Not(isHealthPath), 
            ginx.RateLimitPerDay(100000)).
        // JWT authentication for API routes (skip public endpoints)
        When(ginx.And(isAPIPath, ginx.Not(isPublicPath)),
            ginx.Auth(jwtService)).
        // Admin area protection
        When(isAdminPath, 
            ginx.RequirePermission(rbacService, "admin", "access")).
        // Cache only public GET API responses (requests with Authorization are skipped by default)
        When(ginx.And(ginx.MethodIs("GET"), isAPIPath, ginx.Not(ginx.IsAuthenticated())),
            ginx.Cache(cache)).
        Build())
        
    // Routes
    r.GET("/health", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "ok"})
    })
    
    api := r.Group("/api")
    {
        api.POST("/login", handleLogin)
        api.GET("/users", handleGetUsers)      // Cached
        api.POST("/users", handleCreateUser)   // Not cached
        
        admin := api.Group("/admin")
        {
            admin.GET("/stats", handleAdminStats)     // Requires admin role
            admin.DELETE("/users/:id", handleDeleteUser) // Requires admin role
        }
    }
    
    r.Run(":8080")
}

Microservice with Conditional Rate Limiting

func setupMicroservice() gin.HandlerFunc {
    return ginx.NewChain().
        Use(ginx.Recovery()).
        Use(ginx.Logger()).
        Use(ginx.Timeout(ginx.WithTimeout(10*time.Second))).
        // Different rate limits for different client types
        When(ginx.PathHasPrefix("/internal/"), 
            ginx.RateLimit(1000, 2000)).  // High limits for internal services
        When(ginx.PathHasPrefix("/api/public/"), 
            ginx.RateLimit(10, 20)).      // Low RPS for public API
        When(ginx.PathHasPrefix("/api/public/"), 
            ginx.RateLimitPerHour(1000)). // Hourly quota for public API
        When(ginx.And(
            ginx.PathHasPrefix("/api/"),
            ginx.HeaderExists("X-API-Key"),
        ), ginx.RateLimit(100, 200)).     // Medium limits for API key users
        Build()
}

Multi-tenant SaaS Application

// Per-tenant RPS rate limiting with dynamic limits based on subscription plan
r.Use(ginx.RateLimit(0, 0,
    ginx.WithUser(),  // Rate limit per user
    ginx.WithDynamicLimits(func(key string) (int, int) {
        // key format: "user:<id>"
        if strings.Contains(key, "user:premium_") {
            return 1000, 2000  // Premium users: 1000 RPS, burst 2000
        }
        if strings.Contains(key, "user:pro_") {
            return 100, 200    // Pro users: 100 RPS, burst 200
        }
        return 10, 20          // Free users: 10 RPS, burst 20
    }),
))

// Per-user hourly quotas based on subscription plan
r.Use(ginx.RateLimitPerHour(0,
    ginx.WithUser(),
    ginx.WithDynamicWindowLimits(func(key string) int {
        if strings.Contains(key, "user:premium_") {
            return 100000  // Premium: 100k per hour
        }
        if strings.Contains(key, "user:pro_") {
            return 10000   // Pro: 10k per hour
        }
        return 1000        // Free: 1k per hour
    }),
))

// Feature-based conditional access control
isAnalyticsPath := ginx.PathHasPrefix("/api/analytics/")
isBillingPath := ginx.PathHasPrefix("/api/billing/")
isReportingPath := ginx.PathHasPrefix("/api/reporting/")

r.Use(ginx.NewChain().
    // Analytics requires analytics permission
    When(isAnalyticsPath, 
        ginx.RequireRolePermission(rbacService, "analytics", "read")).
    // Billing requires billing access
    When(isBillingPath, 
        ginx.RequireRolePermission(rbacService, "billing", "access")).
    // Advanced reporting for premium users only
    When(isReportingPath,
        ginx.RequireRolePermission(rbacService, "reporting", "generate")).
    // Cache expensive analytics queries
    When(ginx.And(isAnalyticsPath, ginx.MethodIs("GET")),
        ginx.CacheWithGroup(cache, "analytics")).
    Build())

Complete Cache Strategy Example

// Real-world caching strategy with multiple cache groups and conditions
func setupAdvancedCaching(r *gin.Engine, cache shardedcache.CacheInterface) {
    // Define path conditions for clarity
    isAPIPath := ginx.PathHasPrefix("/api/")
    isPublicData := ginx.PathHasPrefix("/public/")
    isUserSpecific := ginx.PathHasPrefix("/api/users/")
    isAdminData := ginx.PathHasPrefix("/admin/")
    
    // Advanced caching chain with different strategies
    r.Use(ginx.NewChain().
        // Cache public data aggressively (separate group for easy management)
        When(ginx.And(ginx.MethodIs("GET"), isPublicData),
            ginx.CacheWithGroup(cache, "public")).
        // Cache API GET requests but exclude health/status endpoints
        When(ginx.And(
            ginx.MethodIs("GET"),
            isAPIPath,
            ginx.Not(ginx.Or(ginx.PathIs("/api/health", "/api/status"))),
        ), ginx.CacheWithGroup(cache, "api")).
        // User-specific data with separate group (privacy isolation)
        When(ginx.And(ginx.MethodIs("GET"), isUserSpecific),
            ginx.CacheWithGroup(cache, "users")).
        // Never cache admin data (add no-cache headers via custom middleware)
        When(isAdminData, func(next gin.HandlerFunc) gin.HandlerFunc {
            return func(c *gin.Context) {
                c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
                next(c)
            }
        }).
        Build())
}

Combined Rate Limiting Strategies

// Multi-layer rate limiting for different scenarios
func setupRateLimiting(r *gin.Engine) {
    // Example 1: Public API with burst + quota protection
    publicAPI := r.Group("/api/public")
    publicAPI.Use(ginx.NewChain().
        Use(ginx.RateLimit(10, 20)).        // Prevent instant spikes
        Use(ginx.RateLimitPerHour(1000)).   // Hourly quota
        Use(ginx.RateLimitPerDay(10000)).   // Daily quota
        Build())

    // Example 2: Authenticated API with per-user limits
    authAPI := r.Group("/api/v1")
    authAPI.Use(ginx.NewChain().
        Use(ginx.RateLimit(50, 100, ginx.WithUser())).     // Per-user RPS
        Use(ginx.RateLimitPerHour(5000, ginx.WithUser())). // Per-user hourly quota
        Build())

    // Example 3: Heavy operations with strict limits
    r.POST("/api/heavy-task", 
        ginx.NewChain().
            Use(ginx.RateLimit(1, 1)).       // Only 1 request per second
            Use(ginx.RateLimitPerHour(10)).  // Max 10 per hour
            Use(ginx.RateLimitPerDay(50)).   // Max 50 per day
            Build(),
        handleHeavyTask)

    // Example 4: Path-based rate limiting for different endpoints
    r.Use(ginx.NewChain().
        When(ginx.PathHasPrefix("/api/search/"), ginx.NewChain().
            Use(ginx.RateLimit(5, 10, ginx.WithPath())).
            Use(ginx.RateLimitPerMinute(50, ginx.WithPath())).
            Build()).
        When(ginx.PathHasPrefix("/api/login"), ginx.NewChain().
            Use(ginx.RateLimit(1, 2, ginx.WithIP())).
            Use(ginx.RateLimitPerHour(5, ginx.WithIP())).
            Build()).
        Build())

    // Example 5: Dynamic per-user tier-based rate limiting
    r.Use(ginx.NewChain().
        // RPS based on user tier (supports dynamic limits)
        Use(ginx.RateLimit(0, 0,
            ginx.WithUser(),
            ginx.WithDynamicLimits(getUserRPSLimits))).
        // Hourly quota based on user tier
        Use(ginx.RateLimitPerHour(0,
            ginx.WithUser(),
            ginx.WithDynamicWindowLimits(getUserHourlyLimits))).
        // Daily quota based on user tier
        Use(ginx.RateLimitPerDay(0,
            ginx.WithUser(),
            ginx.WithDynamicWindowLimits(getUserDailyLimits))).
        Build())
}

func getUserRPSLimits(key string) (int, int) {
    // Extract user tier from key
    if strings.Contains(key, "user:premium_") {
        return 100, 200  // Premium: 100 RPS, burst 200
    }
    if strings.Contains(key, "user:pro_") {
        return 50, 100   // Pro: 50 RPS, burst 100
    }
    return 10, 20        // Free: 10 RPS, burst 20
}

func getUserHourlyLimits(key string) int {
    if strings.Contains(key, "user:premium_") {
        return 50000  // Premium: 50k per hour
    }
    if strings.Contains(key, "user:pro_") {
        return 5000   // Pro: 5k per hour
    }
    return 500        // Free: 500 per hour
}

func getUserDailyLimits(key string) int {
    if strings.Contains(key, "user:premium_") {
        return 1000000  // Premium: 1M per day
    }
    if strings.Contains(key, "user:pro_") {
        return 100000   // Pro: 100k per day
    }
    return 10000        // Free: 10k per day
}

Test Helpers

Ginx exports lightweight test utilities (in test_helpers.go) so downstream packages can unit-test their middleware and handlers without boilerplate.

Context & handler helpers:

  • TestContext(method, path string, headers map[string]string) (*gin.Context, *httptest.ResponseRecorder) - Create a ready-to-use gin.Context in test mode with custom method, path, headers, and a valid RemoteAddr
  • TestMiddleware(name string, executed *[]string) Middleware - Create a middleware that records its execution by appending name to the slice
  • TestHandler(executed *[]string) gin.HandlerFunc - Create a handler that records execution by appending "handler" to the slice

Rate limit helpers:

  • SetupRateLimitTest(t testing.TB) - Register automatic cleanup of global rate limiter state via t.Cleanup; call at the start of any test that exercises rate limiting

Assertion helpers:

  • AssertContains(slice []string, item string) bool - Check if a string slice contains an element
  • AssertEqual(expected, actual interface{}) bool - Check equality of two values
  • AssertSliceEqual(expected, actual []string) bool - Check equality of two string slices

Example:

package myapp

import (
    "testing"
    "github.com/simp-lee/ginx"
)

func TestMyMiddleware(t *testing.T) {
    ginx.SetupRateLimitTest(t) // automatic rate-limiter cleanup

    var executed []string
    c, w := ginx.TestContext("GET", "/api/test", map[string]string{
        "Authorization": "Bearer token123",
    })

    // Build a chain: your middleware + recording handler
    chain := ginx.NewChain().
        Use(ginx.TestMiddleware("before", &executed)).
        Use(MyCustomMiddleware()).
        Build()

    chain(c)

    if w.Code != 200 {
        t.Errorf("expected 200, got %d", w.Code)
    }
    if !ginx.AssertContains(executed, "before") {
        t.Error("expected 'before' middleware to execute")
    }
}

Performance Notes

  • Conditions efficiency: Most conditions are zero-allocation; ContentTypeIs parses MIME (small overhead), and PathMatches compiles the regex at condition creation time (not per request).
  • Functional composition: Minimal middleware chain overhead with conditional execution
  • Sharded caching: Reduced lock contention for high-concurrency scenarios
  • Rate limiting: Token bucket for smooth RPS control, fixed window counters for quota management, both with automatic memory cleanup
  • Compiled patterns: Cached regex for PathMatches() condition

Dependencies

Core dependencies:

  • github.com/gin-gonic/gin v1.11.0 - Web framework
  • golang.org/x/time v0.14.0 - Rate limiting implementation

Feature dependencies (pulled automatically):

  • github.com/simp-lee/jwt - JWT authentication (Auth middleware)
  • github.com/simp-lee/rbac - Role-based access control (RBAC middleware)
  • github.com/simp-lee/logger - Structured logging (Logger/Recovery middleware)
  • github.com/simp-lee/cache - Response caching (Cache middleware)

Testing:

  • github.com/stretchr/testify v1.11.1 - Test assertions

License

MIT

About

Minimal, composable, and high-performance middleware toolkit for Gin with conditional execution and functional chaining

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages