1use anyhow::Result;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Deserialize, Serialize)]
23#[serde(default, deny_unknown_fields)]
24pub struct TlsRuntimeConfig {
25 pub enabled: bool,
27
28 pub cert_file: String,
30
31 pub key_file: String,
33
34 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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
59#[serde(default, deny_unknown_fields)]
60pub struct CorsRuntimeConfig {
61 pub origins: Vec<String>,
63
64 pub credentials: bool,
66}
67
68#[derive(Debug, Clone, Deserialize, Serialize)]
97#[serde(default, deny_unknown_fields)]
98pub struct ServerRuntimeConfig {
99 pub host: String,
101
102 pub port: u16,
104
105 pub request_timeout_ms: u64,
107
108 pub keep_alive_secs: u64,
110
111 pub cors: CorsRuntimeConfig,
113
114 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 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#[derive(Debug, Clone, Deserialize, Serialize)]
182#[serde(default, deny_unknown_fields)]
183pub struct DatabaseRuntimeConfig {
184 pub url: Option<String>,
189
190 pub pool_min: usize,
192
193 pub pool_max: usize,
195
196 pub connect_timeout_ms: u64,
198
199 pub idle_timeout_ms: u64,
201
202 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 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#[allow(clippy::unwrap_used)] #[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[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 #[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}