shared-framework 0.0.17

Reusable building blocks for HTTP services — Hyper routing, SeaORM data layer, validation, OpenAPI docs, jobs, queues, cache.
Documentation
//! Process-wide service configuration loaded from dotenv files and the environment.
//!
//! [`AppEnvironment`] is a singleton (backed by a `OnceCell`): provision it once
//! with [`AppEnvironment::with_env_file`], then read it with
//! [`AppEnvironment::get`] or [`AppEnvironment::try_get`]. It carries service
//! identity (`SERVICE_NAME`, `SERVICE_VERSION`, `BUILD_NUMBER`), connection URLs
//! for Postgres/Redis/RabbitMQ/MongoDB, ports and replica counts, and the
//! [`EnvironmentKind`], [`ProcessRole`], and [`SetupMode`] selectors.
//!
//! ```ignore
//! AppEnvironment::with_env_file(None)?;
//! let env = AppEnvironment::get();
//! let pg_url = env.pg_url.clone();
//! ```

use once_cell::sync::OnceCell;
use std::collections::HashMap;
use thiserror::Error;

/// Deployment environment selector, parsed from `ENVIRONMENT_KIND` (case-insensitive).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnvironmentKind {
    /// Local development environment.
    Development,
    /// Debug environment.
    Debug,
    /// Staging environment.
    Staging,
    /// Production environment; enables production-only behaviour such as quieter error output.
    Production,
}

impl std::str::FromStr for EnvironmentKind {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_uppercase().as_str() {
            "DEVELOPMENT" => Ok(Self::Development),
            "DEBUG" => Ok(Self::Debug),
            "STAGING" => Ok(Self::Staging),
            "PRODUCTION" => Ok(Self::Production),
            other => Err(format!("invalid ENVIRONMENT_KIND: {other}")),
        }
    }
}

/// Role of this process, parsed from `PROCESS_ROLE` (case-insensitive, defaults to `"WORKER"`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessRole {
    /// Background worker process.
    Worker,
    /// Queue consumer process.
    Consumer,
    /// HTTP API server process.
    ApiServer,
    /// Streaming process.
    Stream,
}

impl std::str::FromStr for ProcessRole {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_uppercase().as_str() {
            "WORKER" => Ok(Self::Worker),
            "CONSUMER" => Ok(Self::Consumer),
            "API_SERVER" => Ok(Self::ApiServer),
            "STREAM" => Ok(Self::Stream),
            other => Err(format!("invalid PROCESS_ROLE: {other}")),
        }
    }
}

/// Setup selector, parsed from `SETUP_MODE` (case-insensitive, defaults to `"PRODUCTION"`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetupMode {
    /// Full production setup.
    Production,
    /// Partial setup.
    Partial,
    /// Local setup.
    Local,
}

impl std::str::FromStr for SetupMode {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_uppercase().as_str() {
            "PRODUCTION" => Ok(Self::Production),
            "PARTIAL" => Ok(Self::Partial),
            "LOCAL" => Ok(Self::Local),
            other => Err(format!("invalid SETUP_MODE: {other}")),
        }
    }
}

/// PostgreSQL SSL mode selector.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DatabaseSslMode {
    /// No SSL.
    Disable,
    /// Require SSL without certificate verification.
    Require,
    /// Verify the server certificate against a CA.
    VerifyCa,
    /// Verify the server certificate and hostname.
    VerifyFull,
}

/// Loaded service configuration. Obtain via [`AppEnvironment::get`] after provisioning.
///
/// Required keys are `SERVICE_NAME`, `SERVICE_VERSION`, `BUILD_NUMBER`,
/// `ENVIRONMENT_KIND`, `SERVER_PORT`, `SERVICE_URL`, `POSTGRESQL_URL`,
/// `REDIS_URL`, `RABBITMQ_URL`, and `MONGODB_URL`; loading fails with
/// [`EnvError::MissingKeys`] when any are absent.
#[derive(Debug, Clone)]
pub struct AppEnvironment {
    /// Service name from `SERVICE_NAME`.
    pub name: String,
    /// Service version from `SERVICE_VERSION`.
    pub version_code: String,
    /// Build number from `BUILD_NUMBER`.
    pub build_number: i32,
    /// Whether SQL statements are logged, from `LOG_SQL` (defaults to `false`).
    pub log_sql: bool,
    /// Number of HTTP server instances, from `SERVER_COUNT` (defaults to `1`).
    pub server_count: usize,
    /// Number of worker instances, from `WORKER_COUNT` (defaults to `0`).
    pub worker_count: usize,
    /// Number of socket server instances, from `SOCKET_COUNT` (defaults to `0`).
    pub socket_count: usize,
    /// Postgres connection URL from `POSTGRESQL_URL`.
    pub pg_url: String,
    /// Redis connection URL from `REDIS_URL`.
    pub redis_url: String,
    /// RabbitMQ connection URL from `RABBITMQ_URL`.
    pub rabbitmq_url: String,
    /// RabbitMQ virtual host from `RABBITMQ_VHOST` (defaults to `"/"`).
    pub rabbitmq_vhost: String,
    /// MongoDB connection URL from `MONGODB_URL`.
    pub mongodb_url: String,
    /// Elasticsearch URL from `ELASTICSEARCH_URL` (defaults to `"<none>"`).
    pub es_url: String,
    /// Elasticsearch API key from `ELASTICSEARCH_API_KEY` (defaults to `"<none>"`).
    pub es_api_key: String,
    /// HTTP listen port from `SERVER_PORT`.
    pub server_port: u16,
    /// Socket listen port from `SOCKET_PORT` (defaults to `8081`).
    pub socket_port: u16,
    /// Queue prefetch count from `PREFETCH_COUNT` (defaults to `1`).
    pub prefetch_count: usize,
    /// Public service URL from `SERVICE_URL`.
    pub url: String,
    /// Setup selector from `SETUP_MODE` (defaults to production).
    pub setup_mode: SetupMode,
    /// Deployment environment selector from `ENVIRONMENT_KIND`.
    pub kind: EnvironmentKind,
    /// Process role from `PROCESS_ROLE` (defaults to worker).
    pub process_role: ProcessRole,
    config_map: HashMap<String, String>,
}

/// Errors returned when loading or querying [`AppEnvironment`].
#[derive(Debug, Error)]
pub enum EnvError {
    /// One or more required keys were absent. Carries the comma-joined key list.
    #[error("missing required environment variables: {0}")]
    MissingKeys(String),
    /// A present key failed to parse. Carries the key and the reason.
    #[error("invalid value for {key}: {msg}")]
    InvalidValue {
        /// The offending key.
        key: String,
        /// Why its value was rejected.
        msg: String,
    },
    /// Dotenv loading or singleton provisioning failed.
    #[error("dotenv error: {0}")]
    Dotenv(String),
}

static INSTANCE: OnceCell<AppEnvironment> = OnceCell::new();

impl AppEnvironment {
    /// Provisions the singleton from the given dotenv file, or from `.env` plus the
    /// process environment when `path` is `None`. Idempotent: no-ops when already provisioned.
    ///
    /// Returns [`EnvError::MissingKeys`] when a required key is absent, or
    /// [`EnvError::InvalidValue`] when a value fails to parse.
    pub fn with_env_file(path: Option<&str>) -> Result<(), EnvError> {
        if INSTANCE.get().is_some() {
            return Ok(());
        }
        let env = Self::load(path)?;
        INSTANCE.set(env).map_err(|_| EnvError::Dotenv("already initialised".into()))?;
        Ok(())
    }

    /// Provisions the singleton from an explicit map (for tests). No-ops when already provisioned.
    #[cfg(test)]
    pub fn init_for_test(map: HashMap<String, String>) -> Result<(), EnvError> {
        let env = Self::from_map(map)?;
        let _ = INSTANCE.set(env);
        Ok(())
    }

    /// Returns the provisioned environment.
    ///
    /// Panics when [`AppEnvironment::with_env_file`] has not run yet.
    pub fn get() -> &'static Self {
        INSTANCE.get().expect("AppEnvironment has not been provisioned yet — call AppEnvironment::with_env_file first")
    }

    /// Returns the provisioned environment, or `None` when not yet provisioned.
    pub fn try_get() -> Option<&'static Self> {
        INSTANCE.get()
    }

    /// Reports whether the environment kind is production.
    pub fn is_production(&self) -> bool {
        self.kind == EnvironmentKind::Production
    }

    /// Reports whether SQL statement logging is enabled (`LOG_SQL`).
    pub fn should_log_sql(&self) -> bool {
        self.log_sql
    }

    /// Looks up `key` in the loaded map, falling back to the process environment. Returns `None` when absent in both.
    pub fn get_value(&self, key: &str) -> Option<String> {
        if let Some(v) = self.config_map.get(key) {
            return Some(v.clone());
        }
        std::env::var(key).ok()
    }

    /// Looks up `key` as in [`AppEnvironment::get_value`]. Returns [`EnvError::MissingKeys`] when absent in both.
    pub fn get_required(&self, key: &str) -> Result<String, EnvError> {
        self.get_value(key).ok_or_else(|| EnvError::MissingKeys(key.to_string()))
    }

    // ── internal ──────────────────────────────────────────────────────────

    fn load(path: Option<&str>) -> Result<Self, EnvError> {
        let mut config_map: HashMap<String, String> = HashMap::new();

        // Try dotenv
        let dotenv_result = if let Some(p) = path {
            dotenvy::from_filename(p).ok()
        } else {
            dotenvy::dotenv().ok()
        };
        let _ = dotenv_result;

        // dotenvy populates std::env; also collect file entries if available
        // We collect by reading the file manually for config_map fidelity
        if let Some(p) = path {
            if let Ok(content) = std::fs::read_to_string(p) {
                for line in content.lines() {
                    let line = line.trim();
                    if line.is_empty() || line.starts_with('#') {
                        continue;
                    }
                    if let Some((k, v)) = line.split_once('=') {
                        let k = k.trim().to_string();
                        let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
                        config_map.insert(k, v);
                    }
                }
            }
        } else if let Ok(content) = std::fs::read_to_string(".env") {
            for line in content.lines() {
                let line = line.trim();
                if line.is_empty() || line.starts_with('#') {
                    continue;
                }
                if let Some((k, v)) = line.split_once('=') {
                    let k = k.trim().to_string();
                    let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
                    config_map.insert(k, v);
                }
            }
        }

        // Also merge any env vars already in process (dotenvy already did, but ensure coverage)
        for (k, v) in std::env::vars() {
            config_map.entry(k).or_insert(v);
        }

        Self::from_map(config_map)
    }

    fn from_map(map: HashMap<String, String>) -> Result<Self, EnvError> {
        let get_req = |key: &str| -> Result<String, EnvError> {
            map.get(key)
                .cloned()
                .or_else(|| std::env::var(key).ok())
                .ok_or_else(|| EnvError::MissingKeys(key.to_string()))
        };
        let get_or = |key: &str, default: &str| -> String {
            map.get(key)
                .cloned()
                .or_else(|| std::env::var(key).ok())
                .unwrap_or_else(|| default.to_string())
        };

        let mut missing = Vec::new();
        let required_keys = [
            "SERVICE_NAME",
            "SERVICE_VERSION",
            "BUILD_NUMBER",
            "ENVIRONMENT_KIND",
            "SERVER_PORT",
            "SERVICE_URL",
            "POSTGRESQL_URL",
            "REDIS_URL",
            "RABBITMQ_URL",
            "MONGODB_URL",
        ];
        for k in required_keys {
            if map.get(k).is_none() && std::env::var(k).is_err() {
                missing.push(k.to_string());
            }
        }
        if !missing.is_empty() {
            return Err(EnvError::MissingKeys(missing.join(", ")));
        }

        let name = get_req("SERVICE_NAME")?;
        let version_code = get_req("SERVICE_VERSION")?;
        let build_number: i32 = get_req("BUILD_NUMBER")?
            .parse()
            .map_err(|_| EnvError::InvalidValue { key: "BUILD_NUMBER".into(), msg: "not an integer".into() })?;
        let server_port: u16 = get_req("SERVER_PORT")?
            .parse()
            .map_err(|_| EnvError::InvalidValue { key: "SERVER_PORT".into(), msg: "not a port".into() })?;
        let url = get_req("SERVICE_URL")?;
        let pg_url = get_req("POSTGRESQL_URL")?;
        let redis_url = get_req("REDIS_URL")?;
        let rabbitmq_url = get_req("RABBITMQ_URL")?;
        let mongodb_url = get_req("MONGODB_URL")?;

        let kind: EnvironmentKind = get_req("ENVIRONMENT_KIND")?
            .parse()
            .map_err(|e| EnvError::InvalidValue { key: "ENVIRONMENT_KIND".into(), msg: e })?;
        let process_role: ProcessRole = get_or("PROCESS_ROLE", "WORKER")
            .parse()
            .map_err(|e| EnvError::InvalidValue { key: "PROCESS_ROLE".into(), msg: e })?;
        let setup_mode: SetupMode = get_or("SETUP_MODE", "PRODUCTION")
            .parse()
            .map_err(|e| EnvError::InvalidValue { key: "SETUP_MODE".into(), msg: e })?;

        let log_sql: bool = get_or("LOG_SQL", "false").parse().unwrap_or(false);
        let server_count: usize = get_or("SERVER_COUNT", "1").parse().unwrap_or(1);
        let worker_count: usize = get_or("WORKER_COUNT", "0").parse().unwrap_or(0);
        let socket_count: usize = get_or("SOCKET_COUNT", "0").parse().unwrap_or(0);
        let socket_port: u16 = get_or("SOCKET_PORT", "8081").parse().unwrap_or(8081);
        let prefetch_count: usize = get_or("PREFETCH_COUNT", "1").parse().unwrap_or(1);
        let es_url = get_or("ELASTICSEARCH_URL", "<none>");
        let es_api_key = get_or("ELASTICSEARCH_API_KEY", "<none>");
        let rabbitmq_vhost = get_or("RABBITMQ_VHOST", "/");

        Ok(Self {
            name,
            version_code,
            build_number,
            log_sql,
            server_count,
            worker_count,
            socket_count,
            pg_url,
            redis_url,
            rabbitmq_url,
            rabbitmq_vhost,
            mongodb_url,
            es_url,
            es_api_key,
            server_port,
            socket_port,
            prefetch_count,
            url,
            setup_mode,
            kind,
            process_role,
            config_map: map,
        })
    }
}