mod rule;
pub mod tcp;
pub use rule::Rule;
use crate::config::RouterConfig;
use crate::error::{GatewayError, Result};
use http::HeaderMap;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ResolvedRoute {
pub router_name: String,
pub service_name: String,
pub middlewares: Vec<String>,
}
pub struct RouterTable {
routes: Vec<CompiledRoute>,
}
struct CompiledRoute {
name: String,
rule: Rule,
service: String,
entrypoints: Vec<String>,
middlewares: Vec<String>,
effective_priority: i64,
}
impl RouterTable {
pub fn from_config(routers: &HashMap<String, RouterConfig>) -> Result<Self> {
let mut routes: Vec<CompiledRoute> = Vec::new();
for (name, config) in routers {
let rule = Rule::parse(&config.rule).map_err(|e| {
GatewayError::Config(format!(
"Router '{}': invalid rule '{}': {}",
name, config.rule, e
))
})?;
let effective_priority = if config.priority > 0 {
config.priority as i64
} else {
config.rule.len() as i64
};
routes.push(CompiledRoute {
name: name.clone(),
rule,
service: config.service.clone(),
entrypoints: config.entrypoints.clone(),
middlewares: config.middlewares.clone(),
effective_priority,
});
}
routes.sort_by(|a, b| {
b.effective_priority
.cmp(&a.effective_priority)
.then_with(|| a.name.cmp(&b.name))
});
Ok(Self { routes })
}
pub fn match_request(
&self,
host: Option<&str>,
path: &str,
method: &str,
headers: &HeaderMap,
entrypoint: &str,
) -> Option<ResolvedRoute> {
for route in &self.routes {
if !route.entrypoints.is_empty() && !route.entrypoints.iter().any(|ep| ep == entrypoint)
{
continue;
}
if route.rule.matches(host, path, method, headers) {
return Some(ResolvedRoute {
router_name: route.name.clone(),
service_name: route.service.clone(),
middlewares: route.middlewares.clone(),
});
}
}
None
}
pub fn len(&self) -> usize {
self.routes.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.routes.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_routers() -> HashMap<String, RouterConfig> {
let mut routers = HashMap::new();
routers.insert(
"api".to_string(),
RouterConfig {
rule: "PathPrefix(`/api`)".to_string(),
service: "backend".to_string(),
entrypoints: vec!["web".to_string()],
middlewares: vec!["auth".to_string()],
priority: 0,
},
);
routers.insert(
"health".to_string(),
RouterConfig {
rule: "Path(`/health`)".to_string(),
service: "health-svc".to_string(),
entrypoints: vec![],
middlewares: vec![],
priority: -1, },
);
routers
}
#[test]
fn test_router_table_build() {
let routers = make_routers();
let table = RouterTable::from_config(&routers).unwrap();
assert_eq!(table.len(), 2);
}
#[test]
fn test_router_table_match_path() {
let routers = make_routers();
let table = RouterTable::from_config(&routers).unwrap();
let headers = http::HeaderMap::new();
let result = table.match_request(None, "/api/users", "GET", &headers, "web");
assert!(result.is_some());
let route = result.unwrap();
assert_eq!(route.service_name, "backend");
assert_eq!(route.middlewares, vec!["auth"]);
}
#[test]
fn test_router_table_match_exact_path() {
let routers = make_routers();
let table = RouterTable::from_config(&routers).unwrap();
let headers = http::HeaderMap::new();
let result = table.match_request(None, "/health", "GET", &headers, "web");
assert!(result.is_some());
assert_eq!(result.unwrap().service_name, "health-svc");
}
#[test]
fn test_router_table_no_match() {
let routers = make_routers();
let table = RouterTable::from_config(&routers).unwrap();
let headers = http::HeaderMap::new();
let result = table.match_request(None, "/unknown", "GET", &headers, "web");
assert!(result.is_none());
}
#[test]
fn test_router_table_entrypoint_filter() {
let routers = make_routers();
let table = RouterTable::from_config(&routers).unwrap();
let headers = http::HeaderMap::new();
let result = table.match_request(None, "/api/users", "GET", &headers, "other");
assert!(result.is_none());
}
#[test]
fn test_router_table_priority_order() {
let routers = make_routers();
let table = RouterTable::from_config(&routers).unwrap();
let headers = http::HeaderMap::new();
let result = table.match_request(None, "/health", "GET", &headers, "web");
assert!(result.is_some());
assert_eq!(result.unwrap().router_name, "health");
}
#[test]
fn test_router_table_specific_beats_catchall() {
let mut routers = HashMap::new();
routers.insert(
"catchall".to_string(),
RouterConfig {
rule: "PathPrefix(`/`)".to_string(),
service: "web".to_string(),
entrypoints: vec![],
middlewares: vec![],
priority: 0,
},
);
routers.insert(
"app".to_string(),
RouterConfig {
rule: "PathPrefix(`/apps/dr-test`)".to_string(),
service: "deep-research".to_string(),
entrypoints: vec![],
middlewares: vec![],
priority: 0,
},
);
let table = RouterTable::from_config(&routers).unwrap();
let headers = http::HeaderMap::new();
let r = table
.match_request(None, "/apps/dr-test/", "GET", &headers, "web")
.unwrap();
assert_eq!(r.service_name, "deep-research");
let r = table
.match_request(None, "/other", "GET", &headers, "web")
.unwrap();
assert_eq!(r.service_name, "web");
}
#[test]
fn test_router_table_explicit_priority_wins() {
let mut routers = HashMap::new();
routers.insert(
"long".to_string(),
RouterConfig {
rule: "PathPrefix(`/a/very/long/specific/path`)".to_string(),
service: "long".to_string(),
entrypoints: vec![],
middlewares: vec![],
priority: 0,
},
);
routers.insert(
"high".to_string(),
RouterConfig {
rule: "PathPrefix(`/a`)".to_string(),
service: "high".to_string(),
entrypoints: vec![],
middlewares: vec![],
priority: 1000,
},
);
let table = RouterTable::from_config(&routers).unwrap();
let headers = http::HeaderMap::new();
let r = table
.match_request(None, "/a/very/long/specific/path", "GET", &headers, "web")
.unwrap();
assert_eq!(r.service_name, "high");
}
#[test]
fn test_router_table_empty() {
let routers = HashMap::new();
let table = RouterTable::from_config(&routers).unwrap();
assert!(table.is_empty());
}
#[test]
fn test_router_table_invalid_rule() {
let mut routers = HashMap::new();
routers.insert(
"bad".to_string(),
RouterConfig {
rule: "InvalidMatcher(`test`)".to_string(),
service: "svc".to_string(),
entrypoints: vec![],
middlewares: vec![],
priority: 0,
},
);
let result = RouterTable::from_config(&routers);
assert!(result.is_err());
}
}