Skip to main content

fraiseql_cli/config/
runtime.rs

1//! Runtime configuration for the HTTP server and database connection pool.
2//!
3//! These structs are shared between `FraiseQLConfig` (Workflow B: JSON + fraiseql.toml)
4//! and `TomlSchema` (Workflow A: TOML-only).  All fields have sensible defaults so
5//! existing `fraiseql.toml` files without `[server]` or `[database]` sections continue
6//! to work unchanged.
7
8use anyhow::Result;
9use fraiseql_core::db::postgres::{PostgresSslMode, PostgresTlsConfig};
10use serde::{Deserialize, Serialize};
11
12// ─── TLS ─────────────────────────────────────────────────────────────────────
13
14/// TLS/HTTPS configuration for the HTTP server.
15///
16/// ```toml
17/// [server.tls]
18/// enabled  = true
19/// cert_file = "/etc/fraiseql/cert.pem"
20/// key_file  = "/etc/fraiseql/key.pem"
21/// min_version = "1.2"   # "1.2" or "1.3"
22/// ```
23#[derive(Debug, Clone, Deserialize, Serialize)]
24#[serde(default, deny_unknown_fields)]
25pub struct TlsRuntimeConfig {
26    /// Enable TLS (HTTPS).  Default: `false`.
27    pub enabled: bool,
28
29    /// Path to the PEM-encoded certificate file.
30    pub cert_file: String,
31
32    /// Path to the PEM-encoded private key file.
33    pub key_file: String,
34
35    /// Minimum TLS version: `"1.2"` (default) or `"1.3"`.
36    pub min_version: String,
37}
38
39impl Default for TlsRuntimeConfig {
40    fn default() -> Self {
41        Self {
42            enabled:     false,
43            cert_file:   String::new(),
44            key_file:    String::new(),
45            min_version: "1.2".to_string(),
46        }
47    }
48}
49
50// ─── CORS ────────────────────────────────────────────────────────────────────
51
52/// CORS configuration for the HTTP server.
53///
54/// ```toml
55/// [server.cors]
56/// origins     = ["https://app.example.com"]
57/// credentials = true
58/// ```
59#[derive(Debug, Clone, Default, Deserialize, Serialize)]
60#[serde(default, deny_unknown_fields)]
61pub struct CorsRuntimeConfig {
62    /// Allowed origins.  Empty list → all origins allowed (development default).
63    pub origins: Vec<String>,
64
65    /// Allow credentials (cookies, `Authorization` header).  Default: `false`.
66    pub credentials: bool,
67}
68
69// ─── Server ──────────────────────────────────────────────────────────────────
70
71/// HTTP server runtime configuration.
72///
73/// The `[server]` section in `fraiseql.toml` is **optional**.  When absent,
74/// the server listens on `0.0.0.0:8080` with no TLS and permissive CORS
75/// (suitable for local development).
76///
77/// CLI flags (`--port`, `--bind`) take precedence over these settings.
78///
79/// # Example
80///
81/// ```toml
82/// [server]
83/// host               = "127.0.0.1"
84/// port               = 9000
85/// request_timeout_ms = 30_000
86/// keep_alive_secs    = 75
87///
88/// [server.cors]
89/// origins     = ["https://app.example.com"]
90/// credentials = true
91///
92/// [server.tls]
93/// enabled   = true
94/// cert_file = "/etc/fraiseql/cert.pem"
95/// key_file  = "/etc/fraiseql/key.pem"
96/// ```
97#[derive(Debug, Clone, Deserialize, Serialize)]
98#[serde(default, deny_unknown_fields)]
99pub struct ServerRuntimeConfig {
100    /// Bind host.  Default: `"0.0.0.0"`.
101    pub host: String,
102
103    /// TCP port.  Default: `8080`.
104    pub port: u16,
105
106    /// Request timeout in milliseconds.  Default: `30 000` (30 s).
107    pub request_timeout_ms: u64,
108
109    /// TCP keep-alive in seconds.  Default: `75`.
110    pub keep_alive_secs: u64,
111
112    /// CORS settings.
113    pub cors: CorsRuntimeConfig,
114
115    /// TLS settings.
116    pub tls: TlsRuntimeConfig,
117}
118
119impl Default for ServerRuntimeConfig {
120    fn default() -> Self {
121        Self {
122            host:               "0.0.0.0".to_string(),
123            port:               8080,
124            request_timeout_ms: 30_000,
125            keep_alive_secs:    75,
126            cors:               CorsRuntimeConfig::default(),
127            tls:                TlsRuntimeConfig::default(),
128        }
129    }
130}
131
132impl ServerRuntimeConfig {
133    /// Validate the server runtime configuration.
134    ///
135    /// # Errors
136    ///
137    /// Returns an error if:
138    /// - `port` is zero
139    /// - `tls.enabled` but `cert_file` or `key_file` is empty
140    /// - `tls.min_version` is not `"1.2"` or `"1.3"`
141    pub fn validate(&self) -> Result<()> {
142        if self.port == 0 {
143            anyhow::bail!("[server] port must be non-zero");
144        }
145
146        if self.tls.enabled {
147            if self.tls.cert_file.is_empty() {
148                anyhow::bail!("[server.tls] cert_file is required when tls.enabled = true");
149            }
150            if self.tls.key_file.is_empty() {
151                anyhow::bail!("[server.tls] key_file is required when tls.enabled = true");
152            }
153            if self.tls.min_version != "1.2" && self.tls.min_version != "1.3" {
154                anyhow::bail!(
155                    "[server.tls] min_version must be \"1.2\" or \"1.3\", got \"{}\"",
156                    self.tls.min_version
157                );
158            }
159        }
160
161        Ok(())
162    }
163}
164
165// ─── Database ────────────────────────────────────────────────────────────────
166
167/// Database connection pool runtime configuration.
168///
169/// The `[database]` section in `fraiseql.toml` is **optional**.  When absent,
170/// connection parameters fall back to the `DATABASE_URL` environment variable
171/// or the `--database` CLI flag.
172///
173/// Supports `${VAR}` environment variable interpolation in the `url` field:
174///
175/// ```toml
176/// [database]
177/// url      = "${DATABASE_URL}"
178/// pool_min = 2
179/// pool_max = 20
180/// ssl_mode = "prefer"
181/// ```
182#[derive(Debug, Clone, Deserialize, Serialize)]
183#[serde(default, deny_unknown_fields)]
184pub struct DatabaseRuntimeConfig {
185    /// Database connection URL.  Supports `${VAR}` interpolation.
186    ///
187    /// If not set here, the runtime falls back to the `DATABASE_URL` environment
188    /// variable or the `--database` CLI flag.
189    pub url: Option<String>,
190
191    /// Minimum connection pool size.  Default: `2`.
192    pub pool_min: usize,
193
194    /// Maximum connection pool size.  Default: `20`.
195    pub pool_max: usize,
196
197    /// Connection acquisition timeout in milliseconds.  Default: `5 000` (5 s).
198    pub connect_timeout_ms: u64,
199
200    /// Idle connection lifetime in milliseconds.  Default: `600 000` (10 min).
201    pub idle_timeout_ms: u64,
202
203    /// PostgreSQL SSL mode: `"disable"`, `"prefer"`, `"require"`, or
204    /// `"verify-full"`. Default: `"prefer"`.
205    ///
206    /// Reaches the connection pool through
207    /// [`postgres_tls`](Self::postgres_tls). It was previously validated here and
208    /// then dropped by `build_config_from`, so `fraiseql run` connected in
209    /// cleartext under `ssl_mode = "require"` while a typo in the same field
210    /// failed loudly — the rejected typo being the reason an operator believed the
211    /// setting was live (#824).
212    ///
213    /// Unset means "whatever the connection URL says", not "prefer": a concrete
214    /// default here would override an explicit `?sslmode=require` in `url` with a
215    /// value the operator never wrote.
216    pub ssl_mode: Option<String>,
217}
218
219impl Default for DatabaseRuntimeConfig {
220    fn default() -> Self {
221        Self {
222            url:                None,
223            pool_min:           2,
224            pool_max:           20,
225            connect_timeout_ms: 5_000,
226            idle_timeout_ms:    600_000,
227            ssl_mode:           None,
228        }
229    }
230}
231
232impl DatabaseRuntimeConfig {
233    /// Validate the database runtime configuration.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if:
238    /// - `pool_min > pool_max`
239    /// - `ssl_mode` is not one of the recognised values
240    pub fn validate(&self) -> Result<()> {
241        if self.pool_min > self.pool_max {
242            anyhow::bail!(
243                "[database] pool_min ({}) must be <= pool_max ({})",
244                self.pool_min,
245                self.pool_max
246            );
247        }
248
249        if self.connect_timeout_ms == 0 {
250            anyhow::bail!(
251                "[database] connect_timeout_ms must be greater than 0; a zero timeout makes \
252                 every connection attempt fail immediately"
253            );
254        }
255
256        // Validated by parsing rather than against a local allow-list. The list here
257        // accepted "allow", which no connector can honour — so validation succeeding
258        // told the operator nothing about whether the mode could take effect (#824).
259        self.postgres_tls()?;
260
261        Ok(())
262    }
263
264    /// Transport security for the pool `fraiseql run` builds.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if `ssl_mode` names a mode the connector cannot honour.
269    pub fn postgres_tls(&self) -> Result<PostgresTlsConfig> {
270        let mode: Option<PostgresSslMode> =
271            self.ssl_mode.as_deref().map(str::parse).transpose().map_err(
272                |e: fraiseql_core::error::FraiseQLError| {
273                    anyhow::anyhow!("[database] ssl_mode: {e}")
274                },
275            )?;
276        Ok(PostgresTlsConfig {
277            mode,
278            ca_bundle_path: None,
279        })
280    }
281
282    /// Pool acquisition timeout in whole seconds, rounded up and floored at one.
283    ///
284    /// `connect_timeout_ms / 1000` truncated any sub-second value to `0`, which
285    /// deadpool applies to its `create` slot as `Duration::ZERO`; a TCP connect plus
286    /// the PostgreSQL startup handshake cannot finish on the first poll, so every
287    /// connection attempt failed with a pool timeout against a perfectly healthy
288    /// server, naming a timeout the operator never configured (#824).
289    #[must_use]
290    pub const fn pool_timeout_secs(&self) -> u64 {
291        let secs = self.connect_timeout_ms.div_ceil(1_000);
292        if secs == 0 { 1 } else { secs }
293    }
294}