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