Skip to main content

type_bridge_server/
config.rs

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    /// Port of the TypeDB HTTP API on the same host as `address`; the
33    /// connect-time version gate probes `/v1/version` here.
34    #[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    // --- from_file tests ---
192
193    #[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        // Defaults should kick in
242        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()); // address and database are required
287    }
288
289    // --- Default function tests ---
290
291    #[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    // --- LoggingSection default ---
327
328    #[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    // --- Serde deserialization edge cases ---
336
337    #[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); // default
350    }
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"); // default
364        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"); // default
458        assert_eq!(audit.file_path, ""); // default
459    }
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        // toml crate with serde ignores unknown fields by default
473        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    // --- TYPEDB_HTTP_PORT env override ---
492
493    #[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        // Pins the server default against the core SSOT constant so any
539        // divergence between the two fails at test time.
540        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}