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}