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    /// Exact TypeDB server version to validate when the HTTP API is disabled
37    /// or unreachable. When set, the connect-time gate skips HTTP probing.
38    #[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    // --- from_file tests ---
200
201    #[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        // Defaults should kick in
251        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()); // address and database are required
297    }
298
299    // --- Default function tests ---
300
301    #[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    // --- LoggingSection default ---
337
338    #[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    // --- Serde deserialization edge cases ---
346
347    #[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); // default
360    }
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"); // default
374        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"); // default
468        assert_eq!(audit.file_path, ""); // default
469    }
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        // toml crate with serde ignores unknown fields by default
483        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    // --- TYPEDB_HTTP_PORT env override ---
502
503    #[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        // Pins the server default against the core SSOT constant so any
567        // divergence between the two fails at test time.
568        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}