lmrc-http-common 0.3.16

Common HTTP utilities and patterns for LMRC Stack applications
Documentation
//! Configuration management utilities
//!
//! This module provides reusable configuration patterns for HTTP services.
//!
//! ## Example
//!
//! ```rust
//! use lmrc_http_common::config::{ServerConfig, DatabaseConfig};
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! # unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test"); }
//! // Load from environment variables
//! let server = ServerConfig::from_env()?;
//! let database = DatabaseConfig::from_env(None)?;
//!
//! println!("Server will bind to {}", server.bind_addr()?);
//! # Ok(())
//! # }
//! ```

use serde::{Deserialize, Serialize};
use std::env;
use std::net::{SocketAddr, IpAddr};
use std::str::FromStr;
use thiserror::Error;

/// Configuration error types
#[derive(Debug, Error)]
pub enum ConfigError {
    /// Required environment variable is missing
    #[error("Missing required environment variable: {0}")]
    MissingEnvVar(String),

    /// Invalid configuration value
    #[error("Invalid configuration value for {key}: {message}")]
    InvalidValue { key: String, message: String },

    /// Parse error
    #[error("Failed to parse {key}: {source}")]
    ParseError {
        key: String,
        #[source]
        source: Box<dyn std::error::Error + Send + Sync>,
    },
}

/// Result type for configuration operations
pub type ConfigResult<T> = Result<T, ConfigError>;

/// Server configuration
///
/// Manages HTTP server settings like host, port, and CORS origins.
///
/// ## Example
///
/// ```rust
/// use lmrc_http_common::config::ServerConfig;
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// // Load from environment with default prefix "SERVER_"
/// let config = ServerConfig::from_env()?;
///
/// // Or use custom prefix
/// let config = ServerConfig::from_env_with_prefix("APP_")?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
    /// Host to bind to (e.g., "0.0.0.0", "127.0.0.1")
    pub host: String,
    /// Port to bind to
    pub port: u16,
    /// CORS allowed origins
    pub cors_origins: Vec<String>,
}

impl ServerConfig {
    /// Load configuration from environment variables with default prefix "SERVER_"
    ///
    /// Expected environment variables:
    /// - `SERVER_HOST` (default: "0.0.0.0")
    /// - `SERVER_PORT` (default: 8080)
    /// - `CORS_ORIGINS` (default: "http://localhost:3000", comma-separated)
    pub fn from_env() -> ConfigResult<Self> {
        Self::from_env_with_prefix("SERVER_")
    }

    /// Load configuration from environment variables with custom prefix
    ///
    /// # Arguments
    ///
    /// * `prefix` - Variable prefix (e.g., "GATEWAY_", "API_")
    pub fn from_env_with_prefix(prefix: &str) -> ConfigResult<Self> {
        let host = env::var(format!("{}HOST", prefix))
            .unwrap_or_else(|_| "0.0.0.0".to_string());

        let port = env::var(format!("{}PORT", prefix))
            .ok()
            .and_then(|s| s.parse().ok())
            .unwrap_or(8080);

        let cors_origins = env::var("CORS_ORIGINS")
            .unwrap_or_else(|_| "http://localhost:3000".to_string())
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();

        Ok(Self {
            host,
            port,
            cors_origins,
        })
    }

    /// Get the socket address to bind to
    pub fn bind_addr(&self) -> ConfigResult<SocketAddr> {
        let ip = IpAddr::from_str(&self.host).map_err(|e| ConfigError::ParseError {
            key: "host".to_string(),
            source: Box::new(e),
        })?;

        Ok(SocketAddr::new(ip, self.port))
    }
}

/// Database configuration
///
/// Manages database connection settings.
///
/// ## Example
///
/// ```rust
/// use lmrc_http_common::config::DatabaseConfig;
///
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # unsafe { std::env::set_var("DATABASE_URL", "postgres://localhost/test"); }
/// // Load from environment with default prefix "DATABASE_"
/// let config = DatabaseConfig::from_env(None)?;
/// # unsafe { std::env::set_var("DB_URL", "postgres://localhost/test"); }
/// // Or use custom prefix
/// let config = DatabaseConfig::from_env(Some("DB_"))?;
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
    /// Database connection URL
    pub url: String,
    /// Maximum number of connections in the pool
    pub max_connections: u32,
    /// Connection timeout in seconds
    pub connect_timeout: u64,
}

impl DatabaseConfig {
    /// Load configuration from environment variables
    ///
    /// # Arguments
    ///
    /// * `prefix` - Optional variable prefix (default: "DATABASE_")
    ///
    /// Expected environment variables:
    /// - `{prefix}URL` (required)
    /// - `{prefix}MAX_CONNECTIONS` (default: 10)
    /// - `{prefix}CONNECT_TIMEOUT` (default: 30)
    pub fn from_env(prefix: Option<&str>) -> ConfigResult<Self> {
        let prefix = prefix.unwrap_or("DATABASE_");

        let url = env::var(format!("{}URL", prefix)).map_err(|_| {
            ConfigError::MissingEnvVar(format!("{}URL", prefix))
        })?;

        let max_connections = env::var(format!("{}MAX_CONNECTIONS", prefix))
            .ok()
            .and_then(|s| s.parse().ok())
            .unwrap_or(10);

        let connect_timeout = env::var(format!("{}CONNECT_TIMEOUT", prefix))
            .ok()
            .and_then(|s| s.parse().ok())
            .unwrap_or(30);

        Ok(Self {
            url,
            max_connections,
            connect_timeout,
        })
    }
}

/// Trait for types that can be loaded from environment variables
pub trait ConfigLoader: Sized {
    /// Load configuration from environment variables
    fn from_env() -> ConfigResult<Self>;

    /// Validate the configuration
    fn validate(&self) -> ConfigResult<()> {
        Ok(())
    }
}

impl ConfigLoader for ServerConfig {
    fn from_env() -> ConfigResult<Self> {
        Self::from_env()
    }

    fn validate(&self) -> ConfigResult<()> {
        if self.port == 0 {
            return Err(ConfigError::InvalidValue {
                key: "port".to_string(),
                message: "Port cannot be 0".to_string(),
            });
        }

        if self.host.is_empty() {
            return Err(ConfigError::InvalidValue {
                key: "host".to_string(),
                message: "Host cannot be empty".to_string(),
            });
        }

        // Validate host is a valid IP address
        if IpAddr::from_str(&self.host).is_err() {
            return Err(ConfigError::InvalidValue {
                key: "host".to_string(),
                message: format!("Invalid IP address: {}", self.host),
            });
        }

        Ok(())
    }
}

impl ConfigLoader for DatabaseConfig {
    fn from_env() -> ConfigResult<Self> {
        Self::from_env(None)
    }

    fn validate(&self) -> ConfigResult<()> {
        if self.url.is_empty() {
            return Err(ConfigError::InvalidValue {
                key: "url".to_string(),
                message: "Database URL cannot be empty".to_string(),
            });
        }

        if self.max_connections == 0 {
            return Err(ConfigError::InvalidValue {
                key: "max_connections".to_string(),
                message: "Max connections must be greater than 0".to_string(),
            });
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::env;

    #[test]
    fn test_server_config_defaults() {
        // Clear environment
        unsafe {
            env::remove_var("SERVER_HOST");
            env::remove_var("SERVER_PORT");
            env::remove_var("CORS_ORIGINS");
        }

        let config = ServerConfig::from_env().unwrap();
        assert_eq!(config.host, "0.0.0.0");
        assert_eq!(config.port, 8080);
        assert_eq!(config.cors_origins, vec!["http://localhost:3000"]);
    }

    #[test]
    fn test_server_config_custom_values() {
        // Cleanup any existing values first
        unsafe {
            env::remove_var("SERVER_HOST");
            env::remove_var("SERVER_PORT");
            env::remove_var("CORS_ORIGINS");
        }

        unsafe {
            env::set_var("SERVER_HOST", "127.0.0.1");
            env::set_var("SERVER_PORT", "3000");
            env::set_var("CORS_ORIGINS", "http://example.com,http://test.com");
        }

        let config = ServerConfig::from_env().unwrap();
        assert_eq!(config.host, "127.0.0.1");
        assert_eq!(config.port, 3000);
        assert_eq!(
            config.cors_origins,
            vec!["http://example.com", "http://test.com"]
        );

        // Cleanup
        unsafe {
            env::remove_var("SERVER_HOST");
            env::remove_var("SERVER_PORT");
            env::remove_var("CORS_ORIGINS");
        }
    }

    #[test]
    fn test_server_config_bind_addr() {
        let config = ServerConfig {
            host: "127.0.0.1".to_string(),
            port: 8080,
            cors_origins: vec![],
        };

        let addr = config.bind_addr().unwrap();
        assert_eq!(addr.to_string(), "127.0.0.1:8080");
    }

    #[test]
    fn test_server_config_validation() {
        let mut config = ServerConfig {
            host: "127.0.0.1".to_string(),
            port: 8080,
            cors_origins: vec![],
        };

        assert!(config.validate().is_ok());

        config.port = 0;
        assert!(config.validate().is_err());

        config.port = 8080;
        config.host = "invalid".to_string();
        assert!(config.validate().is_err());
    }

    #[test]
    fn test_database_config_from_env() {
        unsafe {
            env::set_var("DATABASE_URL", "postgres://localhost/test");
            env::set_var("DATABASE_MAX_CONNECTIONS", "20");
            env::set_var("DATABASE_CONNECT_TIMEOUT", "60");
        }

        let config = DatabaseConfig::from_env(None).unwrap();
        assert_eq!(config.url, "postgres://localhost/test");
        assert_eq!(config.max_connections, 20);
        assert_eq!(config.connect_timeout, 60);

        // Cleanup
        unsafe {
            env::remove_var("DATABASE_URL");
            env::remove_var("DATABASE_MAX_CONNECTIONS");
            env::remove_var("DATABASE_CONNECT_TIMEOUT");
        }
    }

    #[test]
    fn test_database_config_missing_url() {
        unsafe {
            env::remove_var("DATABASE_URL");
        }

        let result = DatabaseConfig::from_env(None);
        assert!(result.is_err());
        assert!(matches!(result.unwrap_err(), ConfigError::MissingEnvVar(_)));
    }

    #[test]
    fn test_database_config_validation() {
        let mut config = DatabaseConfig {
            url: "postgres://localhost/test".to_string(),
            max_connections: 10,
            connect_timeout: 30,
        };

        assert!(config.validate().is_ok());

        config.url = String::new();
        assert!(config.validate().is_err());

        config.url = "postgres://localhost/test".to_string();
        config.max_connections = 0;
        assert!(config.validate().is_err());
    }
}