noema-actix-webapi 0.1.0

Actix-web backend runtime on Noema (modules, sqlx, UoW, swagger, WebSocket dispatch)
use std::sync::OnceLock;

use noema::core::{Container, Injectable};

/// HTTP bind address.
#[derive(Debug, Clone)]
pub struct HttpConfig {
    pub host: String,
    pub port: u16,
    pub env: Environment,
}

impl HttpConfig {
    pub fn bind_addr(&self) -> String {
        format!("{}:{}", self.host, self.port)
    }
}

impl Default for HttpConfig {
    fn default() -> Self {
        Self {
            host: "127.0.0.1".into(),
            port: 8080,
            env: Environment::Production,
        }
    }
}

/// Process environment for leaky diagnostics (sqlx `details`, `/ready` body).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Environment {
    Development,
    Production,
}

impl Environment {
    pub fn parse(raw: &str) -> Self {
        match raw.trim().to_ascii_lowercase().as_str() {
            "development" | "dev" => Self::Development,
            _ => Self::Production,
        }
    }

    pub fn exposes_infra_errors(self) -> bool {
        matches!(self, Self::Development)
    }

    pub fn current() -> Self {
        if let Some(env) = OVERRIDE.with(|c| c.get()) {
            return env;
        }
        INSTALLED.get().copied().unwrap_or(Self::Production)
    }
}

thread_local! {
    static OVERRIDE: std::cell::Cell<Option<Environment>> = const { std::cell::Cell::new(None) };
}

static INSTALLED: OnceLock<Environment> = OnceLock::new();
static SETTINGS: OnceLock<ApplicationConfig> = OnceLock::new();

pub(crate) fn install_environment(env: Environment) {
    let _ = INSTALLED.set(env);
}

pub(crate) fn install(cfg: ApplicationConfig) {
    let _ = SETTINGS.set(cfg);
}

#[cfg(test)]
pub(crate) fn with_environment<R>(env: Environment, f: impl FnOnce() -> R) -> R {
    OVERRIDE.with(|c| {
        let prev = c.replace(Some(env));
        let out = f();
        c.set(prev);
        out
    })
}

/// Pool size only. The URL comes from the `ConfigSource` key `DATABASE_URL`.
#[derive(Debug, Clone)]
pub struct DatabasePoolConfig {
    pub max_connections: u32,
    pub acquire_timeout_ms: u32,
}

impl Default for DatabasePoolConfig {
    fn default() -> Self {
        Self {
            max_connections: 32,
            acquire_timeout_ms: 5000,
        }
    }
}

/// URL from the source plus pool settings. Used by [`crate::db::start`].
#[derive(Debug, Clone)]
pub struct DatabaseConfig {
    pub url: String,
    pub max_connections: u32,
    pub acquire_timeout_ms: u32,
}

/// Subscriber for [`crate::logger::TracingLogger`].
#[derive(Debug, Clone)]
pub struct LogConfig {
    pub level: String,
    pub file: Option<String>,
    pub stdout: bool,
    pub json: bool,
}

impl Default for LogConfig {
    fn default() -> Self {
        Self {
            level: "info".into(),
            file: None,
            stdout: true,
            json: false,
        }
    }
}

/// Timeouts and pool for the process [`crate::HttpClient`].
#[derive(Debug, Clone)]
pub struct HttpClientConfig {
    pub timeout_ms: u32,
    pub connect_timeout_ms: u32,
    pub pool_max_idle_per_host: u32,
    pub pool_idle_timeout_secs: Option<u32>,
    pub user_agent: String,
    /// Max concurrent `send`s. `0` = unlimited. Fail-fast when full (no retry, same client).
    pub max_in_flight: u32,
}

impl Default for HttpClientConfig {
    fn default() -> Self {
        Self {
            timeout_ms: 30_000,
            connect_timeout_ms: 10_000,
            pool_max_idle_per_host: 32,
            pool_idle_timeout_secs: None,
            user_agent: "noema-actix-webapi".into(),
            max_in_flight: 64,
        }
    }
}

/// Browser CORS. Used by [`crate::cors`] / [`crate::cors_from`], not `Application::configure`.
///
/// Empty `origins` allows none. `"*"` in the list allows any origin (credentials are skipped).
#[derive(Debug, Clone)]
pub struct CorsConfig {
    pub origins: Vec<String>,
    pub methods: Vec<String>,
    pub headers: Vec<String>,
    pub credentials: bool,
    pub max_age: u32,
}

impl Default for CorsConfig {
    fn default() -> Self {
        Self {
            origins: Vec::new(),
            methods: vec![
                "GET".into(),
                "POST".into(),
                "PUT".into(),
                "PATCH".into(),
                "DELETE".into(),
                "OPTIONS".into(),
            ],
            headers: vec![
                "Authorization".into(),
                "Content-Type".into(),
                "Accept".into(),
                "Idempotency-Key".into(),
            ],
            credentials: false,
            max_age: 3600,
        }
    }
}

/// Process settings for [`crate::start`]. Installed once; `resolve::<ApplicationConfig>()`.
///
/// `DATABASE_URL` is not a field — it comes from the `ConfigSource` passed to `start`.
#[derive(Debug, Clone, Default)]
pub struct ApplicationConfig {
    pub http: HttpConfig,
    pub log: LogConfig,
    pub cors: CorsConfig,
    pub http_client: HttpClientConfig,
    pub database: DatabasePoolConfig,
}

impl Injectable<Container> for ApplicationConfig {
    fn inject(_: &Container) -> Self {
        SETTINGS
            .get()
            .expect("ApplicationConfig not installed; call Application::start")
            .clone()
    }
}

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

    #[test]
    fn cors_default_allows_no_origins() {
        assert!(CorsConfig::default().origins.is_empty());
    }
}