Skip to main content

type_bridge_server/
config.rs

1use serde::Deserialize;
2
3#[derive(Debug, Deserialize)]
4pub struct ServerConfig {
5    pub server: ServerSection,
6    pub typedb: TypeDBSection,
7    #[serde(default)]
8    pub schema: SchemaSection,
9    #[serde(default)]
10    pub interceptors: InterceptorsSection,
11    #[serde(default)]
12    pub logging: LoggingSection,
13}
14
15#[derive(Debug, Deserialize)]
16pub struct ServerSection {
17    #[serde(default = "default_host")]
18    pub host: String,
19    #[serde(default = "default_port")]
20    pub port: u16,
21}
22
23#[derive(Debug, Deserialize)]
24pub struct TypeDBSection {
25    pub address: String,
26    pub database: String,
27    #[serde(default = "default_username")]
28    pub username: String,
29    #[serde(default = "default_password")]
30    pub password: String,
31}
32
33#[derive(Debug, Default, Deserialize)]
34pub struct SchemaSection {
35    #[serde(default)]
36    pub source_file: String,
37}
38
39#[derive(Debug, Default, Deserialize)]
40pub struct InterceptorsSection {
41    #[serde(default)]
42    pub enabled: Vec<String>,
43    #[serde(default, rename = "audit-log")]
44    pub audit_log: Option<AuditLogConfig>,
45}
46
47#[derive(Debug, Clone, Deserialize)]
48pub struct AuditLogConfig {
49    #[serde(default = "default_audit_output")]
50    pub output: String,
51    #[serde(default)]
52    pub file_path: String,
53}
54
55#[derive(Debug, Deserialize)]
56pub struct LoggingSection {
57    #[serde(default = "default_log_level")]
58    pub level: String,
59    #[serde(default = "default_log_format")]
60    pub format: String,
61}
62
63impl Default for LoggingSection {
64    fn default() -> Self {
65        Self {
66            level: default_log_level(),
67            format: default_log_format(),
68        }
69    }
70}
71
72fn default_host() -> String {
73    "0.0.0.0".to_string()
74}
75
76fn default_port() -> u16 {
77    8080
78}
79
80fn default_username() -> String {
81    "admin".to_string()
82}
83
84fn default_password() -> String {
85    "password".to_string()
86}
87
88fn default_log_level() -> String {
89    "info".to_string()
90}
91
92fn default_log_format() -> String {
93    "json".to_string()
94}
95
96fn default_audit_output() -> String {
97    "stdout".to_string()
98}
99
100impl ServerConfig {
101    pub fn from_file(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
102        let content = std::fs::read_to_string(path)?;
103        let mut config: ServerConfig = toml::from_str(&content)?;
104        config.apply_env_overrides();
105        Ok(config)
106    }
107
108    fn apply_env_overrides(&mut self) {
109        self.apply_env_overrides_from(|name| std::env::var(name).ok());
110    }
111
112    fn apply_env_overrides_from<F>(&mut self, mut get_env: F)
113    where
114        F: FnMut(&str) -> Option<String>,
115    {
116        if let Some(address) = get_env("TYPEDB_ADDRESS") {
117            self.typedb.address = address;
118        }
119        if let Some(database) = get_env("TYPEDB_DATABASE") {
120            self.typedb.database = database;
121        }
122        if let Some(username) = get_env("TYPEDB_USERNAME") {
123            self.typedb.username = username;
124        }
125        if let Some(password) = get_env("TYPEDB_PASSWORD") {
126            self.typedb.password = password;
127        }
128    }
129}
130
131#[cfg(test)]
132#[cfg_attr(coverage_nightly, coverage(off))]
133mod tests {
134    use super::*;
135
136    const FULL_CONFIG: &str = r#"
137[server]
138host = "127.0.0.1"
139port = 9090
140
141[typedb]
142address = "localhost:1729"
143database = "mydb"
144username = "root"
145password = "secret"
146
147[schema]
148source_file = "schema.tql"
149
150[interceptors]
151enabled = ["audit-log"]
152
153[interceptors.audit-log]
154output = "file"
155file_path = "/tmp/audit.log"
156
157[logging]
158level = "debug"
159format = "text"
160"#;
161
162    const MINIMAL_CONFIG: &str = r#"
163[server]
164
165[typedb]
166address = "localhost:1729"
167database = "mydb"
168"#;
169
170    // --- from_file tests ---
171
172    #[test]
173    fn from_file_valid_full_config() {
174        let dir = tempfile::tempdir().unwrap();
175        let path = dir.path().join("server.toml");
176        std::fs::write(&path, FULL_CONFIG).unwrap();
177
178        let config = ServerConfig::from_file(path.to_str().unwrap()).unwrap();
179        assert_eq!(config.server.host, "127.0.0.1");
180        assert_eq!(config.server.port, 9090);
181        assert_eq!(config.typedb.address, "localhost:1729");
182        assert_eq!(config.typedb.database, "mydb");
183        assert_eq!(config.typedb.username, "root");
184        assert_eq!(config.typedb.password, "secret");
185        assert_eq!(config.schema.source_file, "schema.tql");
186        assert_eq!(config.interceptors.enabled, vec!["audit-log"]);
187        let audit = config.interceptors.audit_log.unwrap();
188        assert_eq!(audit.output, "file");
189        assert_eq!(audit.file_path, "/tmp/audit.log");
190        assert_eq!(config.logging.level, "debug");
191        assert_eq!(config.logging.format, "text");
192    }
193
194    #[test]
195    fn env_overrides_typedb_section() {
196        let mut config: ServerConfig = toml::from_str(FULL_CONFIG).unwrap();
197        config.apply_env_overrides_from(|name| match name {
198            "TYPEDB_ADDRESS" => Some("typedb:1729".to_string()),
199            "TYPEDB_DATABASE" => Some("docker_db".to_string()),
200            "TYPEDB_USERNAME" => Some("docker_user".to_string()),
201            "TYPEDB_PASSWORD" => Some("docker_pass".to_string()),
202            _ => None,
203        });
204
205        assert_eq!(config.typedb.address, "typedb:1729");
206        assert_eq!(config.typedb.database, "docker_db");
207        assert_eq!(config.typedb.username, "docker_user");
208        assert_eq!(config.typedb.password, "docker_pass");
209    }
210
211    #[test]
212    fn from_file_valid_minimal_config() {
213        let dir = tempfile::tempdir().unwrap();
214        let path = dir.path().join("server.toml");
215        std::fs::write(&path, MINIMAL_CONFIG).unwrap();
216
217        let config = ServerConfig::from_file(path.to_str().unwrap()).unwrap();
218        // Defaults should kick in
219        assert_eq!(config.server.host, "0.0.0.0");
220        assert_eq!(config.server.port, 8080);
221        assert_eq!(config.typedb.username, "admin");
222        assert_eq!(config.typedb.password, "password");
223        assert_eq!(config.schema.source_file, "");
224        assert!(config.interceptors.enabled.is_empty());
225        assert!(config.interceptors.audit_log.is_none());
226        assert_eq!(config.logging.level, "info");
227        assert_eq!(config.logging.format, "json");
228    }
229
230    #[test]
231    fn from_file_missing_file() {
232        let result = ServerConfig::from_file("/nonexistent/path/server.toml");
233        assert!(result.is_err());
234    }
235
236    #[test]
237    fn from_file_invalid_toml() {
238        let dir = tempfile::tempdir().unwrap();
239        let path = dir.path().join("bad.toml");
240        std::fs::write(&path, "this is not valid toml {{{}}}").unwrap();
241
242        let result = ServerConfig::from_file(path.to_str().unwrap());
243        assert!(result.is_err());
244    }
245
246    #[test]
247    fn from_file_missing_required_typedb_section() {
248        let dir = tempfile::tempdir().unwrap();
249        let path = dir.path().join("incomplete.toml");
250        std::fs::write(&path, "[server]\n").unwrap();
251
252        let result = ServerConfig::from_file(path.to_str().unwrap());
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn from_file_missing_required_typedb_fields() {
258        let dir = tempfile::tempdir().unwrap();
259        let path = dir.path().join("incomplete.toml");
260        std::fs::write(&path, "[server]\n[typedb]\n").unwrap();
261
262        let result = ServerConfig::from_file(path.to_str().unwrap());
263        assert!(result.is_err()); // address and database are required
264    }
265
266    // --- Default function tests ---
267
268    #[test]
269    fn default_host_value() {
270        assert_eq!(default_host(), "0.0.0.0");
271    }
272
273    #[test]
274    fn default_port_value() {
275        assert_eq!(default_port(), 8080);
276    }
277
278    #[test]
279    fn default_username_value() {
280        assert_eq!(default_username(), "admin");
281    }
282
283    #[test]
284    fn default_password_value() {
285        assert_eq!(default_password(), "password");
286    }
287
288    #[test]
289    fn default_log_level_value() {
290        assert_eq!(default_log_level(), "info");
291    }
292
293    #[test]
294    fn default_log_format_value() {
295        assert_eq!(default_log_format(), "json");
296    }
297
298    #[test]
299    fn default_audit_output_value() {
300        assert_eq!(default_audit_output(), "stdout");
301    }
302
303    // --- LoggingSection default ---
304
305    #[test]
306    fn logging_section_default() {
307        let logging = LoggingSection::default();
308        assert_eq!(logging.level, "info");
309        assert_eq!(logging.format, "json");
310    }
311
312    // --- Serde deserialization edge cases ---
313
314    #[test]
315    fn server_section_custom_host_default_port() {
316        let toml = r#"
317[server]
318host = "192.168.1.1"
319
320[typedb]
321address = "localhost:1729"
322database = "db"
323"#;
324        let config: ServerConfig = toml::from_str(toml).unwrap();
325        assert_eq!(config.server.host, "192.168.1.1");
326        assert_eq!(config.server.port, 8080); // default
327    }
328
329    #[test]
330    fn server_section_custom_port_default_host() {
331        let toml = r#"
332[server]
333port = 3000
334
335[typedb]
336address = "localhost:1729"
337database = "db"
338"#;
339        let config: ServerConfig = toml::from_str(toml).unwrap();
340        assert_eq!(config.server.host, "0.0.0.0"); // default
341        assert_eq!(config.server.port, 3000);
342    }
343
344    #[test]
345    fn typedb_section_custom_credentials() {
346        let toml = r#"
347[server]
348
349[typedb]
350address = "remote:1729"
351database = "prod"
352username = "superuser"
353password = "hunter2"
354"#;
355        let config: ServerConfig = toml::from_str(toml).unwrap();
356        assert_eq!(config.typedb.username, "superuser");
357        assert_eq!(config.typedb.password, "hunter2");
358    }
359
360    #[test]
361    fn schema_section_default_when_missing() {
362        let config: ServerConfig = toml::from_str(MINIMAL_CONFIG).unwrap();
363        assert_eq!(config.schema.source_file, "");
364    }
365
366    #[test]
367    fn schema_section_with_file() {
368        let toml = r#"
369[server]
370[typedb]
371address = "localhost:1729"
372database = "db"
373[schema]
374source_file = "my_schema.tql"
375"#;
376        let config: ServerConfig = toml::from_str(toml).unwrap();
377        assert_eq!(config.schema.source_file, "my_schema.tql");
378    }
379
380    #[test]
381    fn interceptors_enabled_empty_by_default() {
382        let config: ServerConfig = toml::from_str(MINIMAL_CONFIG).unwrap();
383        assert!(config.interceptors.enabled.is_empty());
384        assert!(config.interceptors.audit_log.is_none());
385    }
386
387    #[test]
388    fn interceptors_enabled_without_audit_config() {
389        let toml = r#"
390[server]
391[typedb]
392address = "localhost:1729"
393database = "db"
394[interceptors]
395enabled = ["audit-log"]
396"#;
397        let config: ServerConfig = toml::from_str(toml).unwrap();
398        assert_eq!(config.interceptors.enabled, vec!["audit-log"]);
399        assert!(config.interceptors.audit_log.is_none());
400    }
401
402    #[test]
403    fn interceptors_with_audit_config() {
404        let toml = r#"
405[server]
406[typedb]
407address = "localhost:1729"
408database = "db"
409[interceptors]
410enabled = ["audit-log"]
411[interceptors.audit-log]
412output = "file"
413file_path = "/var/log/audit.jsonl"
414"#;
415        let config: ServerConfig = toml::from_str(toml).unwrap();
416        let audit = config.interceptors.audit_log.unwrap();
417        assert_eq!(audit.output, "file");
418        assert_eq!(audit.file_path, "/var/log/audit.jsonl");
419    }
420
421    #[test]
422    fn audit_log_config_defaults() {
423        let toml = r#"
424[server]
425[typedb]
426address = "localhost:1729"
427database = "db"
428[interceptors]
429enabled = ["audit-log"]
430[interceptors.audit-log]
431"#;
432        let config: ServerConfig = toml::from_str(toml).unwrap();
433        let audit = config.interceptors.audit_log.unwrap();
434        assert_eq!(audit.output, "stdout"); // default
435        assert_eq!(audit.file_path, ""); // default
436    }
437
438    #[test]
439    fn extra_fields_ignored() {
440        let toml = r#"
441[server]
442host = "0.0.0.0"
443unknown_field = "ignored"
444
445[typedb]
446address = "localhost:1729"
447database = "db"
448"#;
449        // toml crate with serde ignores unknown fields by default
450        let result: Result<ServerConfig, _> = toml::from_str(toml);
451        assert!(result.is_ok());
452    }
453
454    #[test]
455    fn multiple_interceptors_enabled() {
456        let toml = r#"
457[server]
458[typedb]
459address = "localhost:1729"
460database = "db"
461[interceptors]
462enabled = ["audit-log", "rate-limiter", "custom"]
463"#;
464        let config: ServerConfig = toml::from_str(toml).unwrap();
465        assert_eq!(config.interceptors.enabled.len(), 3);
466    }
467}