cardinal_config/
lib.rs

1use crate::config::get_config_builder;
2use ::config::ConfigError;
3use derive_builder::Builder;
4use serde::{Deserialize, Deserializer, Serialize, Serializer};
5use std::collections::BTreeMap;
6use ts_rs::TS;
7
8pub mod config;
9
10#[derive(Debug, Clone, Serialize, Deserialize, Builder, TS)]
11#[ts(export)]
12pub struct HealthCheck {
13    pub path: String,
14    pub interval_ms: u64,
15    pub timeout_ms: u64,
16    pub expect_status: u16,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)]
20#[ts(export)]
21pub enum MiddlewareType {
22    Inbound,
23    Outbound,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Builder, TS)]
27#[ts(export)]
28pub struct Middleware {
29    pub r#type: MiddlewareType,
30    pub name: String,
31}
32
33#[derive(Debug, Clone, TS)]
34#[ts(export)]
35pub enum Plugin {
36    Builtin(BuiltinPlugin),
37    Wasm(WasmPluginConfig),
38}
39
40impl Plugin {
41    pub fn name(&self) -> &str {
42        match self {
43            Plugin::Builtin(builtin) => &builtin.name,
44            Plugin::Wasm(wasm) => &wasm.name,
45        }
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, Builder, TS)]
50#[ts(export)]
51pub struct BuiltinPlugin {
52    pub name: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Builder, TS)]
56#[ts(export)]
57pub struct WasmPluginConfig {
58    pub name: String,
59    pub path: String,
60    pub memory_name: Option<String>,
61    pub handle_name: Option<String>,
62}
63
64#[derive(Deserialize, TS)]
65#[serde(untagged)]
66#[ts(export)]
67enum PluginSerde {
68    Name(String),
69    Builtin { builtin: BuiltinPlugin },
70    Wasm { wasm: WasmPluginConfig },
71}
72
73impl<'de> Deserialize<'de> for Plugin {
74    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
75    where
76        D: Deserializer<'de>,
77    {
78        match PluginSerde::deserialize(deserializer)? {
79            PluginSerde::Name(name) => Ok(Plugin::Builtin(BuiltinPlugin { name })),
80            PluginSerde::Builtin { builtin } => Ok(Plugin::Builtin(builtin)),
81            PluginSerde::Wasm { wasm } => Ok(Plugin::Wasm(wasm)),
82        }
83    }
84}
85
86impl Serialize for Plugin {
87    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
88    where
89        S: Serializer,
90    {
91        match self {
92            Plugin::Builtin(builtin) => {
93                #[derive(Serialize)]
94                struct Wrapper<'a> {
95                    builtin: &'a BuiltinPlugin,
96                }
97                Wrapper { builtin }.serialize(serializer)
98            }
99            Plugin::Wasm(wasm) => {
100                #[derive(Serialize)]
101                struct Wrapper<'a> {
102                    wasm: &'a WasmPluginConfig,
103                }
104                Wrapper { wasm }.serialize(serializer)
105            }
106        }
107    }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
111#[serde(untagged)]
112#[ts(export)]
113pub enum DestinationMatchValue {
114    String(String),
115    Regex { regex: String },
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Builder, TS)]
119#[ts(export)]
120pub struct DestinationMatch {
121    pub host: Option<DestinationMatchValue>, // exact or wildcard “*.tenant.com”
122    pub path_prefix: Option<DestinationMatchValue>, // e.g. “/billing/”
123    pub path_exact: Option<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, Builder, TS)]
127#[ts(export)]
128pub struct Destination {
129    pub name: String,
130    pub url: String,
131    pub health_check: Option<HealthCheck>,
132    #[serde(default)]
133    pub default: bool,
134    #[serde(default)]
135    pub r#match: Option<Vec<DestinationMatch>>,
136    #[serde(default)]
137    pub routes: Vec<Route>,
138    #[serde(default)]
139    pub middleware: Vec<Middleware>,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize, Builder, TS)]
143#[ts(export)]
144pub struct ServerConfig {
145    pub address: String,
146    pub force_path_parameter: bool,
147    pub log_upstream_response: bool,
148    pub global_request_middleware: Vec<String>,
149    pub global_response_middleware: Vec<String>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, Builder, TS)]
153#[ts(export)]
154pub struct Route {
155    pub path: String,
156    pub method: String,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, Default, Builder, TS)]
160#[ts(export)]
161pub struct CardinalConfig {
162    pub server: ServerConfig,
163    pub destinations: BTreeMap<String, Destination>,
164    #[serde(default)]
165    pub plugins: Vec<Plugin>,
166}
167
168impl Default for ServerConfig {
169    fn default() -> Self {
170        ServerConfig {
171            address: "0.0.0.0:1704".into(),
172            force_path_parameter: true,
173            log_upstream_response: true,
174            global_response_middleware: vec![],
175            global_request_middleware: vec![],
176        }
177    }
178}
179
180pub fn load_config(paths: &[String]) -> Result<CardinalConfig, ConfigError> {
181    let builder = get_config_builder(paths)?;
182    let config: CardinalConfig = builder.build()?.try_deserialize()?;
183    validate_config(&config)?;
184
185    Ok(config)
186}
187
188pub fn validate_config(config: &CardinalConfig) -> Result<(), ConfigError> {
189    if config
190        .server
191        .address
192        .parse::<std::net::SocketAddr>()
193        .is_err()
194    {
195        return Err(ConfigError::Message(format!(
196            "Invalid server address: {}",
197            config.server.address
198        )));
199    }
200
201    let all_plugin_names = config
202        .plugins
203        .iter()
204        .map(|p| p.name())
205        .collect::<Vec<&str>>();
206
207    for middleware in config.destinations.values().flat_map(|d| &d.middleware) {
208        if !all_plugin_names.contains(&middleware.name.as_str()) {
209            return Err(ConfigError::Message(format!(
210                "Middleware {} not found. {0} must be included in the list of plugins.",
211                middleware.name
212            )));
213        }
214    }
215
216    for destination in config.destinations.values() {
217        for route in &destination.routes {
218            if !route.path.starts_with('/') {
219                return Err(ConfigError::Message(format!(
220                    "Route path {} must start with a '/'.",
221                    route.path
222                )));
223            }
224        }
225    }
226
227    for destination in config.destinations.values() {
228        for route in &destination.routes {
229            if !route.method.eq("GET")
230                && !route.method.eq("POST")
231                && !route.method.eq("PUT")
232                && !route.method.eq("DELETE")
233                && !route.method.eq("PATCH")
234                && !route.method.eq("HEAD")
235                && !route.method.eq("OPTIONS")
236            {
237                return Err(ConfigError::Message(format!(
238                    "Route method {} is not supported.",
239                    route.method
240                )));
241            }
242        }
243    }
244
245    Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use serde::{Deserialize, Serialize};
252    use serde_json::{json, to_value};
253
254    #[test]
255    fn serialize_builtin_plugin() {
256        let plugin = Plugin::Builtin(BuiltinPlugin {
257            name: "Logger".to_string(),
258        });
259
260        let val = to_value(&plugin).unwrap();
261
262        let expected = json!({
263            "builtin": {
264                "name": "Logger"
265            }
266        });
267
268        assert_eq!(val, expected);
269    }
270
271    #[test]
272    fn serialize_wasm_plugin() {
273        let wasm_cfg = WasmPluginConfig {
274            name: "RateLimit".to_string(),
275            path: "plugins/ratelimit.wasm".to_string(),
276            memory_name: None,
277            handle_name: None,
278        };
279        let plugin = Plugin::Wasm(wasm_cfg);
280
281        let val = to_value(&plugin).unwrap();
282
283        let expected = json!({
284            "wasm": {
285                "name": "RateLimit",
286                "path": "plugins/ratelimit.wasm",
287                "memory_name": null,
288                "handle_name": null
289            }
290        });
291
292        assert_eq!(val, expected);
293    }
294
295    #[test]
296    fn toml_builtin_plugin() {
297        let plugin = Plugin::Builtin(BuiltinPlugin {
298            name: "Logger".to_string(),
299        });
300
301        let toml_str = toml::to_string(&plugin).unwrap();
302
303        let expected = r#"[builtin]
304name = "Logger"
305"#;
306
307        assert_eq!(toml_str, expected);
308    }
309
310    #[test]
311    fn toml_wasm_plugin() {
312        let wasm_cfg = WasmPluginConfig {
313            name: "RateLimit".to_string(),
314            path: "plugins/ratelimit.wasm".to_string(),
315            memory_name: None,
316            handle_name: None,
317        };
318        let plugin = Plugin::Wasm(wasm_cfg);
319
320        let toml_str = toml::to_string(&plugin).unwrap();
321
322        // None fields are skipped
323        let expected = r#"[wasm]
324name = "RateLimit"
325path = "plugins/ratelimit.wasm"
326"#;
327
328        assert_eq!(toml_str, expected);
329    }
330
331    #[test]
332    fn destination_match_value_string_roundtrip_json() {
333        let value = DestinationMatchValue::String("api.example.com".to_string());
334        let serialized = to_value(&value).unwrap();
335
336        assert_eq!(serialized, json!("api.example.com"));
337
338        let from_string: DestinationMatchValue =
339            serde_json::from_value(json!("api.example.com")).unwrap();
340        assert_eq!(from_string, value);
341    }
342
343    #[test]
344    fn destination_match_value_regex_roundtrip_json() {
345        let value = DestinationMatchValue::Regex {
346            regex: "^api\\.".to_string(),
347        };
348        let serialized = to_value(&value).unwrap();
349
350        assert_eq!(serialized, json!({"regex": "^api\\."}));
351
352        let decoded: DestinationMatchValue =
353            serde_json::from_value(json!({"regex": "^api\\."})).unwrap();
354        assert_eq!(decoded, value);
355    }
356
357    #[test]
358    fn destination_match_value_string_roundtrip_toml() {
359        let value = DestinationMatchValue::String("billing".to_string());
360        #[derive(Serialize, Deserialize, Debug, PartialEq)]
361        struct Wrapper {
362            value: DestinationMatchValue,
363        }
364
365        let toml_encoded = toml::to_string(&Wrapper {
366            value: value.clone(),
367        })
368        .unwrap();
369        assert_eq!(toml_encoded, "value = \"billing\"\n");
370
371        let decoded: Wrapper = toml::from_str(&toml_encoded).unwrap();
372        assert_eq!(decoded.value, value);
373    }
374
375    #[test]
376    fn destination_match_value_regex_roundtrip_toml() {
377        let value = DestinationMatchValue::Regex {
378            regex: "^/billing".to_string(),
379        };
380        #[derive(Serialize, Deserialize, Debug, PartialEq)]
381        struct Wrapper {
382            value: DestinationMatchValue,
383        }
384
385        let toml_encoded = toml::to_string(&Wrapper {
386            value: value.clone(),
387        })
388        .unwrap();
389        assert_eq!(toml_encoded, "[value]\nregex = \"^/billing\"\n");
390
391        let decoded: Wrapper = toml::from_str(&toml_encoded).unwrap();
392        assert_eq!(decoded.value, value);
393    }
394
395    #[test]
396    fn destination_struct_match_variants() {
397        let string_toml = r#"
398name = "customer_service"
399url = "https://svc.internal/api"
400
401[[match]]
402host = "support.example.com"
403path_prefix = "/helpdesk"
404"#;
405
406        let customer: Destination = toml::from_str(string_toml).unwrap();
407        let matcher = customer
408            .r#match
409            .as_ref()
410            .and_then(|entries| entries.first())
411            .expect("expected match section");
412        assert_eq!(
413            matcher.host,
414            Some(DestinationMatchValue::String("support.example.com".into()))
415        );
416        assert_eq!(
417            matcher.path_prefix,
418            Some(DestinationMatchValue::String("/helpdesk".into()))
419        );
420        assert_eq!(matcher.path_exact, None);
421
422        let regex_toml = r#"
423name = "billing"
424url = "https://billing.internal"
425
426[[match]]
427host = { regex = '^api\.(eu|us)\.example\.com$' }
428path_prefix = { regex = '^/billing/(v\d+)/' }
429"#;
430
431        let billing: Destination = toml::from_str(regex_toml).unwrap();
432        let matcher = billing
433            .r#match
434            .as_ref()
435            .and_then(|entries| entries.first())
436            .expect("expected match section");
437        assert_eq!(
438            matcher.host,
439            Some(DestinationMatchValue::Regex {
440                regex: r"^api\.(eu|us)\.example\.com$".into()
441            })
442        );
443        assert_eq!(
444            matcher.path_prefix,
445            Some(DestinationMatchValue::Regex {
446                regex: r"^/billing/(v\d+)/".into()
447            })
448        );
449        assert_eq!(matcher.path_exact, None);
450    }
451
452    #[test]
453    fn destination_match_toml_mixed_variants() {
454        #[derive(Serialize, Deserialize, Debug, PartialEq)]
455        struct ConfigHarness {
456            destinations: BTreeMap<String, DestinationHarness>,
457        }
458
459        #[derive(Serialize, Deserialize, Debug, PartialEq)]
460        struct DestinationHarness {
461            name: String,
462            url: String,
463            #[serde(rename = "match")]
464            matcher: Option<Vec<DestinationMatch>>,
465        }
466
467        impl DestinationHarness {
468            fn first_match(&self) -> &DestinationMatch {
469                self.matcher
470                    .as_ref()
471                    .and_then(|entries| entries.first())
472                    .expect("matcher section present")
473            }
474
475            fn match_count(&self) -> usize {
476                self.matcher.as_ref().map(|m| m.len()).unwrap_or(0)
477            }
478        }
479
480        let toml_source = r#"
481[destinations.customer_service]
482name = "customer_service"
483url = "https://svc.internal/api"
484
485[[destinations.customer_service.match]]
486host = "support.example.com"
487path_prefix = "/helpdesk"
488
489[[destinations.customer_service.match]]
490host = "support.example.com"
491path_prefix = { regex = '^/support' }
492
493[destinations.billing]
494name = "billing"
495url = "https://billing.internal"
496
497[[destinations.billing.match]]
498host = { regex = '^api\.(eu|us)\.example\.com$' }
499path_prefix = { regex = '^/billing/(v\d+)/' }
500"#;
501
502        let parsed: ConfigHarness = toml::from_str(toml_source).unwrap();
503
504        let customer = parsed.destinations.get("customer_service").unwrap();
505        assert_eq!(customer.match_count(), 2);
506        let customer_match = customer.first_match();
507        assert_eq!(
508            customer_match.host,
509            Some(DestinationMatchValue::String("support.example.com".into()))
510        );
511        assert_eq!(
512            customer_match.path_prefix,
513            Some(DestinationMatchValue::String("/helpdesk".into()))
514        );
515
516        let customer_matches = customer.matcher.as_ref().unwrap();
517        let second = &customer_matches[1];
518        assert_eq!(
519            second.path_prefix,
520            Some(DestinationMatchValue::Regex {
521                regex: String::from("^/support"),
522            })
523        );
524
525        let billing = parsed.destinations.get("billing").unwrap();
526        assert_eq!(billing.match_count(), 1);
527        let billing_match = billing.first_match();
528        assert_eq!(
529            billing_match.host,
530            Some(DestinationMatchValue::Regex {
531                regex: r"^api\.(eu|us)\.example\.com$".into()
532            })
533        );
534        assert_eq!(
535            billing_match.path_prefix,
536            Some(DestinationMatchValue::Regex {
537                regex: r"^/billing/(v\d+)/".into()
538            })
539        );
540
541        let serialized = toml::to_string(&parsed).unwrap();
542        let reparsed: ConfigHarness = toml::from_str(&serialized).unwrap();
543        assert_eq!(reparsed, parsed);
544    }
545
546    #[test]
547    fn destination_match_allows_empty_array() {
548        #[derive(Serialize, Deserialize, Debug, PartialEq)]
549        struct ConfigHarness {
550            destinations: BTreeMap<String, DestinationHarness>,
551        }
552
553        #[derive(Serialize, Deserialize, Debug, PartialEq)]
554        struct DestinationHarness {
555            name: String,
556            url: String,
557            #[serde(rename = "match")]
558            matcher: Option<Vec<DestinationMatch>>,
559        }
560
561        let toml_source = r#"
562[destinations.empty]
563name = "empty"
564url = "https://empty.internal"
565match = []
566"#;
567
568        let parsed: ConfigHarness = toml::from_str(toml_source).unwrap();
569        let destination = parsed.destinations.get("empty").unwrap();
570        assert!(destination
571            .matcher
572            .as_ref()
573            .map(|entries| entries.is_empty())
574            .unwrap_or(false));
575
576        let serialized = toml::to_string(&parsed).unwrap();
577        let reparsed: ConfigHarness = toml::from_str(&serialized).unwrap();
578        assert_eq!(reparsed, parsed);
579    }
580}