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