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>, pub path_prefix: Option<DestinationMatchValue>, 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 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}