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}