fraiseql-cli 2.18.0

CLI tools for FraiseQL v2 - Schema compilation and development utilities
Documentation
//! Runtime configuration for the HTTP server and database connection pool.
//!
//! These structs are shared between `FraiseQLConfig` (Workflow B: JSON + fraiseql.toml)
//! and `TomlSchema` (Workflow A: TOML-only).  All fields have sensible defaults so
//! existing `fraiseql.toml` files without `[server]` or `[database]` sections continue
//! to work unchanged.

use anyhow::Result;
use fraiseql_core::db::postgres::{PostgresSslMode, PostgresTlsConfig};
use serde::{Deserialize, Serialize};

// ─── TLS ─────────────────────────────────────────────────────────────────────

/// TLS/HTTPS configuration for the HTTP server.
///
/// ```toml
/// [server.tls]
/// enabled  = true
/// cert_file = "/etc/fraiseql/cert.pem"
/// key_file  = "/etc/fraiseql/key.pem"
/// min_version = "1.2"   # "1.2" or "1.3"
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct TlsRuntimeConfig {
    /// Enable TLS (HTTPS).  Default: `false`.
    pub enabled: bool,

    /// Path to the PEM-encoded certificate file.
    pub cert_file: String,

    /// Path to the PEM-encoded private key file.
    pub key_file: String,

    /// Minimum TLS version: `"1.2"` (default) or `"1.3"`.
    pub min_version: String,
}

impl Default for TlsRuntimeConfig {
    fn default() -> Self {
        Self {
            enabled:     false,
            cert_file:   String::new(),
            key_file:    String::new(),
            min_version: "1.2".to_string(),
        }
    }
}

// ─── CORS ────────────────────────────────────────────────────────────────────

/// CORS configuration for the HTTP server.
///
/// ```toml
/// [server.cors]
/// origins     = ["https://app.example.com"]
/// credentials = true
/// ```
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct CorsRuntimeConfig {
    /// Allowed origins.  Empty list → all origins allowed (development default).
    pub origins: Vec<String>,

    /// Allow credentials (cookies, `Authorization` header).  Default: `false`.
    pub credentials: bool,
}

// ─── Server ──────────────────────────────────────────────────────────────────

/// HTTP server runtime configuration.
///
/// The `[server]` section in `fraiseql.toml` is **optional**.  When absent,
/// the server listens on `0.0.0.0:8080` with no TLS and permissive CORS
/// (suitable for local development).
///
/// CLI flags (`--port`, `--bind`) take precedence over these settings.
///
/// # Example
///
/// ```toml
/// [server]
/// host               = "127.0.0.1"
/// port               = 9000
/// request_timeout_ms = 30_000
/// keep_alive_secs    = 75
///
/// [server.cors]
/// origins     = ["https://app.example.com"]
/// credentials = true
///
/// [server.tls]
/// enabled   = true
/// cert_file = "/etc/fraiseql/cert.pem"
/// key_file  = "/etc/fraiseql/key.pem"
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ServerRuntimeConfig {
    /// Bind host.  Default: `"0.0.0.0"`.
    pub host: String,

    /// TCP port.  Default: `8080`.
    pub port: u16,

    /// Request timeout in milliseconds.  Default: `30 000` (30 s).
    pub request_timeout_ms: u64,

    /// TCP keep-alive in seconds.  Default: `75`.
    pub keep_alive_secs: u64,

    /// CORS settings.
    pub cors: CorsRuntimeConfig,

    /// TLS settings.
    pub tls: TlsRuntimeConfig,
}

impl Default for ServerRuntimeConfig {
    fn default() -> Self {
        Self {
            host:               "0.0.0.0".to_string(),
            port:               8080,
            request_timeout_ms: 30_000,
            keep_alive_secs:    75,
            cors:               CorsRuntimeConfig::default(),
            tls:                TlsRuntimeConfig::default(),
        }
    }
}

impl ServerRuntimeConfig {
    /// Validate the server runtime configuration.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - `port` is zero
    /// - `tls.enabled` but `cert_file` or `key_file` is empty
    /// - `tls.min_version` is not `"1.2"` or `"1.3"`
    pub fn validate(&self) -> Result<()> {
        if self.port == 0 {
            anyhow::bail!("[server] port must be non-zero");
        }

        if self.tls.enabled {
            if self.tls.cert_file.is_empty() {
                anyhow::bail!("[server.tls] cert_file is required when tls.enabled = true");
            }
            if self.tls.key_file.is_empty() {
                anyhow::bail!("[server.tls] key_file is required when tls.enabled = true");
            }
            if self.tls.min_version != "1.2" && self.tls.min_version != "1.3" {
                anyhow::bail!(
                    "[server.tls] min_version must be \"1.2\" or \"1.3\", got \"{}\"",
                    self.tls.min_version
                );
            }
        }

        Ok(())
    }
}

// ─── Database ────────────────────────────────────────────────────────────────

/// Database connection pool runtime configuration.
///
/// The `[database]` section in `fraiseql.toml` is **optional**.  When absent,
/// connection parameters fall back to the `DATABASE_URL` environment variable
/// or the `--database` CLI flag.
///
/// Supports `${VAR}` environment variable interpolation in the `url` field:
///
/// ```toml
/// [database]
/// url      = "${DATABASE_URL}"
/// pool_min = 2
/// pool_max = 20
/// ssl_mode = "prefer"
/// ```
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct DatabaseRuntimeConfig {
    /// Database connection URL.  Supports `${VAR}` interpolation.
    ///
    /// If not set here, the runtime falls back to the `DATABASE_URL` environment
    /// variable or the `--database` CLI flag.
    pub url: Option<String>,

    /// Minimum connection pool size.  Default: `2`.
    pub pool_min: usize,

    /// Maximum connection pool size.  Default: `20`.
    pub pool_max: usize,

    /// Connection acquisition timeout in milliseconds.  Default: `5 000` (5 s).
    pub connect_timeout_ms: u64,

    /// Idle connection lifetime in milliseconds.  Default: `600 000` (10 min).
    pub idle_timeout_ms: u64,

    /// PostgreSQL SSL mode: `"disable"`, `"prefer"`, `"require"`, or
    /// `"verify-full"`. Default: `"prefer"`.
    ///
    /// Reaches the connection pool through
    /// [`postgres_tls`](Self::postgres_tls). It was previously validated here and
    /// then dropped by `build_config_from`, so `fraiseql run` connected in
    /// cleartext under `ssl_mode = "require"` while a typo in the same field
    /// failed loudly — the rejected typo being the reason an operator believed the
    /// setting was live (#824).
    ///
    /// Unset means "whatever the connection URL says", not "prefer": a concrete
    /// default here would override an explicit `?sslmode=require` in `url` with a
    /// value the operator never wrote.
    pub ssl_mode: Option<String>,
}

impl Default for DatabaseRuntimeConfig {
    fn default() -> Self {
        Self {
            url:                None,
            pool_min:           2,
            pool_max:           20,
            connect_timeout_ms: 5_000,
            idle_timeout_ms:    600_000,
            ssl_mode:           None,
        }
    }
}

impl DatabaseRuntimeConfig {
    /// Validate the database runtime configuration.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - `pool_min > pool_max`
    /// - `ssl_mode` is not one of the recognised values
    pub fn validate(&self) -> Result<()> {
        if self.pool_min > self.pool_max {
            anyhow::bail!(
                "[database] pool_min ({}) must be <= pool_max ({})",
                self.pool_min,
                self.pool_max
            );
        }

        if self.connect_timeout_ms == 0 {
            anyhow::bail!(
                "[database] connect_timeout_ms must be greater than 0; a zero timeout makes \
                 every connection attempt fail immediately"
            );
        }

        // Validated by parsing rather than against a local allow-list. The list here
        // accepted "allow", which no connector can honour — so validation succeeding
        // told the operator nothing about whether the mode could take effect (#824).
        self.postgres_tls()?;

        Ok(())
    }

    /// The `[database]` section of a parsed `fraiseql.toml`, or the default when the file
    /// has none.
    ///
    /// Reads that one section rather than the whole document, so it serves any
    /// `fraiseql.toml` a command was given, whichever other sections it carries.
    ///
    /// # Errors
    ///
    /// Returns an error if `[database]` holds a key this struct does not know or a value
    /// of the wrong type.
    pub fn from_document(document: &toml::Value) -> Result<Self> {
        document.get("database").map_or_else(
            || Ok(Self::default()),
            |section| section.clone().try_into().map_err(|e| anyhow::anyhow!("[database]: {e}")),
        )
    }

    /// Transport security for the pool `fraiseql run` builds.
    ///
    /// # Errors
    ///
    /// Returns an error if `ssl_mode` names a mode the connector cannot honour.
    pub fn postgres_tls(&self) -> Result<PostgresTlsConfig> {
        let mode: Option<PostgresSslMode> =
            self.ssl_mode.as_deref().map(str::parse).transpose().map_err(
                |e: fraiseql_core::error::FraiseQLError| {
                    anyhow::anyhow!("[database] ssl_mode: {e}")
                },
            )?;
        Ok(PostgresTlsConfig {
            mode,
            ca_bundle_path: None,
        })
    }

    /// Pool acquisition timeout in whole seconds, rounded up and floored at one.
    ///
    /// `connect_timeout_ms / 1000` truncated any sub-second value to `0`, which
    /// deadpool applies to its `create` slot as `Duration::ZERO`; a TCP connect plus
    /// the PostgreSQL startup handshake cannot finish on the first poll, so every
    /// connection attempt failed with a pool timeout against a perfectly healthy
    /// server, naming a timeout the operator never configured (#824).
    #[must_use]
    pub const fn pool_timeout_secs(&self) -> u64 {
        let secs = self.connect_timeout_ms.div_ceil(1_000);
        if secs == 0 { 1 } else { secs }
    }
}