1use serde::Deserialize;
2use type_bridge_core_lib::version as core_version;
3
4#[derive(Debug, Deserialize)]
5pub struct ServerConfig {
6 pub server: ServerSection,
7 pub typedb: TypeDBSection,
8 #[serde(default)]
9 pub schema: SchemaSection,
10 #[serde(default)]
11 pub interceptors: InterceptorsSection,
12 #[serde(default)]
13 pub logging: LoggingSection,
14}
15
16#[derive(Debug, Deserialize)]
17pub struct ServerSection {
18 #[serde(default = "default_host")]
19 pub host: String,
20 #[serde(default = "default_port")]
21 pub port: u16,
22}
23
24#[derive(Debug, Deserialize)]
25pub struct TypeDBSection {
26 pub address: String,
27 pub database: String,
28 #[serde(default = "default_username")]
29 pub username: String,
30 #[serde(default = "default_password")]
31 pub password: String,
32 #[serde(default = "default_http_port")]
35 pub http_port: u16,
36 #[serde(default)]
39 pub server_version: Option<String>,
40}
41
42#[derive(Debug, Default, Deserialize)]
43pub struct SchemaSection {
44 #[serde(default)]
45 pub source_file: String,
46}
47
48#[derive(Debug, Default, Deserialize)]
49pub struct InterceptorsSection {
50 #[serde(default)]
51 pub enabled: Vec<String>,
52 #[serde(default, rename = "audit-log")]
53 pub audit_log: Option<AuditLogConfig>,
54}
55
56#[derive(Debug, Clone, Deserialize)]
57pub struct AuditLogConfig {
58 #[serde(default = "default_audit_output")]
59 pub output: String,
60 #[serde(default)]
61 pub file_path: String,
62}
63
64#[derive(Debug, Deserialize)]
65pub struct LoggingSection {
66 #[serde(default = "default_log_level")]
67 pub level: String,
68 #[serde(default = "default_log_format")]
69 pub format: String,
70}
71
72impl Default for LoggingSection {
73 fn default() -> Self {
74 Self {
75 level: default_log_level(),
76 format: default_log_format(),
77 }
78 }
79}
80
81fn default_host() -> String {
82 "0.0.0.0".to_string()
83}
84
85fn default_port() -> u16 {
86 8080
87}
88
89fn default_http_port() -> u16 {
90 core_version::DEFAULT_HTTP_PORT
91}
92
93fn default_username() -> String {
94 "admin".to_string()
95}
96
97fn default_password() -> String {
98 "password".to_string()
99}
100
101fn default_log_level() -> String {
102 "info".to_string()
103}
104
105fn default_log_format() -> String {
106 "json".to_string()
107}
108
109fn default_audit_output() -> String {
110 "stdout".to_string()
111}
112
113impl ServerConfig {
114 pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
115 Self::from_file_with_env(path, |name| std::env::var(name).ok())
116 }
117
118 fn from_file_with_env<F>(path: &str, get_env: F) -> Result<Self, Box<dyn std::error::Error>>
119 where
120 F: FnMut(&str) -> Option<String>,
121 {
122 let content = std::fs::read_to_string(path)?;
123 let mut config: ServerConfig = toml::from_str(&content)?;
124 config.apply_env_overrides_from(get_env)?;
125 Ok(config)
126 }
127
128 fn apply_env_overrides_from<F>(
129 &mut self,
130 mut get_env: F,
131 ) -> Result<(), Box<dyn std::error::Error>>
132 where
133 F: FnMut(&str) -> Option<String>,
134 {
135 if let Some(address) = get_env("TYPEDB_ADDRESS") {
136 self.typedb.address = address;
137 }
138 if let Some(database) = get_env("TYPEDB_DATABASE") {
139 self.typedb.database = database;
140 }
141 if let Some(username) = get_env("TYPEDB_USERNAME") {
142 self.typedb.username = username;
143 }
144 if let Some(password) = get_env("TYPEDB_PASSWORD") {
145 self.typedb.password = password;
146 }
147 if let Some(raw) = get_env("TYPEDB_HTTP_PORT") {
148 self.typedb.http_port = raw.parse::<u16>().map_err(|_| {
149 format!("TYPEDB_HTTP_PORT must be a valid port number (0–65535), got {raw:?}")
150 })?;
151 }
152 if let Some(server_version) = get_env("TYPEDB_SERVER_VERSION") {
153 self.typedb.server_version = Some(server_version);
154 }
155 Ok(())
156 }
157}
158
159#[cfg(test)]
160#[cfg_attr(coverage_nightly, coverage(off))]
161mod tests {
162 use super::*;
163
164 const FULL_CONFIG: &str = r#"
165[server]
166host = "127.0.0.1"
167port = 9090
168
169[typedb]
170address = "localhost:1729"
171database = "mydb"
172username = "root"
173password = "secret"
174server_version = "3.11.5"
175
176[schema]
177source_file = "schema.tql"
178
179[interceptors]
180enabled = ["audit-log"]
181
182[interceptors.audit-log]
183output = "file"
184file_path = "/tmp/audit.log"
185
186[logging]
187level = "debug"
188format = "text"
189"#;
190
191 const MINIMAL_CONFIG: &str = r#"
192[server]
193
194[typedb]
195address = "localhost:1729"
196database = "mydb"
197"#;
198
199 #[test]
202 fn from_file_valid_full_config() {
203 let dir = tempfile::tempdir().unwrap();
204 let path = dir.path().join("server.toml");
205 std::fs::write(&path, FULL_CONFIG).unwrap();
206
207 let config = ServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None).unwrap();
208 assert_eq!(config.server.host, "127.0.0.1");
209 assert_eq!(config.server.port, 9090);
210 assert_eq!(config.typedb.address, "localhost:1729");
211 assert_eq!(config.typedb.database, "mydb");
212 assert_eq!(config.typedb.username, "root");
213 assert_eq!(config.typedb.password, "secret");
214 assert_eq!(config.typedb.server_version.as_deref(), Some("3.11.5"));
215 assert_eq!(config.schema.source_file, "schema.tql");
216 assert_eq!(config.interceptors.enabled, vec!["audit-log"]);
217 let audit = config.interceptors.audit_log.unwrap();
218 assert_eq!(audit.output, "file");
219 assert_eq!(audit.file_path, "/tmp/audit.log");
220 assert_eq!(config.logging.level, "debug");
221 assert_eq!(config.logging.format, "text");
222 }
223
224 #[test]
225 fn env_overrides_typedb_section() {
226 let mut config: ServerConfig = toml::from_str(FULL_CONFIG).unwrap();
227 config
228 .apply_env_overrides_from(|name| match name {
229 "TYPEDB_ADDRESS" => Some("typedb:1729".to_string()),
230 "TYPEDB_DATABASE" => Some("docker_db".to_string()),
231 "TYPEDB_USERNAME" => Some("docker_user".to_string()),
232 "TYPEDB_PASSWORD" => Some("docker_pass".to_string()),
233 _ => None,
234 })
235 .unwrap();
236
237 assert_eq!(config.typedb.address, "typedb:1729");
238 assert_eq!(config.typedb.database, "docker_db");
239 assert_eq!(config.typedb.username, "docker_user");
240 assert_eq!(config.typedb.password, "docker_pass");
241 }
242
243 #[test]
244 fn from_file_valid_minimal_config() {
245 let dir = tempfile::tempdir().unwrap();
246 let path = dir.path().join("server.toml");
247 std::fs::write(&path, MINIMAL_CONFIG).unwrap();
248
249 let config = ServerConfig::from_file_with_env(path.to_str().unwrap(), |_| None).unwrap();
250 assert_eq!(config.server.host, "0.0.0.0");
252 assert_eq!(config.server.port, 8080);
253 assert_eq!(config.typedb.username, "admin");
254 assert_eq!(config.typedb.password, "password");
255 assert_eq!(config.typedb.server_version, None);
256 assert_eq!(config.schema.source_file, "");
257 assert!(config.interceptors.enabled.is_empty());
258 assert!(config.interceptors.audit_log.is_none());
259 assert_eq!(config.logging.level, "info");
260 assert_eq!(config.logging.format, "json");
261 }
262
263 #[test]
264 fn from_file_missing_file() {
265 let result = ServerConfig::from_file("/nonexistent/path/server.toml");
266 assert!(result.is_err());
267 }
268
269 #[test]
270 fn from_file_invalid_toml() {
271 let dir = tempfile::tempdir().unwrap();
272 let path = dir.path().join("bad.toml");
273 std::fs::write(&path, "this is not valid toml {{{}}}").unwrap();
274
275 let result = ServerConfig::from_file(path.to_str().unwrap());
276 assert!(result.is_err());
277 }
278
279 #[test]
280 fn from_file_missing_required_typedb_section() {
281 let dir = tempfile::tempdir().unwrap();
282 let path = dir.path().join("incomplete.toml");
283 std::fs::write(&path, "[server]\n").unwrap();
284
285 let result = ServerConfig::from_file(path.to_str().unwrap());
286 assert!(result.is_err());
287 }
288
289 #[test]
290 fn from_file_missing_required_typedb_fields() {
291 let dir = tempfile::tempdir().unwrap();
292 let path = dir.path().join("incomplete.toml");
293 std::fs::write(&path, "[server]\n[typedb]\n").unwrap();
294
295 let result = ServerConfig::from_file(path.to_str().unwrap());
296 assert!(result.is_err()); }
298
299 #[test]
302 fn default_host_value() {
303 assert_eq!(default_host(), "0.0.0.0");
304 }
305
306 #[test]
307 fn default_port_value() {
308 assert_eq!(default_port(), 8080);
309 }
310
311 #[test]
312 fn default_username_value() {
313 assert_eq!(default_username(), "admin");
314 }
315
316 #[test]
317 fn default_password_value() {
318 assert_eq!(default_password(), "password");
319 }
320
321 #[test]
322 fn default_log_level_value() {
323 assert_eq!(default_log_level(), "info");
324 }
325
326 #[test]
327 fn default_log_format_value() {
328 assert_eq!(default_log_format(), "json");
329 }
330
331 #[test]
332 fn default_audit_output_value() {
333 assert_eq!(default_audit_output(), "stdout");
334 }
335
336 #[test]
339 fn logging_section_default() {
340 let logging = LoggingSection::default();
341 assert_eq!(logging.level, "info");
342 assert_eq!(logging.format, "json");
343 }
344
345 #[test]
348 fn server_section_custom_host_default_port() {
349 let toml = r#"
350[server]
351host = "192.168.1.1"
352
353[typedb]
354address = "localhost:1729"
355database = "db"
356"#;
357 let config: ServerConfig = toml::from_str(toml).unwrap();
358 assert_eq!(config.server.host, "192.168.1.1");
359 assert_eq!(config.server.port, 8080); }
361
362 #[test]
363 fn server_section_custom_port_default_host() {
364 let toml = r#"
365[server]
366port = 3000
367
368[typedb]
369address = "localhost:1729"
370database = "db"
371"#;
372 let config: ServerConfig = toml::from_str(toml).unwrap();
373 assert_eq!(config.server.host, "0.0.0.0"); assert_eq!(config.server.port, 3000);
375 }
376
377 #[test]
378 fn typedb_section_custom_credentials() {
379 let toml = r#"
380[server]
381
382[typedb]
383address = "remote:1729"
384database = "prod"
385username = "superuser"
386password = "hunter2"
387"#;
388 let config: ServerConfig = toml::from_str(toml).unwrap();
389 assert_eq!(config.typedb.username, "superuser");
390 assert_eq!(config.typedb.password, "hunter2");
391 }
392
393 #[test]
394 fn schema_section_default_when_missing() {
395 let config: ServerConfig = toml::from_str(MINIMAL_CONFIG).unwrap();
396 assert_eq!(config.schema.source_file, "");
397 }
398
399 #[test]
400 fn schema_section_with_file() {
401 let toml = r#"
402[server]
403[typedb]
404address = "localhost:1729"
405database = "db"
406[schema]
407source_file = "my_schema.tql"
408"#;
409 let config: ServerConfig = toml::from_str(toml).unwrap();
410 assert_eq!(config.schema.source_file, "my_schema.tql");
411 }
412
413 #[test]
414 fn interceptors_enabled_empty_by_default() {
415 let config: ServerConfig = toml::from_str(MINIMAL_CONFIG).unwrap();
416 assert!(config.interceptors.enabled.is_empty());
417 assert!(config.interceptors.audit_log.is_none());
418 }
419
420 #[test]
421 fn interceptors_enabled_without_audit_config() {
422 let toml = r#"
423[server]
424[typedb]
425address = "localhost:1729"
426database = "db"
427[interceptors]
428enabled = ["audit-log"]
429"#;
430 let config: ServerConfig = toml::from_str(toml).unwrap();
431 assert_eq!(config.interceptors.enabled, vec!["audit-log"]);
432 assert!(config.interceptors.audit_log.is_none());
433 }
434
435 #[test]
436 fn interceptors_with_audit_config() {
437 let toml = r#"
438[server]
439[typedb]
440address = "localhost:1729"
441database = "db"
442[interceptors]
443enabled = ["audit-log"]
444[interceptors.audit-log]
445output = "file"
446file_path = "/var/log/audit.jsonl"
447"#;
448 let config: ServerConfig = toml::from_str(toml).unwrap();
449 let audit = config.interceptors.audit_log.unwrap();
450 assert_eq!(audit.output, "file");
451 assert_eq!(audit.file_path, "/var/log/audit.jsonl");
452 }
453
454 #[test]
455 fn audit_log_config_defaults() {
456 let toml = r#"
457[server]
458[typedb]
459address = "localhost:1729"
460database = "db"
461[interceptors]
462enabled = ["audit-log"]
463[interceptors.audit-log]
464"#;
465 let config: ServerConfig = toml::from_str(toml).unwrap();
466 let audit = config.interceptors.audit_log.unwrap();
467 assert_eq!(audit.output, "stdout"); assert_eq!(audit.file_path, ""); }
470
471 #[test]
472 fn extra_fields_ignored() {
473 let toml = r#"
474[server]
475host = "0.0.0.0"
476unknown_field = "ignored"
477
478[typedb]
479address = "localhost:1729"
480database = "db"
481"#;
482 let result: Result<ServerConfig, _> = toml::from_str(toml);
484 assert!(result.is_ok());
485 }
486
487 #[test]
488 fn multiple_interceptors_enabled() {
489 let toml = r#"
490[server]
491[typedb]
492address = "localhost:1729"
493database = "db"
494[interceptors]
495enabled = ["audit-log", "rate-limiter", "custom"]
496"#;
497 let config: ServerConfig = toml::from_str(toml).unwrap();
498 assert_eq!(config.interceptors.enabled.len(), 3);
499 }
500
501 #[test]
504 fn env_overrides_http_port() {
505 let dir = tempfile::tempdir().unwrap();
506 let path = dir.path().join("server.toml");
507 std::fs::write(&path, MINIMAL_CONFIG).unwrap();
508
509 let config = ServerConfig::from_file_with_env(path.to_str().unwrap(), |name| {
510 if name == "TYPEDB_HTTP_PORT" {
511 Some("9123".to_string())
512 } else {
513 None
514 }
515 })
516 .unwrap();
517
518 assert_eq!(config.typedb.http_port, 9123);
519 }
520
521 #[test]
522 fn env_overrides_server_version() {
523 let dir = tempfile::tempdir().unwrap();
524 let path = dir.path().join("server.toml");
525 std::fs::write(&path, MINIMAL_CONFIG).unwrap();
526
527 let config = ServerConfig::from_file_with_env(path.to_str().unwrap(), |name| {
528 if name == "TYPEDB_SERVER_VERSION" {
529 Some("3.10.4".to_string())
530 } else {
531 None
532 }
533 })
534 .unwrap();
535
536 assert_eq!(config.typedb.server_version.as_deref(), Some("3.10.4"));
537 }
538
539 #[test]
540 fn env_invalid_http_port_errors() {
541 let dir = tempfile::tempdir().unwrap();
542 let path = dir.path().join("server.toml");
543 std::fs::write(&path, MINIMAL_CONFIG).unwrap();
544
545 let result = ServerConfig::from_file_with_env(path.to_str().unwrap(), |name| {
546 if name == "TYPEDB_HTTP_PORT" {
547 Some("not-a-port".to_string())
548 } else {
549 None
550 }
551 });
552
553 assert!(
554 result.is_err(),
555 "invalid TYPEDB_HTTP_PORT must return an error"
556 );
557 let msg = result.unwrap_err().to_string();
558 assert!(
559 msg.contains("TYPEDB_HTTP_PORT"),
560 "error message must mention TYPEDB_HTTP_PORT: {msg}"
561 );
562 }
563
564 #[test]
565 fn default_http_port_equals_ssot() {
566 use super::core_version;
569 assert_eq!(
570 default_http_port(),
571 core_version::DEFAULT_HTTP_PORT,
572 "server default_http_port() must equal core DEFAULT_HTTP_PORT"
573 );
574 }
575}