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/// ```
58#[derive(Debug, Clone, Default, Deserialize, Serialize)]
59#[serde(default, deny_unknown_fields)]
60pub struct CorsRuntimeConfig {
61 /// Allowed origins. Empty: the server allows `http://localhost:3000` only, and
62 /// refuses to start in production.
63 pub origins: Vec<String>,
64}
65
66// ─── Server ──────────────────────────────────────────────────────────────────
67
68/// HTTP server runtime configuration.
69///
70/// The `[server]` section in `fraiseql.toml` is **optional**. When absent,
71/// the server listens on `0.0.0.0:8080` with no TLS, and CORS allows
72/// `http://localhost:3000` only (suitable for local development).
73///
74/// CLI flags (`--port`, `--bind`) take precedence over these settings.
75///
76/// # Example
77///
78/// ```toml
79/// [server]
80/// host = "127.0.0.1"
81/// port = 9000
82///
83/// [server.cors]
84/// origins = ["https://app.example.com"]
85///
86/// [server.tls]
87/// enabled = true
88/// cert_file = "/etc/fraiseql/cert.pem"
89/// key_file = "/etc/fraiseql/key.pem"
90/// ```
91#[derive(Debug, Clone, Deserialize, Serialize)]
92#[serde(default, deny_unknown_fields)]
93pub struct ServerRuntimeConfig {
94 /// Bind host. Default: `"0.0.0.0"`.
95 pub host: String,
96
97 /// TCP port. Default: `8080`.
98 pub port: u16,
99
100 /// CORS settings.
101 pub cors: CorsRuntimeConfig,
102
103 /// TLS settings.
104 pub tls: TlsRuntimeConfig,
105}
106
107impl Default for ServerRuntimeConfig {
108 fn default() -> Self {
109 Self {
110 host: "0.0.0.0".to_string(),
111 port: 8080,
112 cors: CorsRuntimeConfig::default(),
113 tls: TlsRuntimeConfig::default(),
114 }
115 }
116}
117
118impl ServerRuntimeConfig {
119 /// Validate the server runtime configuration.
120 ///
121 /// # Errors
122 ///
123 /// Returns an error if:
124 /// - `port` is zero
125 /// - `tls.enabled` but `cert_file` or `key_file` is empty
126 /// - `tls.min_version` is not `"1.2"` or `"1.3"`
127 pub fn validate(&self) -> Result<()> {
128 if self.port == 0 {
129 anyhow::bail!("[server] port must be non-zero");
130 }
131
132 if self.tls.enabled {
133 if self.tls.cert_file.is_empty() {
134 anyhow::bail!("[server.tls] cert_file is required when tls.enabled = true");
135 }
136 if self.tls.key_file.is_empty() {
137 anyhow::bail!("[server.tls] key_file is required when tls.enabled = true");
138 }
139 if self.tls.min_version != "1.2" && self.tls.min_version != "1.3" {
140 anyhow::bail!(
141 "[server.tls] min_version must be \"1.2\" or \"1.3\", got \"{}\"",
142 self.tls.min_version
143 );
144 }
145 }
146
147 Ok(())
148 }
149}
150
151// ─── Database ────────────────────────────────────────────────────────────────
152
153/// Database connection pool runtime configuration.
154///
155/// The `[database]` section in `fraiseql.toml` is **optional**. When absent,
156/// connection parameters fall back to the `DATABASE_URL` environment variable
157/// or the `--database` CLI flag.
158///
159/// Supports `${VAR}` environment variable interpolation in the `url` field:
160///
161/// ```toml
162/// [database]
163/// url = "${DATABASE_URL}"
164/// pool_min = 2
165/// pool_max = 20
166/// ssl_mode = "prefer"
167/// ```
168#[derive(Debug, Clone, Deserialize, Serialize)]
169#[serde(default, deny_unknown_fields)]
170pub struct DatabaseRuntimeConfig {
171 /// Database connection URL. Supports `${VAR}` interpolation.
172 ///
173 /// If not set here, the runtime falls back to the `DATABASE_URL` environment
174 /// variable or the `--database` CLI flag.
175 pub url: Option<String>,
176
177 /// Minimum connection pool size. Default: `2`.
178 pub pool_min: usize,
179
180 /// Maximum connection pool size. Default: `20`.
181 pub pool_max: usize,
182
183 /// Connection acquisition timeout in milliseconds. Default: `5 000` (5 s).
184 pub connect_timeout_ms: u64,
185
186 /// PostgreSQL SSL mode: `"disable"`, `"prefer"`, `"require"`, or
187 /// `"verify-full"`. Default: `"prefer"`.
188 ///
189 /// Reaches the connection pool through
190 /// [`postgres_tls`](Self::postgres_tls). It was previously validated here and
191 /// then dropped by `build_config_from`, so `fraiseql run` connected in
192 /// cleartext under `ssl_mode = "require"` while a typo in the same field
193 /// failed loudly — the rejected typo being the reason an operator believed the
194 /// setting was live (#824).
195 ///
196 /// Unset means "whatever the connection URL says", not "prefer": a concrete
197 /// default here would override an explicit `?sslmode=require` in `url` with a
198 /// value the operator never wrote.
199 pub ssl_mode: Option<String>,
200
201 /// `[database.transaction_retry]`: how often a mutation's transaction runs when
202 /// PostgreSQL aborts it with `40P01` or `40001` (#1879). Absent, once. An empty table
203 /// opts in at its defaults. The server file's `[transaction_retry]`, in the same shape.
204 pub transaction_retry: Option<fraiseql_db::TransactionRetryConfig>,
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 ssl_mode: None,
215 transaction_retry: None,
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 if self.pool_min > self.pool_max {
230 anyhow::bail!(
231 "[database] pool_min ({}) must be <= pool_max ({})",
232 self.pool_min,
233 self.pool_max
234 );
235 }
236
237 if let Some(retry) = &self.transaction_retry {
238 retry
239 .policy()
240 .map_err(|e| anyhow::anyhow!("[database.transaction_retry] {e}"))?;
241 }
242
243 if self.connect_timeout_ms == 0 {
244 anyhow::bail!(
245 "[database] connect_timeout_ms must be greater than 0; a zero timeout makes \
246 every connection attempt fail immediately"
247 );
248 }
249
250 // Validated by parsing rather than against a local allow-list. The list here
251 // accepted "allow", which no connector can honour — so validation succeeding
252 // told the operator nothing about whether the mode could take effect (#824).
253 self.postgres_tls()?;
254
255 Ok(())
256 }
257
258 /// The `[database]` section of a parsed `fraiseql.toml`, or the default when the file
259 /// has none.
260 ///
261 /// Reads that one section rather than the whole document, so it serves any
262 /// `fraiseql.toml` a command was given, whichever other sections it carries.
263 ///
264 /// # Errors
265 ///
266 /// Returns an error if `[database]` holds a key this struct does not know or a value
267 /// of the wrong type.
268 pub fn from_document(document: &toml::Value) -> Result<Self> {
269 document.get("database").map_or_else(
270 || Ok(Self::default()),
271 |section| section.clone().try_into().map_err(|e| anyhow::anyhow!("[database]: {e}")),
272 )
273 }
274
275 /// Transport security for the pool `fraiseql run` builds.
276 ///
277 /// # Errors
278 ///
279 /// Returns an error if `ssl_mode` names a mode the connector cannot honour.
280 pub fn postgres_tls(&self) -> Result<PostgresTlsConfig> {
281 let mode: Option<PostgresSslMode> =
282 self.ssl_mode.as_deref().map(str::parse).transpose().map_err(
283 |e: fraiseql_core::error::FraiseQLError| {
284 anyhow::anyhow!("[database] ssl_mode: {e}")
285 },
286 )?;
287 Ok(PostgresTlsConfig {
288 mode,
289 ca_bundle_path: None,
290 })
291 }
292
293 /// Pool acquisition timeout in whole seconds, rounded up and floored at one.
294 ///
295 /// `connect_timeout_ms / 1000` truncated any sub-second value to `0`, which
296 /// deadpool applies to its `create` slot as `Duration::ZERO`; a TCP connect plus
297 /// the PostgreSQL startup handshake cannot finish on the first poll, so every
298 /// connection attempt failed with a pool timeout against a perfectly healthy
299 /// server, naming a timeout the operator never configured (#824).
300 #[must_use]
301 pub const fn pool_timeout_secs(&self) -> u64 {
302 let secs = self.connect_timeout_ms.div_ceil(1_000);
303 if secs == 0 { 1 } else { secs }
304 }
305}