22 KiB
Research: Fiber Middleware Integration
Feature: 001-fiber-middleware-integration Date: 2025-11-10 Phase: 0 - Research & Discovery
Overview
This document resolves technical unknowns and establishes best practices for integrating Fiber middleware with Viper configuration, Zap logging, and Redis authentication.
1. Viper Configuration Hot Reload
Decision: Use fsnotify (Viper Native Watcher)
Rationale:
- Viper has built-in
WatchConfig()method using fsnotify - No polling overhead, event-driven file change detection
- Cross-platform support (Linux, macOS, Windows)
- Battle-tested in production environments
- Integrates seamlessly with Viper's config merge logic
Implementation Pattern:
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
log.Info("Config file changed", zap.String("file", e.Name))
// Reload config atomically
newConfig := &Config{}
if err := viper.Unmarshal(newConfig); err != nil {
log.Error("Failed to reload config", zap.Error(err))
return // Keep existing config
}
// Validate new config
if err := newConfig.Validate(); err != nil {
log.Error("Invalid config", zap.Error(err))
return // Keep existing config
}
// Atomic swap using sync/atomic
atomic.StorePointer(&globalConfig, unsafe.Pointer(newConfig))
})
Best Practices:
- Use atomic pointer swap to avoid race conditions
- Validate configuration before applying
- Log reload events with success/failure status
- Keep existing config if new config is invalid
- Don't restart services (logger, Redis client) on every change - only update values
Alternatives Considered:
- Manual polling: Higher CPU overhead, added complexity
- Signal-based reload (SIGHUP): Requires manual triggering, not automatic
- Third-party config libraries (consul, etcd): Overkill for file-based config
2. Zap + Lumberjack Integration for Dual Log Files
Decision: Two Separate Zap Logger Instances
Rationale:
- Clean separation of concerns (app logic vs HTTP access)
- Independent rotation policies (app.log: 100MB/30days, access.log: 500MB/90days)
- Different log levels (app: debug/info/error, access: info only)
- Easier to analyze and ship to different log aggregators
- Follows Go's simplicity principle - no complex routing logic
Implementation Pattern:
// Application logger (app.log)
appCore := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(&lumberjack.Logger{
Filename: "logs/app.log",
MaxSize: 100, // MB
MaxBackups: 30,
MaxAge: 30, // days
Compress: true,
}),
zap.InfoLevel,
)
// Access logger (access.log)
accessCore := zapcore.NewCore(
zapcore.NewJSONEncoder(encoderConfig),
zapcore.AddSync(&lumberjack.Logger{
Filename: "logs/access.log",
MaxSize: 500, // MB
MaxBackups: 90,
MaxAge: 90, // days
Compress: true,
}),
zap.InfoLevel,
)
appLogger := zap.New(appCore, zap.AddCaller(), zap.AddStacktrace(zap.ErrorLevel))
accessLogger := zap.New(accessCore)
Logger Usage:
- appLogger: Business logic, errors, debug info, system events
- accessLogger: HTTP requests/responses only (method, path, status, duration, request ID)
JSON Encoder Config:
encoderConfig := zapcore.EncoderConfig{
TimeKey: "timestamp",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
MessageKey: "message",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.LowercaseLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder, // RFC3339 format
EncodeDuration: zapcore.SecondsDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
Alternatives Considered:
- Single logger with routing logic: Complex, error-prone, violates separation of concerns
- Log levels for separation: Doesn't solve retention/rotation policy differences
- Multiple cores in one logger: Still requires complex routing logic
3. Fiber Middleware Execution Order
Decision: recover → requestid → logger → keyauth → limiter → handler
Rationale:
- recover first: Must catch panics from all downstream middleware
- requestid second: All logs need request ID, including auth failures
- logger third: Log all requests including auth failures
- keyauth fourth: Authentication before business logic
- limiter fifth: Rate limit after auth (only count authenticated requests)
- handler last: Business logic with all context available
Fiber Middleware Registration:
app.Use(customRecover()) // Must be first
app.Use(fiber.New(fiber.Config{
Next: nil,
Generator: uuid.NewString, // UUID v4
}))
app.Use(customLogger(accessLogger))
app.Use(customKeyAuth(validator, appLogger))
// app.Use(customLimiter()) // Commented by default
app.Get("/api/v1/users", handler)
Critical Insights:
- Middleware executes in registration order (top to bottom)
recovermust be first to catch panics from all middlewarerequestidmust be before logger to include ID in access logs- Auth middleware should have access to request ID for security logs
- Rate limiter after auth = more accurate rate limiting per user/IP combo
Alternatives Considered:
- Auth before logger: Can't log auth failures with full context
- Rate limit before auth: Anonymous requests consume rate limit quota
- Request ID after logger: Access logs missing correlation IDs
4. Fiber keyauth Middleware Customization
Decision: Wrap Fiber's keyauth with Custom Redis Validator
Rationale:
- Fiber's keyauth middleware provides token extraction from headers
- Custom validator function handles Redis token validation
- Clean separation: Fiber handles HTTP, validator handles business logic
- Easy to test validator independently
- Follows constitution's Handler → Service pattern
Implementation Pattern:
// Validator service (pkg/validator/token.go)
type TokenValidator struct {
redis *redis.Client
logger *zap.Logger
}
func (v *TokenValidator) Validate(token string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
// Check Redis availability
if err := v.redis.Ping(ctx).Err(); err != nil {
return "", ErrRedisUnavailable // Fail closed
}
// Get user ID from token
userID, err := v.redis.Get(ctx, constants.RedisAuthTokenKey(token)).Result()
if err == redis.Nil {
return "", ErrInvalidToken
}
if err != nil {
return "", fmt.Errorf("redis get: %w", err)
}
return userID, nil
}
// Middleware wrapper (internal/middleware/auth.go)
func KeyAuth(validator *validator.TokenValidator, logger *zap.Logger) fiber.Handler {
return keyauth.New(keyauth.Config{
KeyLookup: "header:token",
Validator: func(c *fiber.Ctx, key string) (bool, error) {
userID, err := validator.Validate(key)
if err != nil {
logger.Warn("Token validation failed",
zap.String("request_id", c.Locals(constants.ContextKeyRequestID).(string)),
zap.Error(err),
)
return false, err
}
// Store user ID in context
c.Locals("user_id", userID)
return true, nil
},
ErrorHandler: func(c *fiber.Ctx, err error) error {
// Map errors to unified response format
switch err {
case keyauth.ErrMissingOrMalformedAPIKey:
return response.Error(c, 401, errors.CodeMissingToken, "Missing authentication token")
case ErrInvalidToken:
return response.Error(c, 401, errors.CodeInvalidToken, "Invalid or expired token")
case ErrRedisUnavailable:
return response.Error(c, 503, errors.CodeAuthServiceUnavailable, "Authentication service unavailable")
default:
return response.Error(c, 500, errors.CodeInternalError, "Internal server error")
}
},
})
}
Best Practices:
- Use context timeout for Redis operations (50ms)
- Fail closed when Redis unavailable (HTTP 503)
- Store user ID in Fiber context (
c.Locals) for downstream handlers - Log all auth failures with request ID for security auditing
- Use custom error types for different failure modes
Alternatives Considered:
- Direct Redis calls in middleware: Violates separation of concerns
- JWT tokens: Spec requires Redis validation, not stateless tokens
- Cache validation results: Security risk, defeats Redis TTL purpose
5. Redis Client Selection
Decision: go-redis/redis/v8
Rationale:
- Most widely adopted Redis client in Go ecosystem (19k+ stars)
- Excellent performance and connection pooling
- Native context support for timeouts and cancellation
- Supports Redis Cluster, Sentinel, and standalone
- Active maintenance and community support
- Already compatible with Go 1.18+ (uses generics)
- Comprehensive documentation and examples
Connection Pool Configuration:
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // From config
DB: 0, // From config
PoolSize: 10, // Concurrent connections
MinIdleConns: 5, // Keep-alive connections
MaxRetries: 3, // Retry failed commands
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
PoolTimeout: 4 * time.Second, // Wait for connection from pool
})
Best Practices:
- Use context with timeout for all Redis operations
- Check Redis availability with
Ping()before critical operations - Use
Get()for simple token validation (O(1) complexity) - Let Redis TTL handle token expiration (no manual cleanup)
- Monitor connection pool metrics in production
Alternatives Considered:
- redigo: Older, no context support, more manual connection management
- rueidis: Very fast but newer, less community adoption
- Native Redis module: Doesn't exist in Go standard library
6. UUID v4 Generation
Decision: google/uuid (Already in go.mod)
Rationale:
- Already a dependency (via Fiber's uuid import)
- Official Google implementation, well-tested
- Simple API:
uuid.New()oruuid.NewString() - RFC 4122 compliant UUID v4 (random)
- No external dependencies
- Excellent performance (~1.5M UUIDs/sec)
Implementation:
import "github.com/google/uuid"
// In requestid middleware config
fiber.New(fiber.Config{
Generator: uuid.NewString, // Returns string directly
})
// Or manual generation
requestID := uuid.NewString() // "550e8400-e29b-41d4-a716-446655440000"
UUID v4 Characteristics:
- 122 random bits (collision probability ~1 in 2^122)
- No need for special collision handling
- Compatible with distributed tracing (Jaeger, OpenTelemetry)
- Human-readable in logs and headers
Alternatives Considered:
- crypto/rand + manual formatting: Reinventing the wheel, error-prone
- ULID: Lexicographically sortable but not requested in spec
- Standard library: No UUID support in Go stdlib
7. Fiber Limiter Middleware
Decision: Fiber Built-in Limiter with Memory Storage (Commented by Default)
Rationale:
- Fiber's limiter middleware supports multiple storage backends
- Memory storage sufficient for single-server deployment
- Redis storage available for multi-server deployment
- Per-IP rate limiting via client IP extraction
- Sliding window or fixed window algorithms available
Implementation Pattern (Commented):
// Rate limiter configuration (commented by default)
// Uncomment and configure per endpoint as needed
/*
app.Use("/api/v1/", limiter.New(limiter.Config{
Max: 100, // Max requests
Expiration: 1 * time.Minute, // Time window
KeyGenerator: func(c *fiber.Ctx) string {
return c.IP() // Rate limit by IP
},
LimitReached: func(c *fiber.Ctx) error {
return response.Error(c, 429, errors.CodeTooManyRequests, "Too many requests")
},
Storage: nil, // nil = in-memory, or redis storage for distributed
}))
*/
Configuration Options:
- Max: Number of requests allowed in time window (e.g., 100)
- Expiration: Time window duration (e.g., 1 minute)
- KeyGenerator: Function to extract rate limit key (IP, user ID, API key)
- Storage: Memory (default) or Redis for distributed rate limiting
- LimitReached: Custom error handler returning unified response format
Enabling Rate Limiter:
- Uncomment middleware registration in
main.go - Configure limits per endpoint or globally
- Choose storage backend (memory for single server, Redis for cluster)
- Update documentation with rate limit values
- Monitor rate limit hits in logs
Best Practices:
- Apply rate limits per endpoint (different limits for read vs write)
- Use Redis storage for multi-server deployments
- Log rate limit violations for abuse detection
- Return
Retry-Afterheader in 429 responses - Configure different limits for authenticated vs anonymous requests
Alternatives Considered:
- Third-party rate limiter: Added complexity, Fiber's built-in sufficient
- Token bucket algorithm: Fiber supports sliding window, simpler to configure
- Rate limit before auth: Spec requires after auth, per-IP basis
8. Graceful Shutdown Pattern
Decision: Context-Based Cancellation with Shutdown Hook
Rationale:
- Go's context package provides clean cancellation propagation
- Fiber supports graceful shutdown with timeout
- Config watcher must stop before application exits
- Prevents goroutine leaks and incomplete operations
Implementation Pattern:
func main() {
// Create root context with cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Initialize components with context
cfg := config.Load()
go config.Watch(ctx, cfg) // Pass context to watcher
app := setupApp(cfg)
// Graceful shutdown signal handling
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
go func() {
if err := app.Listen(cfg.Server.Address); err != nil {
log.Fatal("Server failed", zap.Error(err))
}
}()
<-quit // Block until signal
log.Info("Shutting down server...")
cancel() // Cancel context (stops config watcher)
if err := app.ShutdownWithTimeout(30 * time.Second); err != nil {
log.Error("Forced shutdown", zap.Error(err))
}
log.Info("Server stopped")
}
Watcher Cancellation:
func Watch(ctx context.Context, cfg *Config) {
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
select {
case <-ctx.Done():
return // Stop processing config changes
default:
// Reload config logic
}
})
<-ctx.Done() // Block until cancelled
log.Info("Config watcher stopped")
}
Best Practices:
- Use
context.Contextfor all long-running goroutines - Set reasonable shutdown timeout (30 seconds)
- Close resources in defer statements
- Log shutdown progress
- Flush logs before exit (
logger.Sync())
9. Testing Strategies
Decision: Table-Driven Tests with Mock Redis
Rationale:
- Table-driven tests are Go idiomatic (endorsed by Go team)
- Mock Redis avoids external dependencies in unit tests
- Integration tests use testcontainers for real Redis
- Middleware testing requires Fiber test context
Unit Test Pattern (Token Validator):
func TestTokenValidator_Validate(t *testing.T) {
tests := []struct {
name string
token string
setupMock func(*mock.Redis)
wantUser string
wantErr error
}{
{
name: "valid token",
token: "valid-token-123",
setupMock: func(m *mock.Redis) {
m.On("Get", mock.Anything, "auth:token:valid-token-123").
Return(redis.NewStringResult("user-456", nil))
},
wantUser: "user-456",
wantErr: nil,
},
{
name: "expired token",
token: "expired-token",
setupMock: func(m *mock.Redis) {
m.On("Get", mock.Anything, "auth:token:expired-token").
Return(redis.NewStringResult("", redis.Nil))
},
wantUser: "",
wantErr: ErrInvalidToken,
},
// More cases...
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mockRedis := &mock.Redis{}
tt.setupMock(mockRedis)
validator := NewTokenValidator(mockRedis, zap.NewNop())
userID, err := validator.Validate(tt.token)
if err != tt.wantErr {
t.Errorf("got error %v, want %v", err, tt.wantErr)
}
if userID != tt.wantUser {
t.Errorf("got userID %s, want %s", userID, tt.wantUser)
}
mockRedis.AssertExpectations(t)
})
}
}
Integration Test Pattern (Middleware Chain):
func TestMiddlewareChain(t *testing.T) {
// Start testcontainer Redis
redisContainer, err := testcontainers.GenericContainer(ctx,
testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "redis:7-alpine",
ExposedPorts: []string{"6379/tcp"},
},
Started: true,
})
require.NoError(t, err)
defer redisContainer.Terminate(ctx)
// Setup app with middleware
app := setupTestApp(redisContainer)
// Test cases
tests := []struct {
name string
setupToken func(redis *redis.Client)
headers map[string]string
expectedStatus int
expectedCode int
}{
{
name: "valid request with token",
setupToken: func(rdb *redis.Client) {
rdb.Set(ctx, "auth:token:valid-token", "user-123", 1*time.Hour)
},
headers: map[string]string{"token": "valid-token"},
expectedStatus: 200,
expectedCode: 0,
},
// More cases...
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.setupToken != nil {
tt.setupToken(redisClient)
}
req := httptest.NewRequest("GET", "/api/v1/test", nil)
for k, v := range tt.headers {
req.Header.Set(k, v)
}
resp, err := app.Test(req)
require.NoError(t, err)
assert.Equal(t, tt.expectedStatus, resp.StatusCode)
// Parse response body and check code
var body response.Response
json.NewDecoder(resp.Body).Decode(&body)
assert.Equal(t, tt.expectedCode, body.Code)
})
}
}
Testing Best Practices:
- Use
testingpackage (no third-party test frameworks) - Mock external dependencies (Redis) in unit tests
- Use real services in integration tests (testcontainers)
- Test helpers marked with
t.Helper() - Parallel tests when possible (
t.Parallel()) - Clear test names describing scenario
- Assert expected errors, not just success cases
10. Middleware Error Handling
Decision: Custom ErrorHandler for Unified Response Format
Rationale:
- Fiber middleware returns errors, not HTTP responses
- ErrorHandler translates errors to unified response format
- Consistent error structure across all middleware
- Proper HTTP status codes and error codes
Pattern:
// In each middleware config
ErrorHandler: func(c *fiber.Ctx, err error) error {
// Map error to response
code, status, msg := mapError(err)
return response.Error(c, status, code, msg)
}
// Centralized error mapping
func mapError(err error) (code, status int, msg string) {
switch {
case errors.Is(err, ErrMissingToken):
return errors.CodeMissingToken, 401, "Missing authentication token"
case errors.Is(err, ErrInvalidToken):
return errors.CodeInvalidToken, 401, "Invalid or expired token"
case errors.Is(err, ErrRedisUnavailable):
return errors.CodeAuthServiceUnavailable, 503, "Authentication service unavailable"
case errors.Is(err, ErrTooManyRequests):
return errors.CodeTooManyRequests, 429, "Too many requests"
default:
return errors.CodeInternalError, 500, "Internal server error"
}
}
Summary of Decisions
| Component | Decision | Key Rationale |
|---|---|---|
| Config Hot Reload | Viper + fsnotify | Native support, event-driven, atomic swap |
| Logging | Dual Zap loggers + Lumberjack | Separate concerns, independent policies |
| Middleware Order | recover → requestid → logger → keyauth → limiter | Panic safety, context propagation |
| Auth Validation | Custom validator + Fiber keyauth | Separation of concerns, testability |
| Redis Client | go-redis/redis/v8 | Industry standard, excellent performance |
| Request ID | google/uuid v4 | Already in deps, RFC 4122 compliant |
| Rate Limiting | Fiber limiter (commented) | Built-in, flexible, easy to enable |
| Graceful Shutdown | Context cancellation + signal handling | Clean resource cleanup |
| Testing | Table-driven + mocks/testcontainers | Go idiomatic, balanced approach |
Implementation Readiness Checklist
- All technical unknowns resolved
- Best practices established for each component
- Go idiomatic patterns confirmed (no Java-style anti-patterns)
- Constitution compliance verified (Fiber, Zap, Viper, Redis)
- Testing strategies defined
- Error handling patterns established
- Performance considerations addressed
- Security patterns confirmed (fail-closed auth)
Status: Ready for Phase 1 (Design & Contracts)
Next: Generate data-model.md, contracts/api.yaml, quickstart.md