diff --git a/.gitignore b/.gitignore index b5e7894..a264992 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,12 @@ logs *.log # Generated binaries -main \ No newline at end of file +main + +# Configuration files (exclude sensitive configs, include examples) +config.yaml +config.yml +configs/config.yaml +configs/config.yml +!config.example.yaml +!config.example.yml \ No newline at end of file diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..db5503e --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,39 @@ +# Configuration file for go-api +# This file follows a strict schema with validation +# Environment variables will override any values set here + +server: + host: localhost + port: "8000" + +database: + url: postgres://user:password@localhost/dbname?sslmode=disable + +rate_limiter: + requests_per_time_frame: 100 + time_frame: 60s + enabled: true + +push_notification: + vapid_public_key: "your-vapid-public-key" + vapid_private_key: "your-vapid-private-key" + +auth: + # api_key: "your-api-key" # Optional + jwt_secret: "your-secret-key" + jwt_issuer: "your-app" + jwt_audience: "your-app-users" + token_expiration: 15m + refresh_expiration: 168h # 7 days + +storage: + enabled: false + bucket_name: "my-bucket" + account_id: "account-id" + access_key_id: "access-key" + secret_access_key: "secret-key" + # public_domain: "https://cdn.example.com" # Optional + use_public_url: true + +redis: + url: "redis://localhost:6379" \ No newline at end of file diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..6e3a971 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,176 @@ +# Configuration Management + +This document describes the configuration management system for the go-api project, which supports both YAML files and environment variables with strict validation. + +## Overview + +The configuration system follows best practices by: +- Supporting structured YAML configuration files with strict schema validation +- Maintaining backward compatibility with environment variables +- Providing environment variable override capability +- Implementing comprehensive validation using Go's validator package +- Offering flexible configuration file locations + +## Configuration Sources Priority + +1. **Environment Variables** (highest priority) - override any YAML values +2. **YAML Configuration File** - structured configuration with validation +3. **Default Values** (lowest priority) - fallback values defined in code + +## YAML Configuration + +### Configuration File Locations + +The system automatically searches for configuration files in the following order: + +1. `./config.yaml` +2. `./config.yml` +3. `./configs/config.yaml` +4. `./configs/config.yml` +5. `$HOME/.config/go-api/config.yaml` +6. `/etc/go-api/config.yaml` + +### YAML Schema + +```yaml +server: + host: localhost # required: Server hostname + port: "8000" # required: Server port (string) + +database: + url: postgres://user:password@localhost/dbname?sslmode=disable # required: Database connection URL + +rate_limiter: + requests_per_time_frame: 100 # min=0: Maximum requests per time frame + time_frame: 60s # min=0: Time frame duration (e.g., 30s, 5m, 1h) + enabled: true # Enable/disable rate limiting + +push_notification: + vapid_public_key: "your-vapid-public-key" # required: VAPID public key + vapid_private_key: "your-vapid-private-key" # required: VAPID private key + +auth: + api_key: "your-api-key" # optional: API key + jwt_secret: "your-secret-key" # required: JWT signing secret + jwt_issuer: "your-app" # required: JWT issuer + jwt_audience: "your-app-users" # required: JWT audience + token_expiration: 15m # min=1m: Token expiration (e.g., 15m, 1h) + refresh_expiration: 168h # min=1h: Refresh token expiration (e.g., 24h, 168h) + +storage: + enabled: false # Enable/disable storage features + bucket_name: "my-bucket" # required_if enabled: S3-compatible bucket name + account_id: "account-id" # required_if enabled: Storage account ID + access_key_id: "access-key" # required_if enabled: Storage access key + secret_access_key: "secret-key" # required_if enabled: Storage secret key + public_domain: "https://cdn.example.com" # optional: Public domain for file URLs + use_public_url: true # Use public URLs for file access + +redis: + url: "redis://localhost:6379" # required: Redis connection URL +``` + +### Time Duration Format + +Time durations can be specified using Go's standard duration format: +- `30s` - 30 seconds +- `5m` - 5 minutes +- `1h` - 1 hour +- `24h` - 24 hours +- `168h` - 168 hours (7 days) + +## Environment Variables + +All YAML configuration values can be overridden using environment variables: + +```bash +# Server configuration +API_URL=localhost +PORT=8000 + +# Database configuration +DATABASE_URL=postgres://user:password@localhost/dbname?sslmode=disable + +# JWT configuration +JWT_SECRET=your-secret-key +JWT_ISSUER=your-app +JWT_AUDIENCE=your-app-users +JWT_TOKEN_EXPIRATION=15 # in minutes +JWT_REFRESH_EXPIRATION=10080 # in minutes (7 days) + +# Rate limiting +RATE_LIMIT_MAX_REQUESTS=100 +RATE_LIMIT_TIMEFRAME=60 # in seconds + +# Push notifications +VAPID_PUBLIC_KEY=your-vapid-public-key +VAPID_PRIVATE_KEY=your-vapid-private-key + +# Storage configuration +STORAGE_ENABLED=false +STORAGE_BUCKET_NAME=my-bucket +STORAGE_ACCOUNT_ID=account-id +STORAGE_ACCESS_KEY_ID=access-key +STORAGE_SECRET_ACCESS_KEY=secret-key +STORAGE_PUBLIC_DOMAIN=https://cdn.example.com +STORAGE_USE_PUBLIC_URL=true + +# Redis configuration +REDIS_URL=redis://localhost:6379 +``` + +## Validation + +The configuration system includes comprehensive validation: + +### Required Fields +- All `required` fields must have non-empty values +- `required_if` fields are required when the condition is met (e.g., storage fields when storage is enabled) + +### Value Constraints +- **Time Durations**: Minimum values enforced (e.g., token expiration ≥ 1 minute) +- **Numeric Values**: Minimum values enforced (e.g., rate limiting ≥ 0) +- **URLs**: Database and Redis URLs must be valid connection strings + +### Error Handling +- Validation errors are reported with detailed field information +- Missing required configuration stops application startup +- Invalid YAML syntax is reported with line numbers + +## Migration from Environment Variables Only + +If you're currently using only environment variables, no changes are required. The system maintains full backward compatibility: + +1. **Existing setup**: Continue using environment variables as before +2. **Gradual migration**: Create a YAML file with some values, keep others as environment variables +3. **Full migration**: Move all configuration to YAML, use environment variables only for sensitive values or deployment-specific overrides + +## Best Practices + +1. **Use YAML for base configuration**: Define your default configuration in YAML files +2. **Use environment variables for secrets**: Override sensitive values like passwords and keys +3. **Use environment variables for deployment differences**: Override values that differ between environments (dev, staging, production) +4. **Version control**: Include `config.example.yaml` in version control, but exclude actual configuration files with secrets +5. **Validation**: Always test your configuration changes to catch validation errors early + +## Example Usage + +```go +// Load configuration (automatic discovery) +config := config.LoadConfig() + +// Load from specific file +config, err := config.LoadConfigFromYAML("./my-config.yaml") +if err != nil { + log.Fatalf("Failed to load config: %v", err) +} +``` + +## Troubleshooting + +### Common Issues + +1. **Validation errors**: Check that all required fields are provided and meet constraints +2. **YAML syntax errors**: Validate your YAML file syntax using online tools or `yamllint` +3. **File not found**: Ensure your configuration file is in one of the searched locations +4. **Environment variable types**: Remember that environment variables for durations use different units (minutes/seconds) compared to YAML format \ No newline at end of file diff --git a/go.mod b/go.mod index 78254d5..7ce519f 100644 --- a/go.mod +++ b/go.mod @@ -57,7 +57,7 @@ require ( github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index eee1c54..38d2f52 100644 --- a/go.sum +++ b/go.sum @@ -92,6 +92,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= diff --git a/internal/config/config.go b/internal/config/config.go index 5ca947d..f1c77b0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,106 +1,84 @@ package config import ( + "log" "time" - - "github.com/imlargo/go-api/pkg/env" ) type AppConfig struct { - Server ServerConfig - Database DbConfig - RateLimiter RateLimiterConfig - PushNotification PushNotificationConfig - Auth AuthConfig - Storage StorageConfig - Redis RedisConfig + Server ServerConfig `yaml:"server" validate:"required"` + Database DbConfig `yaml:"database" validate:"required"` + RateLimiter RateLimiterConfig `yaml:"rate_limiter" validate:"required"` + PushNotification PushNotificationConfig `yaml:"push_notification" validate:"required"` + Auth AuthConfig `yaml:"auth" validate:"required"` + Storage StorageConfig `yaml:"storage" validate:"required"` + Redis RedisConfig `yaml:"redis" validate:"required"` } type ServerConfig struct { - Host string - Port string + Host string `yaml:"host" validate:"required" example:"localhost"` + Port string `yaml:"port" validate:"required" example:"8000"` } type RateLimiterConfig struct { - RequestsPerTimeFrame int - TimeFrame time.Duration - Enabled bool + RequestsPerTimeFrame int `yaml:"requests_per_time_frame" validate:"min=0" example:"100"` + TimeFrame time.Duration `yaml:"time_frame" validate:"min=0" example:"60s"` + Enabled bool `yaml:"enabled" example:"true"` } type PushNotificationConfig struct { - VAPIDPublicKey string - VAPIDPrivateKey string + VAPIDPublicKey string `yaml:"vapid_public_key" validate:"required" example:"your-vapid-public-key"` + VAPIDPrivateKey string `yaml:"vapid_private_key" validate:"required" example:"your-vapid-private-key"` } type AuthConfig struct { - ApiKey string + ApiKey string `yaml:"api_key,omitempty" example:"your-api-key"` - JwtSecret string - JwtIssuer string - JwtAudience string - TokenExpiration time.Duration - RefreshExpiration time.Duration + JwtSecret string `yaml:"jwt_secret" validate:"required" example:"your-secret-key"` + JwtIssuer string `yaml:"jwt_issuer" validate:"required" example:"your-app"` + JwtAudience string `yaml:"jwt_audience" validate:"required" example:"your-app-users"` + TokenExpiration time.Duration `yaml:"token_expiration" validate:"min=1m" example:"15m"` + RefreshExpiration time.Duration `yaml:"refresh_expiration" validate:"min=1h" example:"168h"` } type DbConfig struct { - URL string + URL string `yaml:"url" validate:"required" example:"postgres://user:password@localhost/dbname?sslmode=disable"` } type StorageConfig struct { - Enabled bool - BucketName string - AccountID string - AccessKeyID string - SecretAccessKey string - PublicDomain string // Optional domain - UsePublicURL bool // Use public URL for accessing files + Enabled bool `yaml:"enabled" example:"true"` + BucketName string `yaml:"bucket_name" validate:"required_if=Enabled true" example:"my-bucket"` + AccountID string `yaml:"account_id" validate:"required_if=Enabled true" example:"account-id"` + AccessKeyID string `yaml:"access_key_id" validate:"required_if=Enabled true" example:"access-key"` + SecretAccessKey string `yaml:"secret_access_key" validate:"required_if=Enabled true" example:"secret-key"` + PublicDomain string `yaml:"public_domain,omitempty" example:"https://cdn.example.com"` + UsePublicURL bool `yaml:"use_public_url" example:"true"` } type RedisConfig struct { - RedisURL string + RedisURL string `yaml:"url" validate:"required" example:"redis://localhost:6379"` } func LoadConfig() AppConfig { + // Try to load .env file for environment variables err := loadEnv() if err != nil { - panic("Error loading environment variables: " + err.Error()) + log.Printf("Warning: %v", err) } - return AppConfig{ - Server: ServerConfig{ - Host: env.GetEnvString(API_URL, "localhost"), - Port: env.GetEnvString(PORT, "8000"), - }, - Database: DbConfig{ - URL: env.GetEnvString(DATABASE_URL, ""), - }, - RateLimiter: RateLimiterConfig{ - RequestsPerTimeFrame: env.GetEnvInt(RATE_LIMIT_MAX_REQUESTS, 0), - TimeFrame: time.Duration(env.GetEnvInt(RATE_LIMIT_TIMEFRAME, 0)) * time.Second, - Enabled: env.GetEnvInt(RATE_LIMIT_MAX_REQUESTS, 0) != 0 && env.GetEnvInt(RATE_LIMIT_TIMEFRAME, 0) != 0, - }, - PushNotification: PushNotificationConfig{ - VAPIDPublicKey: env.GetEnvString(VAPID_PUBLIC_KEY, ""), - VAPIDPrivateKey: env.GetEnvString(VAPID_PRIVATE_KEY, ""), - }, - Auth: AuthConfig{ - JwtSecret: env.GetEnvString(JWT_SECRET, "your-secret-key"), - JwtIssuer: env.GetEnvString(JWT_ISSUER, "your-app"), - JwtAudience: env.GetEnvString(JWT_AUDIENCE, "your-app-users"), - TokenExpiration: time.Duration(env.GetEnvInt(JWT_TOKEN_EXPIRATION, 15)) * time.Minute, - RefreshExpiration: time.Duration(env.GetEnvInt(JWT_REFRESH_EXPIRATION, 10080)) * time.Minute, - }, - Storage: StorageConfig{ - Enabled: env.GetEnvBool(STORAGE_ENABLED, false), - BucketName: env.GetEnvString(STORAGE_BUCKET_NAME, ""), - AccountID: env.GetEnvString(STORAGE_ACCOUNT_ID, ""), - AccessKeyID: env.GetEnvString(STORAGE_ACCESS_KEY_ID, ""), - SecretAccessKey: env.GetEnvString(STORAGE_SECRET_ACCESS_KEY, ""), - PublicDomain: env.GetEnvString(STORAGE_PUBLIC_DOMAIN, ""), - UsePublicURL: env.GetEnvBool(STORAGE_USE_PUBLIC_URL, false), - }, - Redis: RedisConfig{ - RedisURL: env.GetEnvString(REDIS_URL, ""), - }, + // Try to load configuration from YAML files + for _, configPath := range GetDefaultConfigPaths() { + if config, err := LoadConfigFromYAML(configPath); err == nil { + return *config + } } + + // Fallback to environment variables only + log.Println("No YAML configuration found, using environment variables only") + config, err := LoadConfigFromYAML("") // Empty path forces env-only loading + if err != nil { + panic("Error loading configuration: " + err.Error()) + } + + return *config } diff --git a/internal/config/env.go b/internal/config/env.go index f3aaef8..c17eaf8 100644 --- a/internal/config/env.go +++ b/internal/config/env.go @@ -40,49 +40,42 @@ const ( REDIS_URL = "REDIS_URL" ) -// Initialize loads environment variables from .env file +// loadEnv loads environment variables from .env file and validates required ones +// This function is now more lenient since configuration can come from YAML func loadEnv() error { // Load .env file if it exists if err := godotenv.Load(); err != nil { - log.Println("Error loading .env file, proceeding with system environment variables") + log.Println("No .env file found, proceeding with system environment variables") } - // Define required environment variables + // Define required environment variables when no YAML config is present + // These are now validated in the YAML configuration system requiredEnvVars := []string{ - API_URL, - PORT, - DATABASE_URL, - JWT_SECRET, - JWT_ISSUER, - JWT_AUDIENCE, - JWT_TOKEN_EXPIRATION, - JWT_REFRESH_EXPIRATION, - RATE_LIMIT_MAX_REQUESTS, - RATE_LIMIT_TIMEFRAME, - VAPID_PUBLIC_KEY, - VAPID_PRIVATE_KEY, - STORAGE_BUCKET_NAME, - STORAGE_ACCOUNT_ID, - STORAGE_ACCESS_KEY_ID, - STORAGE_SECRET_ACCESS_KEY, - STORAGE_PUBLIC_DOMAIN, - STORAGE_USE_PUBLIC_URL, - REDIS_URL, - STORAGE_ENABLED, + DATABASE_URL, // Always required for database connection } var missingEnvVars []string - // Check for missing environment variables + // Check for missing critical environment variables for _, envVar := range requiredEnvVars { if os.Getenv(envVar) == "" { missingEnvVars = append(missingEnvVars, envVar) } } - // If there are missing variables, return an error listing them + // Only fail if critical variables are missing and no YAML config is available if len(missingEnvVars) > 0 { - return fmt.Errorf("missing required environment variables: %v", missingEnvVars) + // Check if any YAML config file exists + for _, configPath := range GetDefaultConfigPaths() { + if _, err := os.Stat(configPath); err == nil { + // YAML config found, environment validation is less strict + log.Printf("YAML configuration found at %s, environment variable validation is relaxed", configPath) + return nil + } + } + + // No YAML config found, require all environment variables + return fmt.Errorf("no YAML configuration found and missing required environment variables: %v", missingEnvVars) } return nil diff --git a/internal/config/yaml.go b/internal/config/yaml.go new file mode 100644 index 0000000..c645193 --- /dev/null +++ b/internal/config/yaml.go @@ -0,0 +1,186 @@ +package config + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "time" + + "github.com/go-playground/validator/v10" + "github.com/imlargo/go-api/pkg/env" + "gopkg.in/yaml.v3" +) + +var validate *validator.Validate + +func init() { + validate = validator.New() +} + +// LoadConfigFromYAML loads configuration from a YAML file with strict validation +// Falls back to environment variables for any missing values +func LoadConfigFromYAML(configPath string) (*AppConfig, error) { + config := &AppConfig{} + + // Check if YAML config file exists + if _, err := os.Stat(configPath); err == nil { + // Load from YAML file + yamlFile, err := ioutil.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("error reading YAML config file: %w", err) + } + + // Parse YAML + if err := yaml.Unmarshal(yamlFile, config); err != nil { + return nil, fmt.Errorf("error parsing YAML config: %w", err) + } + + // Override with environment variables if they exist + overrideWithEnvVars(config) + } else { + // If no YAML file exists, load entirely from environment variables + config = loadFromEnvVars() + } + + // Validate the final configuration + if err := validate.Struct(config); err != nil { + return nil, fmt.Errorf("configuration validation failed: %w", err) + } + + return config, nil +} + +// overrideWithEnvVars overrides YAML configuration with environment variables where they exist +func overrideWithEnvVars(config *AppConfig) { + // Server configuration + if host := env.GetEnvString(API_URL, ""); host != "" { + config.Server.Host = host + } + if port := env.GetEnvString(PORT, ""); port != "" { + config.Server.Port = port + } + + // Database configuration + if dbURL := env.GetEnvString(DATABASE_URL, ""); dbURL != "" { + config.Database.URL = dbURL + } + + // Rate limiter configuration + if requests := env.GetEnvInt(RATE_LIMIT_MAX_REQUESTS, -1); requests != -1 { + config.RateLimiter.RequestsPerTimeFrame = requests + config.RateLimiter.Enabled = requests > 0 + } + if timeframe := env.GetEnvInt(RATE_LIMIT_TIMEFRAME, -1); timeframe != -1 { + config.RateLimiter.TimeFrame = time.Duration(timeframe) * time.Second + if config.RateLimiter.RequestsPerTimeFrame > 0 { + config.RateLimiter.Enabled = true + } + } + + // Push notification configuration + if publicKey := env.GetEnvString(VAPID_PUBLIC_KEY, ""); publicKey != "" { + config.PushNotification.VAPIDPublicKey = publicKey + } + if privateKey := env.GetEnvString(VAPID_PRIVATE_KEY, ""); privateKey != "" { + config.PushNotification.VAPIDPrivateKey = privateKey + } + + // Auth configuration + if jwtSecret := env.GetEnvString(JWT_SECRET, ""); jwtSecret != "" { + config.Auth.JwtSecret = jwtSecret + } + if jwtIssuer := env.GetEnvString(JWT_ISSUER, ""); jwtIssuer != "" { + config.Auth.JwtIssuer = jwtIssuer + } + if jwtAudience := env.GetEnvString(JWT_AUDIENCE, ""); jwtAudience != "" { + config.Auth.JwtAudience = jwtAudience + } + if tokenExp := env.GetEnvInt(JWT_TOKEN_EXPIRATION, -1); tokenExp != -1 { + config.Auth.TokenExpiration = time.Duration(tokenExp) * time.Minute + } + if refreshExp := env.GetEnvInt(JWT_REFRESH_EXPIRATION, -1); refreshExp != -1 { + config.Auth.RefreshExpiration = time.Duration(refreshExp) * time.Minute + } + + // Storage configuration + if enabled := os.Getenv(STORAGE_ENABLED); enabled != "" { + config.Storage.Enabled = env.GetEnvBool(STORAGE_ENABLED, false) + } + if bucketName := env.GetEnvString(STORAGE_BUCKET_NAME, ""); bucketName != "" { + config.Storage.BucketName = bucketName + } + if accountID := env.GetEnvString(STORAGE_ACCOUNT_ID, ""); accountID != "" { + config.Storage.AccountID = accountID + } + if accessKeyID := env.GetEnvString(STORAGE_ACCESS_KEY_ID, ""); accessKeyID != "" { + config.Storage.AccessKeyID = accessKeyID + } + if secretAccessKey := env.GetEnvString(STORAGE_SECRET_ACCESS_KEY, ""); secretAccessKey != "" { + config.Storage.SecretAccessKey = secretAccessKey + } + if publicDomain := env.GetEnvString(STORAGE_PUBLIC_DOMAIN, ""); publicDomain != "" { + config.Storage.PublicDomain = publicDomain + } + if usePublicURL := os.Getenv(STORAGE_USE_PUBLIC_URL); usePublicURL != "" { + config.Storage.UsePublicURL = env.GetEnvBool(STORAGE_USE_PUBLIC_URL, false) + } + + // Redis configuration + if redisURL := env.GetEnvString(REDIS_URL, ""); redisURL != "" { + config.Redis.RedisURL = redisURL + } +} + +// loadFromEnvVars loads configuration entirely from environment variables (legacy behavior) +func loadFromEnvVars() *AppConfig { + return &AppConfig{ + Server: ServerConfig{ + Host: env.GetEnvString(API_URL, "localhost"), + Port: env.GetEnvString(PORT, "8000"), + }, + Database: DbConfig{ + URL: env.GetEnvString(DATABASE_URL, ""), + }, + RateLimiter: RateLimiterConfig{ + RequestsPerTimeFrame: env.GetEnvInt(RATE_LIMIT_MAX_REQUESTS, 0), + TimeFrame: time.Duration(env.GetEnvInt(RATE_LIMIT_TIMEFRAME, 0)) * time.Second, + Enabled: env.GetEnvInt(RATE_LIMIT_MAX_REQUESTS, 0) != 0 && env.GetEnvInt(RATE_LIMIT_TIMEFRAME, 0) != 0, + }, + PushNotification: PushNotificationConfig{ + VAPIDPublicKey: env.GetEnvString(VAPID_PUBLIC_KEY, ""), + VAPIDPrivateKey: env.GetEnvString(VAPID_PRIVATE_KEY, ""), + }, + Auth: AuthConfig{ + JwtSecret: env.GetEnvString(JWT_SECRET, "your-secret-key"), + JwtIssuer: env.GetEnvString(JWT_ISSUER, "your-app"), + JwtAudience: env.GetEnvString(JWT_AUDIENCE, "your-app-users"), + TokenExpiration: time.Duration(env.GetEnvInt(JWT_TOKEN_EXPIRATION, 15)) * time.Minute, + RefreshExpiration: time.Duration(env.GetEnvInt(JWT_REFRESH_EXPIRATION, 10080)) * time.Minute, + }, + Storage: StorageConfig{ + Enabled: env.GetEnvBool(STORAGE_ENABLED, false), + BucketName: env.GetEnvString(STORAGE_BUCKET_NAME, ""), + AccountID: env.GetEnvString(STORAGE_ACCOUNT_ID, ""), + AccessKeyID: env.GetEnvString(STORAGE_ACCESS_KEY_ID, ""), + SecretAccessKey: env.GetEnvString(STORAGE_SECRET_ACCESS_KEY, ""), + PublicDomain: env.GetEnvString(STORAGE_PUBLIC_DOMAIN, ""), + UsePublicURL: env.GetEnvBool(STORAGE_USE_PUBLIC_URL, false), + }, + Redis: RedisConfig{ + RedisURL: env.GetEnvString(REDIS_URL, ""), + }, + } +} + +// GetDefaultConfigPaths returns the default paths to look for configuration files +func GetDefaultConfigPaths() []string { + return []string{ + "./config.yaml", + "./config.yml", + "./configs/config.yaml", + "./configs/config.yml", + filepath.Join(os.Getenv("HOME"), ".config", "go-api", "config.yaml"), + "/etc/go-api/config.yaml", + } +} \ No newline at end of file diff --git a/internal/config/yaml_test.go b/internal/config/yaml_test.go new file mode 100644 index 0000000..c5f4eec --- /dev/null +++ b/internal/config/yaml_test.go @@ -0,0 +1,212 @@ +package config + +import ( + "os" + "testing" + "time" +) + +func TestLoadConfigFromYAML(t *testing.T) { + // Create a temporary config file + configContent := ` +server: + host: "test-host" + port: "9000" + +database: + url: "postgres://testuser:testpass@testhost/testdb" + +rate_limiter: + requests_per_time_frame: 50 + time_frame: 30s + enabled: true + +push_notification: + vapid_public_key: "test-public-key" + vapid_private_key: "test-private-key" + +auth: + jwt_secret: "test-secret" + jwt_issuer: "test-issuer" + jwt_audience: "test-audience" + token_expiration: 10m + refresh_expiration: 24h + +storage: + enabled: true + bucket_name: "test-bucket" + account_id: "test-account" + access_key_id: "test-access-key" + secret_access_key: "test-secret-key" + use_public_url: false + +redis: + url: "redis://testhost:6379" +` + + // Write config to temporary file + tmpFile, err := os.CreateTemp("", "config_test_*.yaml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(configContent); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + tmpFile.Close() + + // Test loading configuration + config, err := LoadConfigFromYAML(tmpFile.Name()) + if err != nil { + t.Fatalf("Failed to load YAML config: %v", err) + } + + // Verify configuration values + if config.Server.Host != "test-host" { + t.Errorf("Expected host 'test-host', got '%s'", config.Server.Host) + } + + if config.Server.Port != "9000" { + t.Errorf("Expected port '9000', got '%s'", config.Server.Port) + } + + if config.Database.URL != "postgres://testuser:testpass@testhost/testdb" { + t.Errorf("Unexpected database URL: %s", config.Database.URL) + } + + if config.RateLimiter.RequestsPerTimeFrame != 50 { + t.Errorf("Expected 50 requests per time frame, got %d", config.RateLimiter.RequestsPerTimeFrame) + } + + if config.RateLimiter.TimeFrame != 30*time.Second { + t.Errorf("Expected 30s time frame, got %v", config.RateLimiter.TimeFrame) + } + + if !config.RateLimiter.Enabled { + t.Error("Expected rate limiter to be enabled") + } + + if !config.Storage.Enabled { + t.Error("Expected storage to be enabled") + } + + if config.Storage.BucketName != "test-bucket" { + t.Errorf("Expected bucket name 'test-bucket', got '%s'", config.Storage.BucketName) + } +} + +func TestLoadConfigFromYAML_ValidationError(t *testing.T) { + // Create invalid config + invalidConfigContent := ` +server: + host: "" # This should fail validation + port: "9000" + +database: + url: "" # This should fail validation +` + + tmpFile, err := os.CreateTemp("", "invalid_config_test_*.yaml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(invalidConfigContent); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + tmpFile.Close() + + // This should fail due to validation errors + _, err = LoadConfigFromYAML(tmpFile.Name()) + if err == nil { + t.Error("Expected validation error but got none") + } +} + +func TestLoadConfigFromYAML_NonExistentFile(t *testing.T) { + // Test loading from non-existent file should fail validation when no env vars are set + _, err := LoadConfigFromYAML("/non/existent/file.yaml") + if err == nil { + t.Error("Expected validation error when no config file exists and no env vars are set") + } +} + +func TestLoadConfigFromYAML_WithEnvOverride(t *testing.T) { + // Create a basic config file + configContent := ` +server: + host: "yaml-host" + port: "8000" + +database: + url: "postgres://yamluser:yamlpass@yamlhost/yamldb" + +rate_limiter: + requests_per_time_frame: 100 + time_frame: 60s + enabled: true + +push_notification: + vapid_public_key: "yaml-public-key" + vapid_private_key: "yaml-private-key" + +auth: + jwt_secret: "yaml-secret" + jwt_issuer: "yaml-issuer" + jwt_audience: "yaml-audience" + token_expiration: 15m + refresh_expiration: 168h + +storage: + enabled: false + bucket_name: "yaml-bucket" + account_id: "yaml-account" + access_key_id: "yaml-access-key" + secret_access_key: "yaml-secret-key" + use_public_url: false + +redis: + url: "redis://yamlhost:6379" +` + + tmpFile, err := os.CreateTemp("", "config_env_test_*.yaml") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(configContent); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + tmpFile.Close() + + // Set environment variable to override YAML value + os.Setenv("API_URL", "env-host") + os.Setenv("DATABASE_URL", "postgres://envuser:envpass@envhost/envdb") + defer func() { + os.Unsetenv("API_URL") + os.Unsetenv("DATABASE_URL") + }() + + // Test loading configuration with environment override + config, err := LoadConfigFromYAML(tmpFile.Name()) + if err != nil { + t.Fatalf("Failed to load YAML config with env override: %v", err) + } + + // Verify environment variable override + if config.Server.Host != "env-host" { + t.Errorf("Expected host 'env-host' (from env), got '%s'", config.Server.Host) + } + + if config.Database.URL != "postgres://envuser:envpass@envhost/envdb" { + t.Errorf("Expected database URL from env, got '%s'", config.Database.URL) + } + + // Verify YAML values still used where no env override + if config.PushNotification.VAPIDPublicKey != "yaml-public-key" { + t.Errorf("Expected YAML VAPID key 'yaml-public-key', got '%s'", config.PushNotification.VAPIDPublicKey) + } +} \ No newline at end of file