28 KiB
Data Model: Fiber Middleware Integration
Feature: 001-fiber-middleware-integration Date: 2025-11-10 Phase: 1 - Design & Contracts
Overview
This document defines the data structures, entities, and models used in the middleware integration feature. All structures follow Go idiomatic design principles with simple, flat structures and no Java-style patterns.
1. Configuration Model
Config Structure
File: pkg/config/config.go
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Redis RedisConfig `mapstructure:"redis"`
Logging LoggingConfig `mapstructure:"logging"`
Middleware MiddlewareConfig `mapstructure:"middleware"`
}
// ServerConfig contains HTTP server settings
type ServerConfig struct {
Address string `mapstructure:"address"` // e.g., ":3000"
ReadTimeout time.Duration `mapstructure:"read_timeout"` // e.g., "10s"
WriteTimeout time.Duration `mapstructure:"write_timeout"` // e.g., "10s"
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"` // e.g., "30s"
Prefork bool `mapstructure:"prefork"` // Multi-process mode
}
// RedisConfig contains Redis connection settings
type RedisConfig struct {
Address string `mapstructure:"address"` // e.g., "localhost:6379"
Password string `mapstructure:"password"` // Leave empty if no auth
DB int `mapstructure:"db"` // Database number (0-15)
PoolSize int `mapstructure:"pool_size"` // Max connections
MinIdleConns int `mapstructure:"min_idle_conns"` // Keep-alive connections
DialTimeout time.Duration `mapstructure:"dial_timeout"` // e.g., "5s"
ReadTimeout time.Duration `mapstructure:"read_timeout"` // e.g., "3s"
WriteTimeout time.Duration `mapstructure:"write_timeout"` // e.g., "3s"
}
// LoggingConfig contains logging settings
type LoggingConfig struct {
Level string `mapstructure:"level"` // debug, info, warn, error
Development bool `mapstructure:"development"` // Enable dev mode (pretty print)
AppLog LogRotationConfig `mapstructure:"app_log"` // Application log settings
AccessLog LogRotationConfig `mapstructure:"access_log"` // HTTP access log settings
}
// LogRotationConfig contains log rotation settings for Lumberjack
type LogRotationConfig struct {
Filename string `mapstructure:"filename"` // Log file path
MaxSize int `mapstructure:"max_size"` // Max size in MB before rotation
MaxBackups int `mapstructure:"max_backups"` // Max number of old files to keep
MaxAge int `mapstructure:"max_age"` // Max days to retain old files
Compress bool `mapstructure:"compress"` // Compress rotated files
}
// MiddlewareConfig contains middleware settings
type MiddlewareConfig struct {
EnableAuth bool `mapstructure:"enable_auth"` // Enable keyauth middleware
EnableRateLimiter bool `mapstructure:"enable_rate_limiter"` // Enable limiter (default: false)
RateLimiter RateLimiterConfig `mapstructure:"rate_limiter"` // Rate limiter settings
}
// RateLimiterConfig contains rate limiter settings
type RateLimiterConfig struct {
Max int `mapstructure:"max"` // Max requests per window
Expiration time.Duration `mapstructure:"expiration"` // Time window (e.g., "1m")
Storage string `mapstructure:"storage"` // "memory" or "redis"
}
// Validate validates configuration values
func (c *Config) Validate() error {
if c.Server.Address == "" {
return errors.New("server address cannot be empty")
}
if c.Server.ReadTimeout <= 0 {
return errors.New("server read timeout must be positive")
}
if c.Redis.Address == "" {
return errors.New("redis address cannot be empty")
}
if c.Redis.PoolSize <= 0 {
return errors.New("redis pool size must be positive")
}
if c.Logging.AppLog.Filename == "" {
return errors.New("app log filename cannot be empty")
}
if c.Logging.AccessLog.Filename == "" {
return errors.New("access log filename cannot be empty")
}
if c.Middleware.RateLimiter.Max <= 0 {
c.Middleware.RateLimiter.Max = 100 // Default
}
return nil
}
Example YAML Configuration (configs/config.yaml):
server:
address: ":3000"
read_timeout: "10s"
write_timeout: "10s"
shutdown_timeout: "30s"
prefork: false
redis:
address: "localhost:6379"
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
dial_timeout: "5s"
read_timeout: "3s"
write_timeout: "3s"
logging:
level: "info"
development: false
app_log:
filename: "logs/app.log"
max_size: 100 # MB
max_backups: 30
max_age: 30 # days
compress: true
access_log:
filename: "logs/access.log"
max_size: 500 # MB
max_backups: 90
max_age: 90 # days
compress: true
middleware:
enable_auth: true
enable_rate_limiter: false # Disabled by default
rate_limiter:
max: 100 # requests
expiration: "1m" # per minute
storage: "memory" # or "redis"
Validation Rules:
- All required fields must be non-empty
- Timeouts must be positive durations
- Pool sizes must be positive integers
- Log filenames must be valid paths
- Rate limiter max must be positive (defaults to 100)
2. Authentication Model
AuthToken Entity
Storage: Redis key-value pair
Key Format: auth:token:{token_string} (generated via constants.RedisAuthTokenKey())
Value Format: Plain string containing user ID
TTL: Managed by Redis (set when token is created)
// Token validation doesn't use a struct - just Redis key-value
// Key: "auth:token:abc123def456"
// Value: "user-789"
// TTL: 3600 seconds (1 hour)
Redis Operations:
// Store token (example - not part of this feature)
rdb.Set(ctx, constants.RedisAuthTokenKey(token), userID, 1*time.Hour)
// Validate token (this feature)
userID, err := rdb.Get(ctx, constants.RedisAuthTokenKey(token)).Result()
if err == redis.Nil {
// Token not found or expired
}
// Delete token (example - logout feature)
rdb.Del(ctx, constants.RedisAuthTokenKey(token))
Key Generation Function (pkg/constants/redis.go):
// RedisAuthTokenKey generates Redis key for authentication tokens
func RedisAuthTokenKey(token string) string {
return fmt.Sprintf("auth:token:%s", token)
}
Validation Rules:
- Token must exist as Redis key
- Redis must be available (fail closed if not)
- Token value must be non-empty user ID
- TTL is checked automatically by Redis (expired keys return
redis.Nil)
3. Request Context Model
RequestContext Structure
File: pkg/middleware/context.go (or stored in Fiber's c.Locals())
// Request context is stored in Fiber's Locals, not a struct
// Access via: c.Locals("key")
// Request ID (set by requestid middleware)
requestID := c.Locals(constants.ContextKeyRequestID).(string) // UUID v4 string
// User ID (set by keyauth middleware after validation)
userID, ok := c.Locals(constants.ContextKeyUserID).(string)
if !ok {
// Not authenticated
}
// Start time (for duration calculation)
startTime := c.Locals(constants.ContextKeyStartTime).(time.Time)
Context Keys (constants in pkg/constants/constants.go):
const (
// Context keys for Fiber Locals
ContextKeyRequestID = "requestid"
ContextKeyUserID = "user_id"
ContextKeyStartTime = "start_time"
)
Lifecycle:
- requestid middleware: Sets
requestidin Locals (UUID v4) - logger middleware: Sets
start_timein Locals - keyauth middleware: Sets
user_idin Locals (after validation) - handler: Accesses context values from Locals
- logger middleware (after handler): Calculates duration and logs
4. Log Entry Models
Application Log Entry
Format: JSON
Output: logs/app.log
Logger: Zap (appLogger instance)
{
"timestamp": "2025-11-10T15:30:45.123Z",
"level": "info",
"logger": "service.user",
"caller": "user/service.go:42",
"message": "User created successfully",
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"user_id": "user-789",
"username": "john_doe",
"ip": "192.168.1.100"
}
Fields:
timestamp: ISO 8601 format (RFC3339)level: debug, info, warn, errorlogger: Logger name (optional, for structured logging)caller: Source file and line numbermessage: Log messagerequest_id: Request correlation ID (if available)user_id: Authenticated user ID (if available)- Custom fields: Any additional context-specific fields
Zap Usage:
appLogger.Info("User created successfully",
zap.String("request_id", requestID),
zap.String(constants.ContextKeyUserID, userID),
zap.String("username", username),
zap.String("ip", ip),
)
Access Log Entry Format
Purpose: Records all HTTP requests for audit trail, performance monitoring, and troubleshooting
Format: JSON (one entry per line)
Output: logs/access.log
Logger: Zap (accessLogger instance)
Requirement: Implements spec.md FR-011
Complete JSON Schema:
{
"timestamp": "2025-11-10T15:30:45.123Z",
"level": "info",
"method": "POST",
"path": "/api/v1/users",
"status": 200,
"duration_ms": 45.234,
"request_id": "550e8400-e29b-41d4-a716-446655440000",
"ip": "192.168.1.100",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...",
"user_id": "user-789"
}
Field Definitions:
| Field | Type | Required | Description | Example |
|---|---|---|---|---|
timestamp |
string | Yes | ISO 8601 timestamp (RFC3339 with milliseconds) | 2025-11-10T15:30:45.123Z |
level |
string | Yes | Log level (always "info" for access logs) | info |
method |
string | Yes | HTTP method | GET, POST, PUT, DELETE, PATCH |
path |
string | Yes | Request path (including query params) | /api/v1/users?page=1 |
status |
int | Yes | HTTP status code | 200, 401, 500 |
duration_ms |
float64 | Yes | Request processing duration in milliseconds | 45.234 |
request_id |
string | Yes | UUID v4 request identifier | 550e8400-e29b-41d4-a716-446655440000 |
ip |
string | Yes | Client IP address | 192.168.1.100 |
user_agent |
string | Yes | User-Agent header value | Mozilla/5.0... |
user_id |
string | No | Authenticated user ID (empty string if not authenticated) | user-789 or "" |
Notes:
- All access logs are written at "info" level
user_idfield is empty string ("") for unauthenticated requestsduration_msincludes full middleware chain execution timepathincludes query parameters for complete request tracking- Logged after response is sent (includes actual status code)
Zap Usage:
accessLogger.Info("",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Int("status", c.Response().StatusCode()),
zap.Float64("duration_ms", duration.Seconds()*1000),
zap.String("request_id", requestID),
zap.String("ip", c.IP()),
zap.String("user_agent", c.Get("User-Agent")),
zap.String(constants.ContextKeyUserID, userID),
)
5. API Response Model
Unified Response Structure
File: pkg/response/response.go
// Response is the unified API response structure
type Response struct {
Code int `json:"code"` // Application error code (0 = success)
Data interface{} `json:"data"` // Response data (object, array, or null)
Message string `json:"msg"` // Human-readable message
Timestamp string `json:"timestamp"` // ISO 8601 timestamp (optional, can be added)
}
// Success returns a successful response
func Success(c *fiber.Ctx, data interface{}) error {
return c.JSON(Response{
Code: 0,
Data: data,
Message: "success",
Timestamp: time.Now().Format(time.RFC3339),
})
}
// Error returns an error response
func Error(c *fiber.Ctx, httpStatus int, code int, message string) error {
return c.Status(httpStatus).JSON(Response{
Code: code,
Data: nil,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
// SuccessWithMessage returns a successful response with custom message
func SuccessWithMessage(c *fiber.Ctx, data interface{}, message string) error {
return c.JSON(Response{
Code: 0,
Data: data,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
Success Response Example:
{
"code": 0,
"data": {
"id": "user-123",
"name": "John Doe",
"email": "john@example.com"
},
"msg": "success",
"timestamp": "2025-11-10T15:30:45Z"
}
Error Response Example:
{
"code": 1002,
"data": null,
"msg": "Invalid or expired token",
"timestamp": "2025-11-10T15:30:45Z"
}
List Response Example:
{
"code": 0,
"data": [
{"id": "1", "name": "Item 1"},
{"id": "2", "name": "Item 2"}
],
"msg": "success",
"timestamp": "2025-11-10T15:30:45Z"
}
6. Error Model
Error Code Constants
File: pkg/errors/codes.go
// Application error codes
const (
CodeSuccess = 0 // Success
CodeInternalError = 1000 // Internal server error
CodeMissingToken = 1001 // Missing authentication token
CodeInvalidToken = 1002 // Invalid or expired token
CodeTooManyRequests = 1003 // Too many requests (rate limited)
CodeAuthServiceUnavailable = 1004 // Authentication service unavailable (Redis down)
)
// Error messages (bilingual)
var errorMessages = map[int]struct {
EN string
ZH string
}{
CodeSuccess: {"Success", "成功"},
CodeInternalError: {"Internal server error", "内部服务器错误"},
CodeMissingToken: {"Missing authentication token", "缺失认证令牌"},
CodeInvalidToken: {"Invalid or expired token", "令牌无效或已过期"},
CodeTooManyRequests: {"Too many requests", "请求过于频繁"},
CodeAuthServiceUnavailable: {"Authentication service unavailable", "认证服务不可用"},
}
// GetMessage returns error message for given code and language
func GetMessage(code int, lang string) string {
msg, ok := errorMessages[code]
if !ok {
return "Unknown error"
}
if lang == "zh" {
return msg.ZH
}
return msg.EN
}
Custom Error Types
File: pkg/errors/errors.go
import "errors"
// Standard error types for middleware
var (
ErrMissingToken = errors.New("missing authentication token")
ErrInvalidToken = errors.New("invalid or expired token")
ErrRedisUnavailable = errors.New("redis unavailable")
ErrTooManyRequests = errors.New("too many requests")
)
// AppError represents an application error with code
type AppError struct {
Code int // Application error code
Message string // Error message
Err error // Underlying error (optional)
}
func (e *AppError) Error() string {
if e.Err != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Err)
}
return e.Message
}
func (e *AppError) Unwrap() error {
return e.Err
}
// New creates a new AppError
func New(code int, message string) *AppError {
return &AppError{
Code: code,
Message: message,
}
}
// Wrap wraps an existing error with code and message
func Wrap(code int, message string, err error) *AppError {
return &AppError{
Code: code,
Message: message,
Err: err,
}
}
7. Rate Limit State Model
Rate Limit Tracking
Storage: In-memory (default) or Redis (for distributed) Managed by: Fiber limiter middleware (internal storage)
// Rate limit state is managed internally by Fiber limiter
// For memory storage: map[string]*limiterEntry
// For Redis storage: Redis keys with TTL
// Memory storage structure (internal to Fiber)
type limiterEntry struct {
count int // Current request count
expiration time.Time // Window expiration time
}
// Redis storage structure (if using Redis storage)
// Key: "ratelimit:{ip_address}"
// Value: JSON {"count": 5, "expiration": "2025-11-10T15:31:00Z"}
// TTL: Same as expiration window
Key Generation (for custom Redis storage):
// pkg/constants/redis.go
func RedisRateLimitKey(ip string) string {
return fmt.Sprintf("ratelimit:%s", ip)
}
Access Pattern:
- Middleware checks rate limit state before handler
- Increment counter on each request
- Reset counter when window expires
- Return 429 when limit exceeded
Entity Relationship Diagram
┌─────────────────┐
│ Configuration │ (YAML file)
│ - Server │
│ - Redis │
│ - Logging │
│ - Middleware │
└────────┬────────┘
│ loads into
▼
┌─────────────────┐ ┌──────────────────┐
│ Application │────▶│ Redis Client │
│ (main.go) │ │ (connection │
└────────┬────────┘ │ pool) │
│ └──────────┬───────┘
│ creates │
▼ │ validates tokens
┌─────────────────┐ │
│ Middleware │ │
│ Chain: │ │
│ - Recover │ │
│ - RequestID │ │
│ - Logger │ │
│ - KeyAuth │◀───────────────┘
│ - RateLimiter │
└────────┬────────┘
│ processes
▼
┌─────────────────┐ ┌──────────────────┐
│ Handlers │────▶│ Response │
│ (API │ │ {code, data, │
│ endpoints) │ │ msg} │
└─────────────────┘ └──────────────────┘
│
│ logs to
▼
┌─────────────────┐
│ Log Files │
│ - app.log │ (Zap + Lumberjack)
│ - access.log │
└─────────────────┘
Data Flow Diagram
Request Processing Flow
1. HTTP Request arrives
↓
2. Recover Middleware (catch panics)
↓
3. RequestID Middleware (generate UUID v4)
→ Store in c.Locals(constants.ContextKeyRequestID)
↓
4. Logger Middleware (start)
→ Store start_time in c.Locals(constants.ContextKeyStartTime)
↓
5. KeyAuth Middleware
→ Extract token from header
→ Call TokenValidator.Validate(token)
→ Validate with Redis: GET auth:token:{token}
→ If valid: Store user_id in c.Locals(constants.ContextKeyUserID)
→ If invalid: Return 401 with error code
→ If Redis down: Return 503 with error code
↓
6. [RateLimiter Middleware] (if enabled)
→ Check rate limit for c.IP()
→ If exceeded: Return 429 with error code
↓
7. Handler (business logic)
→ Access c.Locals(constants.ContextKeyRequestID), c.Locals(constants.ContextKeyUserID)
→ Process request
→ Return response via response.Success() or response.Error()
↓
8. Logger Middleware (end)
→ Calculate duration
→ Log to access.log with all context
↓
9. HTTP Response sent
Summary
Key Entities:
- Config: Application configuration (YAML → struct)
- AuthToken: Redis key-value (token → user ID)
- RequestContext: Fiber Locals (requestid, user_id, start_time)
- LogEntry: JSON logs (app.log, access.log)
- Response: Unified API response ({code, data, msg})
- Error: Error codes and custom types
- RateLimitState: Managed by Fiber limiter (memory or Redis)
Design Principles:
- ✅ Simple, flat structures (no deep nesting)
- ✅ Direct field access (no getters/setters)
- ✅ Composition over inheritance
- ✅ Explicit error handling
- ✅ Go naming conventions (URL, ID, HTTP)
- ✅ No Java-style patterns (no I-prefix, no Impl-suffix)
Next: Generate API contracts (OpenAPI specification)
8. Configuration Validation Rules
This section defines comprehensive validation constraints for all configuration fields, implementing the requirements in spec.md FR-003.
Validation Error Format
All validation errors MUST follow this format:
"Invalid configuration: {field_path}: {error_reason} (current value: {value}, expected: {constraint})"
Example:
"Invalid configuration: server.read_timeout: duration out of range (current value: 1s, expected: 5s-300s)"
Server Configuration Validation
| Field | Type | Required | Constraint | Default | Error Message |
|---|---|---|---|---|---|
server.address |
string | Yes | Non-empty, format :PORT or HOST:PORT |
:3000 |
"server.address: must be non-empty and in format ':PORT' or 'HOST:PORT'" |
server.read_timeout |
duration | Yes | 5s - 300s | 10s |
"server.read_timeout: duration out of range (expected: 5s-300s)" |
server.write_timeout |
duration | Yes | 5s - 300s | 10s |
"server.write_timeout: duration out of range (expected: 5s-300s)" |
server.shutdown_timeout |
duration | Yes | 10s - 120s | 30s |
"server.shutdown_timeout: duration out of range (expected: 10s-120s)" |
server.prefork |
bool | No | true or false | false |
"server.prefork: must be boolean (true/false)" |
Redis Configuration Validation
| Field | Type | Required | Constraint | Default | Error Message |
|---|---|---|---|---|---|
redis.address |
string | Yes | Non-empty, format HOST:PORT |
localhost:6379 |
"redis.address: must be non-empty and in format 'HOST:PORT'" |
redis.password |
string | No | Any string (empty allowed) | "" |
N/A |
redis.db |
int | No | 0 - 15 | 0 |
"redis.db: database number out of range (expected: 0-15)" |
redis.pool_size |
int | No | 1 - 1000 | 10 |
"redis.pool_size: pool size out of range (expected: 1-1000)" |
redis.min_idle_conns |
int | No | 0 - pool_size | 5 |
"redis.min_idle_conns: must be 0 to pool_size" |
redis.dial_timeout |
duration | No | 1s - 30s | 5s |
"redis.dial_timeout: timeout out of range (expected: 1s-30s)" |
redis.read_timeout |
duration | No | 1s - 30s | 3s |
"redis.read_timeout: timeout out of range (expected: 1s-30s)" |
redis.write_timeout |
duration | No | 1s - 30s | 3s |
"redis.write_timeout: timeout out of range (expected: 1s-30s)" |
Logging Configuration Validation
| Field | Type | Required | Constraint | Default | Error Message |
|---|---|---|---|---|---|
logging.level |
string | No | One of: debug, info, warn, error | info |
"logging.level: invalid log level (expected: debug, info, warn, error)" |
logging.development |
bool | No | true or false | false |
"logging.development: must be boolean (true/false)" |
App Log Validation
| Field | Type | Required | Constraint | Default | Error Message |
|---|---|---|---|---|---|
logging.app_log.filename |
string | Yes | Non-empty, valid file path | logs/app.log |
"logging.app_log.filename: must be non-empty valid file path" |
logging.app_log.max_size |
int | No | 1 - 1000 (MB) | 100 |
"logging.app_log.max_size: size out of range (expected: 1-1000 MB)" |
logging.app_log.max_backups |
int | No | 0 - 999 (0 = keep all) | 30 |
"logging.app_log.max_backups: count out of range (expected: 0-999)" |
logging.app_log.max_age |
int | No | 1 - 365 (days) | 30 |
"logging.app_log.max_age: retention period out of range (expected: 1-365 days)" |
logging.app_log.compress |
bool | No | true or false | true |
"logging.app_log.compress: must be boolean (true/false)" |
Access Log Validation
| Field | Type | Required | Constraint | Default | Error Message |
|---|---|---|---|---|---|
logging.access_log.filename |
string | Yes | Non-empty, valid file path | logs/access.log |
"logging.access_log.filename: must be non-empty valid file path" |
logging.access_log.max_size |
int | No | 1 - 1000 (MB) | 500 |
"logging.access_log.max_size: size out of range (expected: 1-1000 MB)" |
logging.access_log.max_backups |
int | No | 0 - 999 (0 = keep all) | 90 |
"logging.access_log.max_backups: count out of range (expected: 0-999)" |
logging.access_log.max_age |
int | No | 1 - 365 (days) | 90 |
"logging.access_log.max_age: retention period out of range (expected: 1-365 days)" |
logging.access_log.compress |
bool | No | true or false | true |
"logging.access_log.compress: must be boolean (true/false)" |
Middleware Configuration Validation
| Field | Type | Required | Constraint | Default | Error Message |
|---|---|---|---|---|---|
middleware.enable_auth |
bool | No | true or false | true |
"middleware.enable_auth: must be boolean (true/false)" |
middleware.enable_rate_limiter |
bool | No | true or false | false |
"middleware.enable_rate_limiter: must be boolean (true/false)" |
Rate Limiter Validation
| Field | Type | Required | Constraint | Default | Error Message |
|---|---|---|---|---|---|
middleware.rate_limiter.max |
int | No | 1 - 10000 | 30 |
"middleware.rate_limiter.max: request limit out of range (expected: 1-10000)" |
middleware.rate_limiter.expiration |
duration | No | 1s - 1h | 1m |
"middleware.rate_limiter.expiration: window duration out of range (expected: 1s-1h)" |
middleware.rate_limiter.storage |
string | No | "memory" or "redis" | memory |
"middleware.rate_limiter.storage: invalid storage type (expected: memory or redis)" |
Validation Implementation Guidelines
Location: pkg/config/loader.go - Validate() function
Validation Order:
- Required fields check - Fail fast if critical fields missing
- Type validation - Viper handles basic type conversion
- Range validation - Check numeric bounds
- Format validation - Validate string formats (HOST:PORT, file paths)
- Cross-field validation - Check dependencies (e.g., min_idle_conns <= pool_size)
Example Implementation Pattern:
func (c *Config) Validate() error {
// Required fields
if c.Server.Address == "" {
return fmt.Errorf("Invalid configuration: server.address: must be non-empty (current value: empty)")
}
// Range validation
if c.Server.ReadTimeout < 5*time.Second || c.Server.ReadTimeout > 300*time.Second {
return fmt.Errorf("Invalid configuration: server.read_timeout: duration out of range (current value: %s, expected: 5s-300s)", c.Server.ReadTimeout)
}
// Format validation
if !strings.Contains(c.Redis.Address, ":") {
return fmt.Errorf("Invalid configuration: redis.address: invalid format (current value: %s, expected: HOST:PORT)", c.Redis.Address)
}
// Cross-field validation
if c.Redis.MinIdleConns > c.Redis.PoolSize {
return fmt.Errorf("Invalid configuration: redis.min_idle_conns: must not exceed pool_size (current value: %d, pool_size: %d)", c.Redis.MinIdleConns, c.Redis.PoolSize)
}
return nil
}
Testing Requirements:
- Unit tests MUST cover all validation rules (T020 in tasks.md)
- Test valid configurations (should pass)
- Test each constraint violation (should fail with correct error message)
- Test edge cases (min/max boundaries)
- Test malformed YAML (should be caught by Viper)