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 serde::{Deserialize, Serialize};
10
11// ─── TLS ─────────────────────────────────────────────────────────────────────
12
13/// TLS/HTTPS configuration for the HTTP server.
14///
15/// ```toml
16/// [server.tls]
17/// enabled  = true
18/// cert_file = "/etc/fraiseql/cert.pem"
19/// key_file  = "/etc/fraiseql/key.pem"
20/// min_version = "1.2"   # "1.2" or "1.3"
21/// ```
22#[derive(Debug, Clone, Deserialize, Serialize)]
23#[serde(default, deny_unknown_fields)]
24pub struct TlsRuntimeConfig {
25    /// Enable TLS (HTTPS).  Default: `false`.
26    pub enabled: bool,
27
28    /// Path to the PEM-encoded certificate file.
29    pub cert_file: String,
30
31    /// Path to the PEM-encoded private key file.
32    pub key_file: String,
33
34    /// Minimum TLS version: `"1.2"` (default) or `"1.3"`.
35    pub min_version: String,
36}
37
38impl Default for TlsRuntimeConfig {
39    fn default() -> Self {
40        Self {
41            enabled:     false,
42            cert_file:   String::new(),
43            key_file:    String::new(),
44            min_version: "1.2".to_string(),
45        }
46    }
47}
48
49// ─── CORS ────────────────────────────────────────────────────────────────────
50
51/// CORS configuration for the HTTP server.
52///
53/// ```toml
54/// [server.cors]
55/// origins     = ["https://app.example.com"]
56/// credentials = true
57/// ```
58#[derive(Debug, Clone, Default, Deserialize, Serialize)]
59#[serde(default, deny_unknown_fields)]
60pub struct CorsRuntimeConfig {
61    /// Allowed origins.  Empty list → all origins allowed (development default).
62    pub origins: Vec<String>,
63
64    /// Allow credentials (cookies, `Authorization` header).  Default: `false`.
65    pub credentials: bool,
66}
67
68// ─── Server ──────────────────────────────────────────────────────────────────
69
70/// HTTP server runtime configuration.
71///
72/// The `[server]` section in `fraiseql.toml` is **optional**.  When absent,
73/// the server listens on `0.0.0.0:8080` with no TLS and permissive CORS
74/// (suitable for local development).
75///
76/// CLI flags (`--port`, `--bind`) take precedence over these settings.
77///
78/// # Example
79///
80/// ```toml
81/// [server]
82/// host               = "127.0.0.1"
83/// port               = 9000
84/// request_timeout_ms = 30_000
85/// keep_alive_secs    = 75
86///
87/// [server.cors]
88/// origins     = ["https://app.example.com"]
89/// credentials = true
90///
91/// [server.tls]
92/// enabled   = true
93/// cert_file = "/etc/fraiseql/cert.pem"
94/// key_file  = "/etc/fraiseql/key.pem"
95/// ```
96#[derive(Debug, Clone, Deserialize, Serialize)]
97#[serde(default, deny_unknown_fields)]
98pub struct ServerRuntimeConfig {
99    /// Bind host.  Default: `"0.0.0.0"`.
100    pub host: String,
101
102    /// TCP port.  Default: `8080`.
103    pub port: u16,
104
105    /// Request timeout in milliseconds.  Default: `30 000` (30 s).
106    pub request_timeout_ms: u64,
107
108    /// TCP keep-alive in seconds.  Default: `75`.
109    pub keep_alive_secs: u64,
110
111    /// CORS settings.
112    pub cors: CorsRuntimeConfig,
113
114    /// TLS settings.
115    pub tls: TlsRuntimeConfig,
116}
117
118impl Default for ServerRuntimeConfig {
119    fn default() -> Self {
120        Self {
121            host:               "0.0.0.0".to_string(),
122            port:               8080,
123            request_timeout_ms: 30_000,
124            keep_alive_secs:    75,
125            cors:               CorsRuntimeConfig::default(),
126            tls:                TlsRuntimeConfig::default(),
127        }
128    }
129}
130
131impl ServerRuntimeConfig {
132    /// Validate the server runtime configuration.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if:
137    /// - `port` is zero
138    /// - `tls.enabled` but `cert_file` or `key_file` is empty
139    /// - `tls.min_version` is not `"1.2"` or `"1.3"`
140    pub fn validate(&self) -> Result<()> {
141        if self.port == 0 {
142            anyhow::bail!("[server] port must be non-zero");
143        }
144
145        if self.tls.enabled {
146            if self.tls.cert_file.is_empty() {
147                anyhow::bail!("[server.tls] cert_file is required when tls.enabled = true");
148            }
149            if self.tls.key_file.is_empty() {
150                anyhow::bail!("[server.tls] key_file is required when tls.enabled = true");
151            }
152            if self.tls.min_version != "1.2" && self.tls.min_version != "1.3" {
153                anyhow::bail!(
154                    "[server.tls] min_version must be \"1.2\" or \"1.3\", got \"{}\"",
155                    self.tls.min_version
156                );
157            }
158        }
159
160        Ok(())
161    }
162}
163
164// ─── Database ────────────────────────────────────────────────────────────────
165
166/// Database connection pool runtime configuration.
167///
168/// The `[database]` section in `fraiseql.toml` is **optional**.  When absent,
169/// connection parameters fall back to the `DATABASE_URL` environment variable
170/// or the `--database` CLI flag.
171///
172/// Supports `${VAR}` environment variable interpolation in the `url` field:
173///
174/// ```toml
175/// [database]
176/// url      = "${DATABASE_URL}"
177/// pool_min = 2
178/// pool_max = 20
179/// ssl_mode = "prefer"
180/// ```
181#[derive(Debug, Clone, Deserialize, Serialize)]
182#[serde(default, deny_unknown_fields)]
183pub struct DatabaseRuntimeConfig {
184    /// Database connection URL.  Supports `${VAR}` interpolation.
185    ///
186    /// If not set here, the runtime falls back to the `DATABASE_URL` environment
187    /// variable or the `--database` CLI flag.
188    pub url: Option<String>,
189
190    /// Minimum connection pool size.  Default: `2`.
191    pub pool_min: usize,
192
193    /// Maximum connection pool size.  Default: `20`.
194    pub pool_max: usize,
195
196    /// Connection acquisition timeout in milliseconds.  Default: `5 000` (5 s).
197    pub connect_timeout_ms: u64,
198
199    /// Idle connection lifetime in milliseconds.  Default: `600 000` (10 min).
200    pub idle_timeout_ms: u64,
201
202    /// PostgreSQL SSL mode: `"disable"`, `"allow"`, `"prefer"`, or `"require"`.
203    /// Default: `"prefer"`.
204    pub ssl_mode: String,
205}
206
207impl Default for DatabaseRuntimeConfig {
208    fn default() -> Self {
209        Self {
210            url:                None,
211            pool_min:           2,
212            pool_max:           20,
213            connect_timeout_ms: 5_000,
214            idle_timeout_ms:    600_000,
215            ssl_mode:           "prefer".to_string(),
216        }
217    }
218}
219
220impl DatabaseRuntimeConfig {
221    /// Validate the database runtime configuration.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if:
226    /// - `pool_min > pool_max`
227    /// - `ssl_mode` is not one of the recognised values
228    pub fn validate(&self) -> Result<()> {
229        const VALID_SSL: &[&str] = &["disable", "allow", "prefer", "require"];
230
231        if self.pool_min > self.pool_max {
232            anyhow::bail!(
233                "[database] pool_min ({}) must be <= pool_max ({})",
234                self.pool_min,
235                self.pool_max
236            );
237        }
238
239        if !VALID_SSL.contains(&self.ssl_mode.as_str()) {
240            anyhow::bail!(
241                "[database] ssl_mode must be one of {:?}, got \"{}\"",
242                VALID_SSL,
243                self.ssl_mode
244            );
245        }
246
247        Ok(())
248    }
249}
250
251// ─── Tests ───────────────────────────────────────────────────────────────────
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    // ── ServerRuntimeConfig defaults ─────────────────────────────────────────
258
259    #[test]
260    fn test_server_runtime_config_default() {
261        let cfg = ServerRuntimeConfig::default();
262        assert_eq!(cfg.host, "0.0.0.0");
263        assert_eq!(cfg.port, 8080);
264        assert_eq!(cfg.request_timeout_ms, 30_000);
265        assert_eq!(cfg.keep_alive_secs, 75);
266        assert!(!cfg.tls.enabled);
267        assert!(cfg.cors.origins.is_empty());
268        assert!(!cfg.cors.credentials);
269    }
270
271    #[test]
272    fn test_server_runtime_config_validate_ok() {
273        assert!(ServerRuntimeConfig::default().validate().is_ok());
274    }
275
276    #[test]
277    fn test_server_runtime_config_validate_port_zero() {
278        let cfg = ServerRuntimeConfig { port: 0, ..Default::default() };
279        let err = cfg.validate().unwrap_err();
280        assert!(err.to_string().contains("port"), "got: {err}");
281    }
282
283    #[test]
284    fn test_server_runtime_config_validate_tls_missing_cert() {
285        let cfg = ServerRuntimeConfig {
286            tls: TlsRuntimeConfig {
287                enabled:     true,
288                cert_file:   String::new(),
289                key_file:    "key.pem".to_string(),
290                min_version: "1.2".to_string(),
291            },
292            ..Default::default()
293        };
294        let err = cfg.validate().unwrap_err();
295        assert!(err.to_string().contains("cert_file"), "got: {err}");
296    }
297
298    #[test]
299    fn test_server_runtime_config_validate_tls_missing_key() {
300        let cfg = ServerRuntimeConfig {
301            tls: TlsRuntimeConfig {
302                enabled:     true,
303                cert_file:   "cert.pem".to_string(),
304                key_file:    String::new(),
305                min_version: "1.2".to_string(),
306            },
307            ..Default::default()
308        };
309        let err = cfg.validate().unwrap_err();
310        assert!(err.to_string().contains("key_file"), "got: {err}");
311    }
312
313    #[test]
314    fn test_server_runtime_config_validate_bad_tls_version() {
315        let cfg = ServerRuntimeConfig {
316            tls: TlsRuntimeConfig {
317                enabled:     true,
318                cert_file:   "cert.pem".to_string(),
319                key_file:    "key.pem".to_string(),
320                min_version: "1.0".to_string(),
321            },
322            ..Default::default()
323        };
324        let err = cfg.validate().unwrap_err();
325        assert!(err.to_string().contains("min_version"), "got: {err}");
326    }
327
328    #[test]
329    fn test_server_runtime_config_parses_toml() {
330        let toml_str = r#"
331host               = "127.0.0.1"
332port               = 9000
333request_timeout_ms = 60_000
334
335[cors]
336origins     = ["https://example.com"]
337credentials = true
338
339[tls]
340enabled = false
341"#;
342        let cfg: ServerRuntimeConfig = toml::from_str(toml_str).expect("parse failed");
343        assert_eq!(cfg.host, "127.0.0.1");
344        assert_eq!(cfg.port, 9000);
345        assert_eq!(cfg.request_timeout_ms, 60_000);
346        assert_eq!(cfg.cors.origins, ["https://example.com"]);
347        assert!(cfg.cors.credentials);
348        assert!(!cfg.tls.enabled);
349    }
350
351    // ── DatabaseRuntimeConfig ────────────────────────────────────────────────
352
353    #[test]
354    fn test_database_runtime_config_default() {
355        let cfg = DatabaseRuntimeConfig::default();
356        assert!(cfg.url.is_none());
357        assert_eq!(cfg.pool_min, 2);
358        assert_eq!(cfg.pool_max, 20);
359        assert_eq!(cfg.connect_timeout_ms, 5_000);
360        assert_eq!(cfg.idle_timeout_ms, 600_000);
361        assert_eq!(cfg.ssl_mode, "prefer");
362    }
363
364    #[test]
365    fn test_database_runtime_config_validate_ok() {
366        assert!(DatabaseRuntimeConfig::default().validate().is_ok());
367    }
368
369    #[test]
370    fn test_database_runtime_config_validate_pool_range() {
371        let cfg = DatabaseRuntimeConfig { pool_min: 10, pool_max: 5, ..Default::default() };
372        let err = cfg.validate().unwrap_err();
373        assert!(err.to_string().contains("pool_min"), "got: {err}");
374    }
375
376    #[test]
377    fn test_database_runtime_config_validate_ssl_mode() {
378        let cfg =
379            DatabaseRuntimeConfig { ssl_mode: "bogus".to_string(), ..Default::default() };
380        let err = cfg.validate().unwrap_err();
381        assert!(err.to_string().contains("ssl_mode"), "got: {err}");
382    }
383
384    #[test]
385    fn test_database_runtime_config_parses_toml() {
386        let toml_str = r#"
387url      = "postgresql://localhost/mydb"
388pool_min = 5
389pool_max = 50
390ssl_mode = "require"
391"#;
392        let cfg: DatabaseRuntimeConfig = toml::from_str(toml_str).expect("parse failed");
393        assert_eq!(cfg.url, Some("postgresql://localhost/mydb".to_string()));
394        assert_eq!(cfg.pool_min, 5);
395        assert_eq!(cfg.pool_max, 50);
396        assert_eq!(cfg.ssl_mode, "require");
397    }
398}