// Configuration module for VAPORA Backend // Loads config from vapora.toml with environment variable interpolation use serde::{Deserialize, Serialize}; use std::fs; use std::path::Path; use vapora_shared::{Result, VaporaError}; /// Main configuration structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub server: ServerConfig, pub database: DatabaseConfig, pub nats: NatsConfig, pub auth: AuthConfig, pub logging: LoggingConfig, pub metrics: MetricsConfig, } /// Server configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { pub host: String, pub port: u16, pub tls: TlsConfig, } /// TLS configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TlsConfig { pub enabled: bool, #[serde(default)] pub cert_path: String, #[serde(default)] pub key_path: String, } /// Database configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DatabaseConfig { pub url: String, pub max_connections: u32, } /// NATS configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NatsConfig { pub url: String, pub stream_name: String, } /// Authentication configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuthConfig { pub jwt_secret: String, pub jwt_expiration_hours: u32, } /// Logging configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoggingConfig { pub level: String, pub json: bool, } /// Metrics configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MetricsConfig { pub enabled: bool, pub port: u16, } impl Config { /// Load configuration from a TOML file with environment variable interpolation pub fn load>(path: P) -> Result { let path = path.as_ref(); // Read file content let content = fs::read_to_string(path).map_err(|e| { VaporaError::ConfigError(format!("Failed to read config file {:?}: {}", path, e)) })?; // Interpolate environment variables let interpolated = Self::interpolate_env_vars(&content)?; // Parse TOML let config: Config = toml::from_str(&interpolated)?; // Validate configuration config.validate()?; Ok(config) } /// Interpolate environment variables in format ${VAR} or ${VAR:-default} fn interpolate_env_vars(content: &str) -> Result { let mut result = content.to_string(); let re = regex::Regex::new(r"\$\{([^}:]+)(?::-(.*?))?\}").map_err(|e| { VaporaError::ConfigError(format!("Invalid regex pattern: {}", e)) })?; // Process each match for cap in re.captures_iter(content) { let full_match = cap.get(0).ok_or_else(|| { VaporaError::ConfigError("Failed to get regex match".to_string()) })?; let var_name = cap.get(1).ok_or_else(|| { VaporaError::ConfigError("Failed to get variable name".to_string()) })?.as_str(); let default_value = cap.get(2).map(|m| m.as_str()).unwrap_or(""); // Get environment variable or use default let value = std::env::var(var_name).unwrap_or_else(|_| default_value.to_string()); // Replace in result result = result.replace(full_match.as_str(), &value); } Ok(result) } /// Validate configuration values fn validate(&self) -> Result<()> { // Validate server config if self.server.host.is_empty() { return Err(VaporaError::ConfigError("Server host cannot be empty".to_string())); } if self.server.port == 0 { return Err(VaporaError::ConfigError("Server port must be > 0".to_string())); } // Validate TLS config if enabled if self.server.tls.enabled { if self.server.tls.cert_path.is_empty() { return Err(VaporaError::ConfigError("TLS cert_path required when TLS is enabled".to_string())); } if self.server.tls.key_path.is_empty() { return Err(VaporaError::ConfigError("TLS key_path required when TLS is enabled".to_string())); } } // Validate database config if self.database.url.is_empty() { return Err(VaporaError::ConfigError("Database URL cannot be empty".to_string())); } if self.database.max_connections == 0 { return Err(VaporaError::ConfigError("Database max_connections must be > 0".to_string())); } // Validate NATS config if self.nats.url.is_empty() { return Err(VaporaError::ConfigError("NATS URL cannot be empty".to_string())); } // Validate auth config if self.auth.jwt_secret.is_empty() { return Err(VaporaError::ConfigError("JWT secret cannot be empty".to_string())); } if self.auth.jwt_expiration_hours == 0 { return Err(VaporaError::ConfigError("JWT expiration hours must be > 0".to_string())); } // Validate logging config let valid_log_levels = ["trace", "debug", "info", "warn", "error"]; if !valid_log_levels.contains(&self.logging.level.as_str()) { return Err(VaporaError::ConfigError( format!("Invalid log level '{}'. Must be one of: {:?}", self.logging.level, valid_log_levels) )); } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[test] fn test_env_var_interpolation() { std::env::set_var("TEST_VAR", "test_value"); let input = "host = \"${TEST_VAR}\""; let result = Config::interpolate_env_vars(input).unwrap(); assert_eq!(result, "host = \"test_value\""); } #[test] fn test_env_var_with_default() { let input = "host = \"${NONEXISTENT_VAR:-default_value}\""; let result = Config::interpolate_env_vars(input).unwrap(); assert_eq!(result, "host = \"default_value\""); } #[test] fn test_validate_empty_host() { let config = Config { server: ServerConfig { host: "".to_string(), port: 8080, tls: TlsConfig { enabled: false, cert_path: "".to_string(), key_path: "".to_string(), }, }, database: DatabaseConfig { url: "ws://localhost:8000".to_string(), max_connections: 10, }, nats: NatsConfig { url: "nats://localhost:4222".to_string(), stream_name: "vapora".to_string(), }, auth: AuthConfig { jwt_secret: "secret".to_string(), jwt_expiration_hours: 24, }, logging: LoggingConfig { level: "info".to_string(), json: false, }, metrics: MetricsConfig { enabled: true, port: 9090, }, }; assert!(config.validate().is_err()); } }