Middleware Patterns
Middleware Patterns
In 1992, the object-oriented community had a popular saying: "Good software is made of layers." That idea later evolved into MVC, layered architecture, the onion model... but one problem was never perfectly solved by these architectural patterns: cross-cutting concerns.
Cross-cutting concerns are features that cut across multiple layers: logging, authentication, rate limiting, tracing, caching, CORS. You can't put them in any single layer — they belong to all layers, or rather, they exist outside all layers.
Middleware is the answer to this problem. Not the only answer, but in the domain of HTTP services, it is the most elegant one.
Level 1 · What You Need to Know
What Middleware Is: The Onion Model
Imagine a cross-section of an onion. An HTTP request enters from the outermost layer, passes through each onion layer, reaches the innermost business handler, and then the response travels back out through each layer.
Request direction → ← Response direction
┌─────────────────────────────────────────────┐
│ Logger Middleware │
│ ┌───────────────────────────────────────┐ │
│ │ Auth Middleware │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ Rate Limiter Middleware │ │ │
│ │ │ ┌───────────────────────────┐ │ │ │
│ │ │ │ Business Handler │ │ │ │
│ │ │ └───────────────────────────┘ │ │ │
│ │ └─────────────────────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Every middleware has two execution phases:
- Before phase: Code before calling
c.Next()(runs when the request comes in) - After phase: Code after calling
c.Next()(runs when the response goes out)
This is the essence of the onion model: a single function handles both request entry and response exit.
Why Middleware Is the Right Abstraction for Cross-Cutting Concerns
Consider the alternative. What if there were no middleware — we hand-coded logging logic in every handler:
// A world without middleware
func getUserHandler(c *gin.Context) {
start := time.Now()
// Authentication
token := c.GetHeader("Authorization")
if !validateToken(token) {
log.Printf("auth failed: %s %s", c.Request.Method, c.Request.URL)
c.JSON(401, gin.H{"error": "unauthorized"})
return
}
// Rate limit check
if !rateLimiter.Allow() {
log.Printf("rate limited: %s %s", c.Request.Method, c.Request.URL)
c.JSON(429, gin.H{"error": "too many requests"})
return
}
// Actual business logic
user := getUser(c.Param("id"))
c.JSON(200, user)
// Log the request
log.Printf("method=%s path=%s status=%d latency=%v",
c.Request.Method, c.Request.URL, 200, time.Since(start))
}
This code has several problems:
- Repetition (DRY violation): Every handler must repeat authentication, logging, and rate limiting. 500 handlers means 500 copies.
- Coupling: Business logic and infrastructure logic are intertwined. Unit-testing
getUserHandlerrequires mocking auth and rate limiting. - Inconsistency risk: An intern writes a handler, forgets the rate limit check, and nobody notices.
Middleware solves all three problems:
- Centralization: Each cross-cutting concern has one implementation; all handlers benefit automatically.
- Decoupling: Handlers focus on business logic; middleware handles infrastructure. Handler unit tests can be called directly without mocking any middleware.
- Enforcement: Middleware applied at the route group level automatically protects every route in that group — nothing can be overlooked.
The Core Philosophy of Middleware: Composability
Middleware's power comes from composability. Each middleware is an independent, individually testable unit that can be combined arbitrarily:
Infrastructure: Logger, Recovery, RequestID
Security: CORS, SecurityHeaders, CSRF
Authentication: JWTAuth, APIKeyAuth, SessionAuth
Business: RateLimiter, Tenant, Permission
Different route groups use different combinations:
public := r.Group("/") // Logger + Recovery
authed := r.Group("/") // Logger + Recovery + JWTAuth
admin := r.Group("/admin") // Logger + Recovery + JWTAuth + AdminPermission
This is the Unix pipeline philosophy applied to the HTTP world: each component does one thing well, then you chain them together.
Level 2 · How It Works
Gin's Middleware Execution Mechanism: Deep Dive into c.Next()
Gin's middleware execution model is fundamentally a stateful loop iterator:
// gin/context.go core section (simplified and annotated)
type Context struct {
// ...
handlers HandlersChain // []HandlerFunc, complete handler chain for this request
index int8 // which handler we're currently executing; -1 means not started
}
func (c *Context) Next() {
c.index++
for c.index < int8(len(c.handlers)) {
c.handlers[c.index](c)
c.index++
}
}
Let's trace a request through the [Logger, Auth, BusinessHandler] chain:
Initial state: index = -1
1. Server calls c.Next()
index becomes 0, executes Logger (before-code)
Logger calls c.Next()
index becomes 1, executes Auth (before-code)
Auth calls c.Next()
index becomes 2, executes BusinessHandler
BusinessHandler returns
index becomes 3, loop condition fails, inner Next() exits
Auth (after-code) executes
Auth returns
index becomes 2 (incremented in outer Next()'s loop)
Logger (after-code) executes
Logger returns
Key insight: c.Next() calls are recursive. Each middleware's c.Next() call blocks until all subsequent handlers have completed. This is why you can access the response status code after c.Next():
func LoggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
c.Next() // at this point, ALL subsequent handlers have completed
// We can access the response status code here!
latency := time.Since(start)
status := c.Writer.Status() // the status code that was written
size := c.Writer.Size() // the number of bytes written
log.Printf("[%d] %s %s %v %d bytes",
status, c.Request.Method, path, latency, size)
}
}
Middleware State Propagation: c.Set and c.Get
Middleware needs to pass data to downstream handlers (e.g., the Auth middleware extracts a user ID that the business handler needs). Gin provides the c.Set/c.Get mechanism:
// Auth middleware sets user information
func JWTAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c)
claims, err := validateJWT(token)
if err != nil {
c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"})
return
}
// Store parsed user info in Context
c.Set("user_id", claims.UserID)
c.Set("user_role", claims.Role)
c.Set("user_email", claims.Email)
c.Next()
}
}
// Downstream handler retrieves user info
func getProfileHandler(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
c.JSON(500, gin.H{"error": "user_id not found in context"})
return
}
// Type assertion (c.Get returns interface{})
uid, ok := userID.(int64)
if !ok {
c.JSON(500, gin.H{"error": "user_id type assertion failed"})
return
}
profile := fetchProfile(uid)
c.JSON(200, profile)
}
c.Set/c.Get internally uses map[string]interface{} with concurrency safety guaranteed (Gin uses a read-write lock internally). Note: this map is only valid for the lifetime of a single request — it cannot be shared across requests.
Type-safe helper methods: Gin provides c.GetString, c.GetInt64, c.GetBool, and similar methods to avoid manual type assertions:
userID := c.GetInt64("user_id") // returns zero value if not found or wrong type
userRole := c.GetString("user_role")
Stopping the Chain: Variants of c.Abort()
Beyond basic c.Abort(), Gin provides several convenience methods:
c.Abort() // stop chain, write no response
c.AbortWithStatus(403) // stop chain, write status code
c.AbortWithStatusJSON(403, data) // stop chain, write JSON response
c.AbortWithError(500, err) // stop chain, record error (for error-collecting middleware)
Use case for c.AbortWithError: Sometimes you want middleware to "record an error but let downstream middleware decide how to respond":
func ValidationMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if err := validateRequest(c); err != nil {
// Record error, but don't write a response yet
c.AbortWithError(http.StatusBadRequest, err).SetType(gin.ErrorTypePublic)
return
}
c.Next()
}
}
// Error-handling middleware (must be registered first to catch errors from later middleware)
func ErrorHandlerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// Check for recorded errors
if len(c.Errors) > 0 {
err := c.Errors.Last()
if err.IsType(gin.ErrorTypePublic) {
c.JSON(c.Writer.Status(), gin.H{"error": err.Error()})
} else {
c.JSON(500, gin.H{"error": "internal server error"})
}
}
}
}
Level 3 · Code in Practice
Rate Limiter Middleware: Token Bucket Algorithm
Go's golang.org/x/time/rate provides a production-grade token bucket implementation:
package middleware
import (
"fmt"
"net/http"
"sync"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
)
// IPRateLimiter is an IP-based rate limiter
type IPRateLimiter struct {
mu sync.Mutex
limiters map[string]*rate.Limiter
r rate.Limit // requests allowed per second
b int // token bucket capacity (max burst)
}
func NewIPRateLimiter(r rate.Limit, b int) *IPRateLimiter {
return &IPRateLimiter{
limiters: make(map[string]*rate.Limiter),
r: r,
b: b,
}
}
func (i *IPRateLimiter) getLimiter(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()
limiter, exists := i.limiters[ip]
if !exists {
limiter = rate.NewLimiter(i.r, i.b)
i.limiters[ip] = limiter
}
return limiter
}
// RateLimitMiddleware returns an IP-based rate limiting middleware.
// r: requests per second (token generation rate)
// b: burst size (token bucket capacity)
func RateLimitMiddleware(r rate.Limit, b int) gin.HandlerFunc {
limiter := NewIPRateLimiter(r, b)
return func(c *gin.Context) {
// Get real IP (considering proxies)
ip := c.ClientIP()
ipLimiter := limiter.getLimiter(ip)
if !ipLimiter.Allow() {
c.Header("X-RateLimit-Limit", fmt.Sprintf("%.0f", float64(r)))
c.Header("Retry-After", "1")
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "too many requests",
"code": 4029,
})
return
}
c.Next()
}
}
// Usage example
func setupWithRateLimiting() *gin.Engine {
r := gin.New()
// Global rate limit: 100 requests/sec, burst of 200
r.Use(RateLimitMiddleware(100, 200))
// API routes can have stricter limits
api := r.Group("/api")
api.Use(RateLimitMiddleware(10, 20)) // 10 requests/sec
{
api.POST("/login", loginHandler) // login endpoint gets strictest limits
}
return r
}
How the token bucket works: The token bucket adds tokens at a constant rate (r tokens/second) up to a maximum capacity of b. Each request consumes one token; requests are rejected when there are no tokens left. The bucket capacity b controls the allowed burst — if there are no requests for a while, tokens accumulate, allowing a short burst of traffic afterward.
Production note: The above implementation stores rate-limiting state in memory. In multi-instance deployments, you need to store limiter state in Redis; otherwise, each instance counts independently and global rate limiting cannot be achieved. Use github.com/go-redis/redis_rate for a Redis-backed distributed token bucket.
Request Logger Middleware
A production-grade request logger needs to record: path, method, status code, latency, response size, client IP, and error information.
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func LoggerMiddleware(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next() // execute subsequent handlers
// Log after the request completes
end := time.Now()
latency := end.Sub(start)
if query != "" {
path = path + "?" + query
}
// Collect all errors set by middleware / handlers
errors := c.Errors.Errors()
fields := []zap.Field{
zap.Int("status", c.Writer.Status()),
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("ip", c.ClientIP()),
zap.Duration("latency", latency),
zap.Int("body_size", c.Writer.Size()),
zap.String("user_agent", c.Request.UserAgent()),
zap.String("request_id", c.GetString("X-Request-ID")),
}
if len(errors) > 0 {
fields = append(fields, zap.Strings("errors", errors))
}
// Choose log level based on status code
status := c.Writer.Status()
switch {
case status >= 500:
logger.Error("server error", fields...)
case status >= 400:
logger.Warn("client error", fields...)
case latency > 3*time.Second:
logger.Warn("slow request", fields...)
default:
logger.Info("request", fields...)
}
}
}
CORS Middleware
CORS (Cross-Origin Resource Sharing) is a browser security mechanism. Servers must inform browsers which cross-origin requests are permitted via response headers:
package middleware
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
type CORSConfig struct {
AllowOrigins []string
AllowMethods []string
AllowHeaders []string
ExposeHeaders []string
AllowCredentials bool
MaxAge int // seconds to cache preflight results
}
func DefaultCORSConfig() CORSConfig {
return CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Request-ID"},
ExposeHeaders: []string{"X-Request-ID"},
MaxAge: 86400, // 24 hours
}
}
func CORSMiddleware(config CORSConfig) gin.HandlerFunc {
allowOriginsMap := make(map[string]bool)
for _, origin := range config.AllowOrigins {
allowOriginsMap[origin] = true
}
allowMethodsStr := strings.Join(config.AllowMethods, ", ")
allowHeadersStr := strings.Join(config.AllowHeaders, ", ")
exposeHeadersStr := strings.Join(config.ExposeHeaders, ", ")
return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin")
// Check if Origin is allowed
originAllowed := allowOriginsMap["*"] || allowOriginsMap[origin]
if !originAllowed {
c.Next()
return
}
// Set CORS response headers
if allowOriginsMap["*"] {
c.Header("Access-Control-Allow-Origin", "*")
} else {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin") // tell caches this response varies by Origin
}
c.Header("Access-Control-Allow-Methods", allowMethodsStr)
c.Header("Access-Control-Allow-Headers", allowHeadersStr)
c.Header("Access-Control-Expose-Headers", exposeHeadersStr)
if config.AllowCredentials {
c.Header("Access-Control-Allow-Credentials", "true")
}
// Handle preflight requests (OPTIONS)
if c.Request.Method == http.MethodOptions {
c.Header("Access-Control-Max-Age", fmt.Sprintf("%d", config.MaxAge))
c.AbortWithStatus(http.StatusNoContent) // 204
return
}
c.Next()
}
}
Common CORS pitfalls:
- When
AllowCredentials: true,AllowOriginscannot be"*"— it must be a specific domain (browser security requirement). - Custom response headers you want JavaScript to access (like
X-Request-ID) must be listed inAccess-Control-Expose-Headers.
Security Headers Middleware
Security headers are the first line of defense against many web attacks:
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
)
func SecurityHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Prevent MIME type sniffing attacks
c.Header("X-Content-Type-Options", "nosniff")
// Prevent clickjacking
c.Header("X-Frame-Options", "DENY")
// Enable browser XSS filter (older browsers)
c.Header("X-XSS-Protection", "1; mode=block")
// Control Referer information exposure
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// Force HTTPS (only effective over HTTPS)
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
// Content Security Policy (CSP) — the most powerful and complex security header.
// Adjust based on your actual requirements; overly strict CSP breaks functionality.
csp := strings.Join([]string{
"default-src 'self'",
"script-src 'self' 'unsafe-inline' https://cdn.example.com",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://api.example.com",
"frame-ancestors 'none'", // equivalent to X-Frame-Options: DENY
"base-uri 'self'",
"form-action 'self'",
}, "; ")
c.Header("Content-Security-Policy", csp)
// Permissions Policy: disable browser APIs you don't need
c.Header("Permissions-Policy",
"geolocation=(), camera=(), microphone=(), payment=()")
c.Next()
}
}
Panic Recovery Middleware with Full Stack Traces
Gin's built-in gin.Recovery() prevents panics from crashing the service, but its error reporting is limited. Here's an enhanced version:
package middleware
import (
"fmt"
"net/http"
"runtime/debug"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
func RecoveryMiddleware(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
// Capture full stack trace
stack := debug.Stack()
// Write structured log
logger.Error("panic recovered",
zap.Any("error", err),
zap.String("stack", string(stack)),
zap.String("path", c.Request.URL.Path),
zap.String("method", c.Request.Method),
zap.String("ip", c.ClientIP()),
zap.String("request_id", c.GetString("X-Request-ID")),
zap.Time("time", time.Now()),
)
// Send alert (production integrates DingTalk, Slack, PagerDuty, etc.)
go sendAlert(fmt.Sprintf("PANIC: %v\n%s", err, stack))
// Return 500 to the client without exposing internal details
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": "internal server error",
"code": 5000,
"request_id": c.GetString("X-Request-ID"),
})
}
}()
c.Next()
}
}
func sendAlert(message string) {
// Real implementation: call an alerting webhook
_ = message
}
Level 4 · Deep Water
Engineering Principles for Middleware Ordering
The order in which middleware is registered determines its execution order. Wrong ordering creates security vulnerabilities or logic errors. Here is the recommended order:
r := gin.New()
// Layer 1: Infrastructure (outermost — always executes)
r.Use(RecoveryMiddleware(logger)) // must be registered first to catch panics in other middleware
r.Use(RequestIDMiddleware()) // generate ID early so all subsequent logs can include it
// Layer 2: Observability (before security checks — records rejected requests too)
r.Use(LoggerMiddleware(logger)) // logs must cover all requests, including rejected ones
// Layer 3: Security and access control
r.Use(SecurityHeadersMiddleware()) // all responses should have security headers
r.Use(CORSMiddleware(corsConfig)) // CORS must be processed before business logic
// Layer 4: Traffic control
r.Use(RateLimitMiddleware(100, 200))
// Layer 5: Authentication (before business middleware)
authGroup := r.Group("/")
authGroup.Use(JWTAuthMiddleware())
// Layer 6: Business middleware (innermost — closest to handlers)
authGroup.Use(TenantMiddleware())
authGroup.Use(PermissionMiddleware())
Why must Recovery be first? Because the middleware chain executes from outside in, Recovery's defer statement is registered at the outermost layer, capable of catching panics from all inner layers, including panics in other middleware. If Recovery is registered after Logger, a panic in Logger cannot be caught.
Why does Logger come before security middleware? Security middleware may reject requests (returning 403, 429, etc.), and you need those rejected requests logged for security auditing and monitoring. If Logger is registered after security middleware, rejected requests have no log entries.
Circuit Breaker Pattern
The Circuit Breaker is a key pattern in microservice architecture for preventing cascading failures. It monitors call success rates and "trips" when the failure rate exceeds a threshold — failing fast instead of letting requests time out:
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type CircuitState int
const (
StateClosed CircuitState = iota // normal, requests allowed
StateOpen // tripped, fast reject
StateHalfOpen // half-open, allow limited probe requests
)
type CircuitBreaker struct {
mu sync.Mutex
state CircuitState
failures int
successes int
lastFailTime time.Time
// Configuration
maxFailures int // failure count threshold to trip
resetTimeout time.Duration // time before transitioning to half-open
halfOpenMaxReq int // max requests allowed in half-open state
}
func NewCircuitBreaker(maxFailures int, resetTimeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
state: StateClosed,
maxFailures: maxFailures,
resetTimeout: resetTimeout,
halfOpenMaxReq: 3,
}
}
func (cb *CircuitBreaker) Allow() bool {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case StateClosed:
return true
case StateOpen:
// Check if we should transition to half-open
if time.Since(cb.lastFailTime) > cb.resetTimeout {
cb.state = StateHalfOpen
cb.successes = 0
return true
}
return false
case StateHalfOpen:
return cb.successes < cb.halfOpenMaxReq
}
return false
}
func (cb *CircuitBreaker) RecordSuccess() {
cb.mu.Lock()
defer cb.mu.Unlock()
switch cb.state {
case StateHalfOpen:
cb.successes++
if cb.successes >= cb.halfOpenMaxReq {
cb.state = StateClosed
cb.failures = 0
}
case StateClosed:
cb.failures = 0 // reset failure count on success
}
}
func (cb *CircuitBreaker) RecordFailure() {
cb.mu.Lock()
defer cb.mu.Unlock()
cb.lastFailTime = time.Now()
switch cb.state {
case StateClosed:
cb.failures++
if cb.failures >= cb.maxFailures {
cb.state = StateOpen
}
case StateHalfOpen:
cb.state = StateOpen // failure in half-open state trips again
}
}
// CircuitBreakerMiddleware applies the circuit breaker to a Gin route
func CircuitBreakerMiddleware(cb *CircuitBreaker) gin.HandlerFunc {
return func(c *gin.Context) {
if !cb.Allow() {
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
"error": "service temporarily unavailable, please retry later",
"code": 5030,
})
return
}
c.Next()
// Record success or failure based on response status code
if c.Writer.Status() >= 500 {
cb.RecordFailure()
} else {
cb.RecordSuccess()
}
}
}
Distributed Tracing Injection
In a microservice architecture, you can inject OpenTelemetry tracing at the middleware layer for automatic distributed tracing:
package middleware
import (
"fmt"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
"go.opentelemetry.io/otel/trace"
)
func TracingMiddleware(serviceName string) gin.HandlerFunc {
tracer := otel.Tracer(serviceName)
propagator := otel.GetTextMapPropagator()
return func(c *gin.Context) {
// Extract upstream trace context from request headers (W3C TraceContext / B3 format)
ctx := propagator.Extract(c.Request.Context(),
propagation.HeaderCarrier(c.Request.Header))
// Create a new Span
spanName := c.FullPath() // use route template (/users/:id), not the actual path
if spanName == "" {
spanName = c.Request.URL.Path
}
ctx, span := tracer.Start(ctx, spanName,
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(
semconv.HTTPMethodKey.String(c.Request.Method),
semconv.HTTPURLKey.String(c.Request.URL.String()),
semconv.HTTPSchemeKey.String(c.Request.URL.Scheme),
semconv.NetHostNameKey.String(c.Request.Host),
),
)
defer span.End()
// Inject trace context into the request's Context
c.Request = c.Request.WithContext(ctx)
// Store TraceID in Gin Context for log correlation
c.Set("trace_id", span.SpanContext().TraceID().String())
c.Next()
// Record response information
span.SetAttributes(
semconv.HTTPStatusCodeKey.Int(c.Writer.Status()),
)
if c.Writer.Status() >= 500 {
span.RecordError(fmt.Errorf("HTTP %d", c.Writer.Status()))
}
}
}
Testing Middleware with httptest
Middleware should have independent unit tests that don't require a full server startup:
package middleware_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"yourproject/middleware"
)
func TestRateLimitMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
// Very tight limit: 1 request/sec, bucket capacity 1
r.Use(middleware.RateLimitMiddleware(1, 1))
r.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"ok": true})
})
// First request should succeed
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
// Second request should be rate limited (bucket empty)
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusTooManyRequests, w2.Code)
}
func TestCORSMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
config := middleware.CORSConfig{
AllowOrigins: []string{"https://example.com"},
AllowMethods: []string{"GET", "POST"},
AllowHeaders: []string{"Content-Type"},
}
r.Use(middleware.CORSMiddleware(config))
r.GET("/test", func(c *gin.Context) {
c.JSON(200, gin.H{"ok": true})
})
t.Run("allowed origin", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
req.Header.Set("Origin", "https://example.com")
r.ServeHTTP(w, req)
assert.Equal(t, "https://example.com",
w.Header().Get("Access-Control-Allow-Origin"))
})
t.Run("disallowed origin", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
req.Header.Set("Origin", "https://evil.com")
r.ServeHTTP(w, req)
assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"))
})
t.Run("preflight request", func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("OPTIONS", "/test", nil)
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusNoContent, w.Code)
})
}
// Test middleware state propagation
func TestContextValuePropagation(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
// Middleware that sets a value
r.Use(func(c *gin.Context) {
c.Set("test_key", "test_value")
c.Next()
})
var capturedValue string
r.GET("/test", func(c *gin.Context) {
capturedValue = c.GetString("test_key")
c.JSON(200, nil)
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, req)
assert.Equal(t, "test_value", capturedValue)
}
Key Takeaways:
- Middleware is the best abstraction for cross-cutting concerns — it completely separates infrastructure logic from business logic.
- Gin's
c.Next()implements the onion model: a single function can execute logic both when a request enters and when a response exits. c.Abort()stops the chain by setting an index flag, not through exceptions or return values.- Middleware ordering is critical: Recovery outermost, logging before security, authentication before business logic.
- Use
httptestto test each middleware independently without starting a full server.