Interceptors
Interceptors provide operation-level request and response processing in Humus REST services. They allow you to execute custom logic before your handler runs, making them ideal for cross-cutting concerns like logging, authentication, metrics collection, and request transformation.
Overview
Interceptors operate at the operation level, meaning they’re configured per-endpoint using the rest.Intercept() option. Unlike global middleware, interceptors:
- Are applied to specific operations via
rest.Handle()options - Use an error-returning signature that integrates with the operation’s error handling
- Can modify the request, inspect the response, or short-circuit execution
- Have access to the full request/response lifecycle
- Execute in the order they were registered
Core Concepts
ServerInterceptor Interface
The ServerInterceptor interface defines a single method:
type ServerInterceptor interface {
Intercept(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error
}
The Intercept method receives the next handler in the chain and returns a new handler function. This allows interceptors to:
- Execute code before calling
next - Execute code after calling
next - Conditionally call
next(or skip it entirely) - Modify the request before passing it to
next - Handle errors returned by
next
ServerInterceptorFunc Adapter
For convenience, Humus provides ServerInterceptorFunc to create interceptors from functions:
type ServerInterceptorFunc func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error
This allows you to define interceptors inline without creating a new type.
Error-Returning Signature
Interceptors work with handlers that return errors:
func(http.ResponseWriter, *http.Request) error
When an interceptor (or the handler it wraps) returns an error:
- The error propagates up through the interceptor chain
- The operation’s error handler processes it (configured via
rest.OnError()) - An appropriate HTTP response is sent to the client
This design integrates interceptors seamlessly with Humus’s error handling system.
Basic Usage
Simple Header Injection
Add a custom header to all responses:
interceptor := rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
// Set header before calling next handler
w.Header().Set("X-Service-Version", "1.0.0")
// Call the next handler in the chain
return next(w, r)
}
})
rest.Handle(
http.MethodGet,
rest.BasePath("/api/data"),
handler,
rest.Intercept(interceptor),
)
Request Logging
Log request details:
loggingInterceptor := rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
start := time.Now()
// Log request
log := humus.Logger("api")
log.InfoContext(r.Context(), "incoming request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("remote_addr", r.RemoteAddr),
)
// Call next handler
err := next(w, r)
// Log response
duration := time.Since(start)
if err != nil {
log.ErrorContext(r.Context(), "request failed",
slog.Duration("duration", duration),
slog.Any("error", err),
)
} else {
log.InfoContext(r.Context(), "request completed",
slog.Duration("duration", duration),
)
}
return err
}
})
rest.Handle(
http.MethodPost,
rest.BasePath("/api/users"),
createUserHandler,
rest.Intercept(loggingInterceptor),
)
Common Use Cases
Authentication
Verify authentication and inject user context:
func authInterceptor(tokenValidator TokenValidator) rest.ServerInterceptor {
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
// Extract token from header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return rest.UnauthorizedError{Message: "missing authorization header"}
}
// Validate token
userID, err := tokenValidator.Validate(r.Context(), authHeader)
if err != nil {
return rest.UnauthorizedError{Message: "invalid token"}
}
// Inject user ID into context
ctx := context.WithValue(r.Context(), "user_id", userID)
// Continue with enriched context
return next(w, r.WithContext(ctx))
}
})
}
// Usage
rest.Handle(
http.MethodGet,
rest.BasePath("/api/profile"),
profileHandler,
rest.Intercept(authInterceptor(myTokenValidator)),
)
Request ID Generation
Generate and propagate request IDs:
requestIDInterceptor := rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
// Check for existing request ID
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
// Inject into context
ctx := context.WithValue(r.Context(), "request_id", requestID)
// Add to response headers
w.Header().Set("X-Request-ID", requestID)
// Continue with enriched context
return next(w, r.WithContext(ctx))
}
})
rest.Handle(
http.MethodPost,
rest.BasePath("/api/orders"),
createOrderHandler,
rest.Intercept(requestIDInterceptor),
)
Rate Limiting
Implement rate limiting per endpoint:
func rateLimitInterceptor(limiter RateLimiter) rest.ServerInterceptor {
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
// Check rate limit
allowed, retryAfter := limiter.Allow(r.RemoteAddr)
if !allowed {
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
return rest.BadRequestError{
Message: "rate limit exceeded",
}
}
// Continue processing
return next(w, r)
}
})
}
// Usage
rest.Handle(
http.MethodPost,
rest.BasePath("/api/expensive-operation"),
handler,
rest.Intercept(rateLimitInterceptor(myRateLimiter)),
)
Custom Metrics
Collect operation-specific metrics:
func metricsInterceptor(meter metric.Meter) rest.ServerInterceptor {
requestCounter, _ := meter.Int64Counter("http.server.requests")
requestDuration, _ := meter.Float64Histogram("http.server.duration")
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
start := time.Now()
// Increment request counter
requestCounter.Add(r.Context(), 1,
metric.WithAttributes(
attribute.String("method", r.Method),
attribute.String("path", r.URL.Path),
),
)
// Call next handler
err := next(w, r)
// Record duration
duration := time.Since(start).Seconds()
status := "success"
if err != nil {
status = "error"
}
requestDuration.Record(r.Context(), duration,
metric.WithAttributes(
attribute.String("method", r.Method),
attribute.String("path", r.URL.Path),
attribute.String("status", status),
),
)
return err
}
})
}
Request Validation
Validate requests before handler execution:
func validateContentTypeInterceptor(allowedTypes ...string) rest.ServerInterceptor {
allowed := make(map[string]bool)
for _, ct := range allowedTypes {
allowed[ct] = true
}
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
contentType := r.Header.Get("Content-Type")
if !allowed[contentType] {
return rest.InvalidContentTypeError{
ContentType: contentType,
}
}
return next(w, r)
}
})
}
// Usage
rest.Handle(
http.MethodPost,
rest.BasePath("/api/data"),
handler,
rest.Intercept(validateContentTypeInterceptor("application/json", "application/xml")),
)
Advanced Patterns
Multiple Interceptors
Chain multiple interceptors by calling rest.Intercept() multiple times. They execute in registration order:
rest.Handle(
http.MethodPost,
rest.BasePath("/api/orders"),
createOrderHandler,
rest.Intercept(requestIDInterceptor), // Executes first
rest.Intercept(loggingInterceptor), // Executes second
rest.Intercept(authInterceptor(validator)), // Executes third
rest.Intercept(rateLimitInterceptor(limiter)), // Executes fourth
)
Execution flow:
- Request ID interceptor generates ID and injects into context
- Logging interceptor logs incoming request with ID
- Auth interceptor validates token and injects user
- Rate limit interceptor checks limits
- Handler executes
- Rate limit interceptor completes (if any post-processing)
- Auth interceptor completes
- Logging interceptor logs response
- Request ID interceptor completes
Conditional Execution
Skip handler execution based on conditions:
cacheInterceptor := rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
// Check cache
cacheKey := generateCacheKey(r)
if cached, found := cache.Get(cacheKey); found {
// Return cached response, skip handler
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "HIT")
w.Write(cached)
return nil // Don't call next()
}
// Cache miss - continue to handler
w.Header().Set("X-Cache", "MISS")
return next(w, r)
}
})
Context Value Injection
Pass data between interceptors and handlers using context:
type contextKey string
const (
userContextKey contextKey = "user"
requestIDKey contextKey = "request_id"
sessionContextKey contextKey = "session"
)
// First interceptor: inject request ID
requestIDInterceptor := rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
requestID := generateRequestID()
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
return next(w, r.WithContext(ctx))
}
})
// Second interceptor: use request ID, inject user
authInterceptor := rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
requestID := r.Context().Value(requestIDKey).(string)
user, err := authenticateUser(r, requestID)
if err != nil {
return err
}
ctx := context.WithValue(r.Context(), userContextKey, user)
return next(w, r.WithContext(ctx))
}
})
// Handler: use both values
handler := rest.ProducerFunc[UserProfile](func(ctx context.Context) (*UserProfile, error) {
user := ctx.Value(userContextKey).(*User)
requestID := ctx.Value(requestIDKey).(string)
log.Printf("Request %s: fetching profile for user %s", requestID, user.ID)
return getProfile(ctx, user.ID)
})
Interceptor Composition
Create reusable interceptor builders:
// Builder function for auth interceptors
func requireRole(role string, validator TokenValidator) rest.ServerInterceptor {
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
user, err := validator.ValidateAndExtractUser(r.Context(), r.Header.Get("Authorization"))
if err != nil {
return rest.UnauthorizedError{Message: "authentication failed"}
}
if !user.HasRole(role) {
return rest.UnauthorizedError{Message: "insufficient permissions"}
}
ctx := context.WithValue(r.Context(), userContextKey, user)
return next(w, r.WithContext(ctx))
}
})
}
// Builder function for logging with custom fields
func logWithFields(fields ...slog.Attr) rest.ServerInterceptor {
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
log := humus.Logger("api")
attrs := append([]any{
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
}, fields...)
log.InfoContext(r.Context(), "request", attrs...)
return next(w, r)
}
})
}
// Usage
rest.Handle(
http.MethodDelete,
rest.BasePath("/admin/users").Param("id"),
deleteUserHandler,
rest.Intercept(requireRole("admin", myValidator)),
rest.Intercept(logWithFields(slog.String("operation", "user_deletion"))),
)
State Management with Closures
Use closures to maintain state across requests:
func createInFlightLimiter(maxConcurrent int) rest.ServerInterceptor {
var (
mu sync.Mutex
current int
)
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
// Acquire slot
mu.Lock()
if current >= maxConcurrent {
mu.Unlock()
return rest.BadRequestError{Message: "server busy, too many concurrent requests"}
}
current++
mu.Unlock()
// Release slot on exit
defer func() {
mu.Lock()
current--
mu.Unlock()
}()
return next(w, r)
}
})
}
// Usage
rest.Handle(
http.MethodPost,
rest.BasePath("/api/expensive-operation"),
handler,
rest.Intercept(createInFlightLimiter(10)),
)
Best Practices
Keep Interceptors Focused
Each interceptor should have a single, clear responsibility:
// Good - focused interceptors
rest.Intercept(requestIDInterceptor)
rest.Intercept(authInterceptor(validator))
rest.Intercept(loggingInterceptor)
// Bad - do-everything interceptor
rest.Intercept(doEverythingInterceptor) // logging + auth + metrics + validation
Handle Errors Appropriately
Return appropriate error types to ensure correct HTTP status codes:
authInterceptor := rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
token := r.Header.Get("Authorization")
// Use specific error types
if token == "" {
return rest.UnauthorizedError{Message: "missing token"}
}
user, err := validateToken(r.Context(), token)
if err != nil {
return rest.UnauthorizedError{Message: "invalid token"}
}
ctx := context.WithValue(r.Context(), userContextKey, user)
return next(w, r.WithContext(ctx))
}
})
Use Type-Safe Context Keys
Avoid string collisions by using typed context keys:
// Define typed keys
type contextKey string
const (
userKey contextKey = "user"
requestIDKey contextKey = "request_id"
)
// Use in interceptor
ctx := context.WithValue(r.Context(), userKey, user)
// Use in handler
user, ok := ctx.Value(userKey).(*User)
if !ok {
return nil, fmt.Errorf("user not found in context")
}
Consider Performance
Minimize work in interceptors, especially for high-traffic endpoints:
// Good - efficient check
if !isValidFormat(r.Header.Get("X-Custom-Header")) {
return rest.BadRequestError{Message: "invalid header format"}
}
// Bad - expensive operation on every request
user, err := db.QueryUser(r.Context(), extractUserID(r))
if err != nil {
return err
}
Order Matters
Place interceptors in logical order:
- Request ID generation (needed by logging)
- Logging (needs request ID)
- Authentication (needed by authorization)
- Authorization (needs user from auth)
- Rate limiting (after auth for per-user limits)
- Validation (after auth/authz checks)
rest.Handle(
http.MethodPost,
rest.BasePath("/api/admin/users"),
handler,
rest.Intercept(requestIDInterceptor), // 1. Generate ID
rest.Intercept(loggingInterceptor), // 2. Log with ID
rest.Intercept(authInterceptor), // 3. Authenticate
rest.Intercept(requireAdminInterceptor), // 4. Check permissions
rest.Intercept(rateLimitInterceptor), // 5. Check rate limits
rest.Intercept(validateInputInterceptor), // 6. Validate request
)
Interceptors vs Error Handlers
Interceptors and error handlers serve different purposes:
Use Interceptors for:
- Pre-processing requests (authentication, validation, transformation)
- Post-processing responses (adding headers, logging)
- Short-circuiting execution (caching, rate limiting)
- Context enrichment (user data, request IDs)
Use Error Handlers (rest.OnError) for:
- Formatting error responses
- Mapping errors to HTTP status codes
- Logging errors in a consistent format
- Converting errors to RFC 7807 Problem Details
Example combining both:
// Interceptor: pre-process request
authInterceptor := rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
user, err := authenticate(r)
if err != nil {
return rest.UnauthorizedError{Message: "authentication failed"}
}
ctx := context.WithValue(r.Context(), userContextKey, user)
return next(w, r.WithContext(ctx))
}
})
// Error handler: format errors
errorHandler := rest.NewProblemDetailsErrorHandler(
rest.WithDefaultType("https://api.example.com/errors"),
)
// Combine
rest.Handle(
http.MethodPost,
rest.BasePath("/api/orders"),
createOrderHandler,
rest.Intercept(authInterceptor), // Pre-process
rest.OnError(errorHandler), // Format errors
)
Complete Example
package main
import (
"context"
"log/slog"
"net/http"
"time"
"github.com/z5labs/humus"
"github.com/z5labs/humus/rest"
)
type contextKey string
const (
requestIDKey contextKey = "request_id"
userKey contextKey = "user"
)
// Request ID interceptor
func requestIDInterceptor() rest.ServerInterceptor {
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
requestID := r.Header.Get("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
w.Header().Set("X-Request-ID", requestID)
return next(w, r.WithContext(ctx))
}
})
}
// Logging interceptor
func loggingInterceptor() rest.ServerInterceptor {
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
start := time.Now()
log := humus.Logger("api")
requestID, _ := r.Context().Value(requestIDKey).(string)
log.InfoContext(r.Context(), "incoming request",
slog.String("request_id", requestID),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
)
err := next(w, r)
duration := time.Since(start)
if err != nil {
log.ErrorContext(r.Context(), "request failed",
slog.String("request_id", requestID),
slog.Duration("duration", duration),
slog.Any("error", err),
)
} else {
log.InfoContext(r.Context(), "request completed",
slog.String("request_id", requestID),
slog.Duration("duration", duration),
)
}
return err
}
})
}
// Auth interceptor
func authInterceptor(validator TokenValidator) rest.ServerInterceptor {
return rest.ServerInterceptorFunc(func(next func(http.ResponseWriter, *http.Request) error) func(http.ResponseWriter, *http.Request) error {
return func(w http.ResponseWriter, r *http.Request) error {
token := r.Header.Get("Authorization")
if token == "" {
return rest.UnauthorizedError{Message: "missing authorization"}
}
user, err := validator.Validate(r.Context(), token)
if err != nil {
return rest.UnauthorizedError{Message: "invalid token"}
}
ctx := context.WithValue(r.Context(), userKey, user)
return next(w, r.WithContext(ctx))
}
})
}
type Order struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Total int `json:"total"`
}
type CreateOrderRequest struct {
Items []string `json:"items"`
}
func Init(ctx context.Context, cfg rest.Config) (*rest.Api, error) {
validator := NewTokenValidator()
handler := rest.HandlerFunc[CreateOrderRequest, Order](
func(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
user := ctx.Value(userKey).(*User)
order := &Order{
ID: generateOrderID(),
UserID: user.ID,
Total: calculateTotal(req.Items),
}
return order, nil
},
)
api := rest.NewApi(
"Orders API",
"1.0.0",
rest.Handle(
http.MethodPost,
rest.BasePath("/orders"),
rest.HandleJson(handler),
rest.Intercept(requestIDInterceptor()),
rest.Intercept(loggingInterceptor()),
rest.Intercept(authInterceptor(validator)),
),
)
return api, nil
}
func main() {
rest.Run(rest.YamlSource("config.yaml"), Init)
}
See Also
- Error Handling - Custom error responses and error handlers
- Authentication - Built-in authentication schemes
- Handler Helpers - Type-safe request/response handling
- API Reference