use std::collections::HashSet;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RoutingTable {
pub routes: Vec<Route>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Route {
#[serde(default)]
pub name: String,
pub match_rule: MatchRule,
pub target: RouteTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchRule {
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub path_prefix: Option<String>,
#[serde(default)]
pub source_cidr: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RouteTarget {
pub pool_id: String,
#[serde(default)]
pub instance_selector: InstanceSelector,
#[serde(default)]
pub target_port: Option<u16>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InstanceSelector {
#[default]
Any,
ByIp(String),
LeastConnections,
}
impl RoutingTable {
pub fn from_json(json: &str) -> Result<Self> {
let table: Self = serde_json::from_str(json)?;
table.validate()?;
Ok(table)
}
pub fn to_json(&self) -> Result<String> {
Ok(serde_json::to_string_pretty(self)?)
}
pub fn validate(&self) -> Result<()> {
let mut seen_ports: HashSet<u16> = HashSet::new();
for (i, route) in self.routes.iter().enumerate() {
if route.match_rule.port.is_none()
&& route.match_rule.path_prefix.is_none()
&& route.match_rule.source_cidr.is_none()
{
bail!(
"Route {} ({}) has no match criteria — at least one of port, path_prefix, or source_cidr must be set",
i,
route.name,
);
}
if let Some(port) = route.match_rule.port
&& !seen_ports.insert(port)
{
bail!(
"Route {} ({}) has duplicate port {}: another route already matches this port",
i,
route.name,
port,
);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_routing_table_serde_roundtrip() {
let table = RoutingTable {
routes: vec![
Route {
name: "slack-webhook".to_string(),
match_rule: MatchRule {
port: Some(8080),
path_prefix: Some("/webhook/slack".to_string()),
source_cidr: None,
},
target: RouteTarget {
pool_id: "workers".to_string(),
instance_selector: InstanceSelector::Any,
target_port: Some(8080),
},
},
Route {
name: "telegram-bot".to_string(),
match_rule: MatchRule {
port: Some(8443),
path_prefix: None,
source_cidr: Some("149.154.160.0/20".to_string()),
},
target: RouteTarget {
pool_id: "workers".to_string(),
instance_selector: InstanceSelector::ByIp("10.240.3.5".to_string()),
target_port: None,
},
},
],
};
let json = table.to_json().unwrap();
let parsed = RoutingTable::from_json(&json).unwrap();
assert_eq!(parsed.routes.len(), 2);
assert_eq!(parsed.routes[0].name, "slack-webhook");
assert_eq!(parsed.routes[0].match_rule.port, Some(8080));
assert_eq!(parsed.routes[1].target.pool_id, "workers");
}
#[test]
fn test_empty_routing_table() {
let table = RoutingTable::default();
assert!(table.validate().is_ok());
let json = table.to_json().unwrap();
let parsed = RoutingTable::from_json(&json).unwrap();
assert!(parsed.routes.is_empty());
}
#[test]
fn test_validation_rejects_empty_match() {
let table = RoutingTable {
routes: vec![Route {
name: "bad-route".to_string(),
match_rule: MatchRule {
port: None,
path_prefix: None,
source_cidr: None,
},
target: RouteTarget {
pool_id: "workers".to_string(),
instance_selector: InstanceSelector::Any,
target_port: None,
},
}],
};
let err = table.validate().unwrap_err();
assert!(err.to_string().contains("no match criteria"));
}
#[test]
fn test_validation_rejects_duplicate_port() {
let table = RoutingTable {
routes: vec![
Route {
name: "first".to_string(),
match_rule: MatchRule {
port: Some(8080),
path_prefix: None,
source_cidr: None,
},
target: RouteTarget {
pool_id: "workers".to_string(),
instance_selector: InstanceSelector::Any,
target_port: None,
},
},
Route {
name: "second".to_string(),
match_rule: MatchRule {
port: Some(8080),
path_prefix: None,
source_cidr: None,
},
target: RouteTarget {
pool_id: "other".to_string(),
instance_selector: InstanceSelector::Any,
target_port: None,
},
},
],
};
let err = table.validate().unwrap_err();
assert!(err.to_string().contains("duplicate port 8080"));
}
#[test]
fn test_instance_selector_serde() {
let variants = vec![
(InstanceSelector::Any, "\"any\""),
(
InstanceSelector::ByIp("10.0.0.1".to_string()),
"{\"by_ip\":\"10.0.0.1\"}",
),
(InstanceSelector::LeastConnections, "\"least_connections\""),
];
for (selector, expected) in &variants {
let json = serde_json::to_string(selector).unwrap();
assert_eq!(&json, expected);
let parsed: InstanceSelector = serde_json::from_str(&json).unwrap();
assert_eq!(&parsed, selector);
}
}
#[test]
fn test_instance_selector_default_is_any() {
assert_eq!(InstanceSelector::default(), InstanceSelector::Any);
}
#[test]
fn test_route_with_path_prefix_only() {
let table = RoutingTable {
routes: vec![Route {
name: "api".to_string(),
match_rule: MatchRule {
port: None,
path_prefix: Some("/api/v1".to_string()),
source_cidr: None,
},
target: RouteTarget {
pool_id: "workers".to_string(),
instance_selector: InstanceSelector::LeastConnections,
target_port: Some(3000),
},
}],
};
assert!(table.validate().is_ok());
}
#[test]
fn test_route_with_source_cidr_only() {
let table = RoutingTable {
routes: vec![Route {
name: "trusted".to_string(),
match_rule: MatchRule {
port: None,
path_prefix: None,
source_cidr: Some("10.0.0.0/8".to_string()),
},
target: RouteTarget {
pool_id: "internal".to_string(),
instance_selector: InstanceSelector::Any,
target_port: None,
},
}],
};
assert!(table.validate().is_ok());
}
#[test]
fn test_backward_compat_no_routing_table() {
let json = r#"{"routes": []}"#;
let parsed: RoutingTable = serde_json::from_str(json).unwrap();
assert!(parsed.routes.is_empty());
}
}