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#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    // ── ServerRuntimeConfig defaults ─────────────────────────────────────────
259
260    #[test]
261    fn test_server_runtime_config_default() {
262        let cfg = ServerRuntimeConfig::default();
263        assert_eq!(cfg.host, "0.0.0.0");
264        assert_eq!(cfg.port, 8080);
265        assert_eq!(cfg.request_timeout_ms, 30_000);
266        assert_eq!(cfg.keep_alive_secs, 75);
267        assert!(!cfg.tls.enabled);
268        assert!(cfg.cors.origins.is_empty());
269        assert!(!cfg.cors.credentials);
270    }
271
272    #[test]
273    fn test_server_runtime_config_validate_ok() {
274        ServerRuntimeConfig::default()
275            .validate()
276            .unwrap_or_else(|e| panic!("expected Ok from validate: {e:?}"));
277    }
278
279    #[test]
280    fn test_server_runtime_config_validate_port_zero() {
281        let cfg = ServerRuntimeConfig {
282            port: 0,
283            ..Default::default()
284        };
285        let err = cfg.validate().unwrap_err();
286        assert!(err.to_string().contains("port"), "got: {err}");
287    }
288
289    #[test]
290    fn test_server_runtime_config_validate_tls_missing_cert() {
291        let cfg = ServerRuntimeConfig {
292            tls: TlsRuntimeConfig {
293                enabled:     true,
294                cert_file:   String::new(),
295                key_file:    "key.pem".to_string(),
296                min_version: "1.2".to_string(),
297            },
298            ..Default::default()
299        };
300        let err = cfg.validate().unwrap_err();
301        assert!(err.to_string().contains("cert_file"), "got: {err}");
302    }
303
304    #[test]
305    fn test_server_runtime_config_validate_tls_missing_key() {
306        let cfg = ServerRuntimeConfig {
307            tls: TlsRuntimeConfig {
308                enabled:     true,
309                cert_file:   "cert.pem".to_string(),
310                key_file:    String::new(),
311                min_version: "1.2".to_string(),
312            },
313            ..Default::default()
314        };
315        let err = cfg.validate().unwrap_err();
316        assert!(err.to_string().contains("key_file"), "got: {err}");
317    }
318
319    #[test]
320    fn test_server_runtime_config_validate_bad_tls_version() {
321        let cfg = ServerRuntimeConfig {
322            tls: TlsRuntimeConfig {
323                enabled:     true,
324                cert_file:   "cert.pem".to_string(),
325                key_file:    "key.pem".to_string(),
326                min_version: "1.0".to_string(),
327            },
328            ..Default::default()
329        };
330        let err = cfg.validate().unwrap_err();
331        assert!(err.to_string().contains("min_version"), "got: {err}");
332    }
333
334    #[test]
335    fn test_server_runtime_config_parses_toml() {
336        let toml_str = r#"
337host               = "127.0.0.1"
338port               = 9000
339request_timeout_ms = 60_000
340
341[cors]
342origins     = ["https://example.com"]
343credentials = true
344
345[tls]
346enabled = false
347"#;
348        let cfg: ServerRuntimeConfig = toml::from_str(toml_str).expect("parse failed");
349        assert_eq!(cfg.host, "127.0.0.1");
350        assert_eq!(cfg.port, 9000);
351        assert_eq!(cfg.request_timeout_ms, 60_000);
352        assert_eq!(cfg.cors.origins, ["https://example.com"]);
353        assert!(cfg.cors.credentials);
354        assert!(!cfg.tls.enabled);
355    }
356
357    // ── DatabaseRuntimeConfig ────────────────────────────────────────────────
358
359    #[test]
360    fn test_database_runtime_config_default() {
361        let cfg = DatabaseRuntimeConfig::default();
362        assert!(cfg.url.is_none());
363        assert_eq!(cfg.pool_min, 2);
364        assert_eq!(cfg.pool_max, 20);
365        assert_eq!(cfg.connect_timeout_ms, 5_000);
366        assert_eq!(cfg.idle_timeout_ms, 600_000);
367        assert_eq!(cfg.ssl_mode, "prefer");
368    }
369
370    #[test]
371    fn test_database_runtime_config_validate_ok() {
372        DatabaseRuntimeConfig::default()
373            .validate()
374            .unwrap_or_else(|e| panic!("expected Ok from validate: {e:?}"));
375    }
376
377    #[test]
378    fn test_database_runtime_config_validate_pool_range() {
379        let cfg = DatabaseRuntimeConfig {
380            pool_min: 10,
381            pool_max: 5,
382            ..Default::default()
383        };
384        let err = cfg.validate().unwrap_err();
385        assert!(err.to_string().contains("pool_min"), "got: {err}");
386    }
387
388    #[test]
389    fn test_database_runtime_config_validate_ssl_mode() {
390        let cfg = DatabaseRuntimeConfig {
391            ssl_mode: "bogus".to_string(),
392            ..Default::default()
393        };
394        let err = cfg.validate().unwrap_err();
395        assert!(err.to_string().contains("ssl_mode"), "got: {err}");
396    }
397
398    #[test]
399    fn test_database_runtime_config_parses_toml() {
400        let toml_str = r#"
401url      = "postgresql://localhost/mydb"
402pool_min = 5
403pool_max = 50
404ssl_mode = "require"
405"#;
406        let cfg: DatabaseRuntimeConfig = toml::from_str(toml_str).expect("parse failed");
407        assert_eq!(cfg.url, Some("postgresql://localhost/mydb".to_string()));
408        assert_eq!(cfg.pool_min, 5);
409        assert_eq!(cfg.pool_max, 50);
410        assert_eq!(cfg.ssl_mode, "require");
411    }
412}