use dashmap::DashMap;
use regex::Regex;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::Arc;
use tracing::{debug, info, trace, warn};
use zentinel_common::types::Priority;
use zentinel_common::RouteId;
use zentinel_config::{MatchCondition, RouteConfig, RoutePolicies};
pub struct RouteMatcher {
routes: Vec<CompiledRoute>,
default_route: Option<RouteId>,
cache: Arc<RouteCache>,
needs_headers: bool,
needs_query_params: bool,
}
struct CompiledRoute {
config: Arc<RouteConfig>,
id: RouteId,
priority: Priority,
matchers: Vec<CompiledMatcher>,
}
enum CompiledMatcher {
Path(String),
PathPrefix(String),
PathRegex(Regex),
Host(HostMatcher),
Header { name: String, value: Option<String> },
Method(Vec<String>),
QueryParam { name: String, value: Option<String> },
}
enum HostMatcher {
Exact(String),
Wildcard { suffix: String },
Regex(Regex),
}
struct RouteCache {
entries: DashMap<String, RouteId>,
max_size: usize,
entry_count: AtomicUsize,
hits: AtomicU64,
misses: AtomicU64,
}
impl RouteMatcher {
pub fn new(
routes: Vec<RouteConfig>,
default_route: Option<String>,
) -> Result<Self, RouteError> {
info!(
route_count = routes.len(),
default_route = ?default_route,
"Initializing route matcher"
);
let mut compiled_routes = Vec::new();
for route in routes {
trace!(
route_id = %route.id,
priority = ?route.priority,
match_count = route.matches.len(),
"Compiling route"
);
let compiled = CompiledRoute::compile(route)?;
compiled_routes.push(compiled);
}
compiled_routes.sort_by(|a, b| {
b.priority
.cmp(&a.priority)
.then_with(|| b.specificity().cmp(&a.specificity()))
});
for (index, route) in compiled_routes.iter().enumerate() {
debug!(
route_id = %route.id,
order = index,
priority = ?route.priority,
specificity = route.specificity(),
"Route compiled and ordered"
);
}
let needs_headers = compiled_routes.iter().any(|r| {
r.matchers
.iter()
.any(|m| matches!(m, CompiledMatcher::Header { .. }))
});
let needs_query_params = compiled_routes.iter().any(|r| {
r.matchers
.iter()
.any(|m| matches!(m, CompiledMatcher::QueryParam { .. }))
});
info!(
compiled_routes = compiled_routes.len(),
needs_headers, needs_query_params, "Route matcher initialized"
);
Ok(Self {
routes: compiled_routes,
default_route: default_route.map(RouteId::new),
cache: Arc::new(RouteCache::new(1000)),
needs_headers,
needs_query_params,
})
}
#[inline]
pub fn needs_headers(&self) -> bool {
self.needs_headers
}
#[inline]
pub fn needs_query_params(&self) -> bool {
self.needs_query_params
}
pub fn match_request(&self, req: &RequestInfo<'_>) -> Option<RouteMatch> {
trace!(
method = %req.method,
path = %req.path,
host = %req.host,
"Starting route matching"
);
let cached = req.with_cache_key(|key| {
self.cache.get(key).map(|r| {
let route_id = r.clone();
drop(r);
route_id
})
});
if let Some(route_id) = cached {
trace!(
route_id = %route_id,
"Route cache hit"
);
if let Some(route) = self.find_route_by_id(&route_id) {
debug!(
route_id = %route_id,
method = %req.method,
path = %req.path,
source = "cache",
"Route matched from cache"
);
return Some(RouteMatch {
route_id,
config: route.config.clone(),
});
}
}
self.cache.record_miss();
trace!(
route_count = self.routes.len(),
"Cache miss, evaluating routes"
);
for (index, route) in self.routes.iter().enumerate() {
trace!(
route_id = %route.id,
route_index = index,
priority = ?route.priority,
matcher_count = route.matchers.len(),
"Evaluating route"
);
if route.matches(req) {
debug!(
route_id = %route.id,
method = %req.method,
path = %req.path,
host = %req.host,
priority = ?route.priority,
route_index = index,
"Route matched"
);
req.with_cache_key(|key| {
self.cache.insert(key.to_string(), route.id.clone());
});
trace!(
route_id = %route.id,
"Route added to cache"
);
return Some(RouteMatch {
route_id: route.id.clone(),
config: route.config.clone(),
});
}
}
if let Some(ref default_id) = self.default_route {
debug!(
route_id = %default_id,
method = %req.method,
path = %req.path,
"Using default route (no explicit match)"
);
if let Some(route) = self.find_route_by_id(default_id) {
return Some(RouteMatch {
route_id: default_id.clone(),
config: route.config.clone(),
});
}
}
debug!(
method = %req.method,
path = %req.path,
host = %req.host,
routes_evaluated = self.routes.len(),
"No route matched"
);
None
}
fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
self.routes.iter().find(|r| r.id == *id)
}
pub fn clear_cache(&self) {
self.cache.clear();
}
pub fn cache_stats(&self) -> CacheStats {
CacheStats {
entries: self.cache.len(),
max_size: self.cache.max_size,
hit_rate: self.cache.hit_rate(),
}
}
}
impl CompiledRoute {
fn compile(config: RouteConfig) -> Result<Self, RouteError> {
let mut matchers = Vec::new();
for condition in &config.matches {
let compiled = match condition {
MatchCondition::Path(path) => CompiledMatcher::Path(path.clone()),
MatchCondition::PathPrefix(prefix) => CompiledMatcher::PathPrefix(prefix.clone()),
MatchCondition::PathRegex(pattern) => {
let regex = Regex::new(pattern).map_err(|e| RouteError::InvalidRegex {
pattern: pattern.clone(),
error: e.to_string(),
})?;
CompiledMatcher::PathRegex(regex)
}
MatchCondition::Host(host) => CompiledMatcher::Host(HostMatcher::parse(host)),
MatchCondition::Header { name, value } => CompiledMatcher::Header {
name: name.to_lowercase(),
value: value.clone(),
},
MatchCondition::Method(methods) => {
CompiledMatcher::Method(methods.iter().map(|m| m.to_uppercase()).collect())
}
MatchCondition::QueryParam { name, value } => CompiledMatcher::QueryParam {
name: name.clone(),
value: value.clone(),
},
};
matchers.push(compiled);
}
Ok(Self {
id: RouteId::new(&config.id),
priority: config.priority,
config: Arc::new(config),
matchers,
})
}
fn matches(&self, req: &RequestInfo<'_>) -> bool {
let mut has_host_matchers = false;
let mut any_host_matched = false;
for matcher in &self.matchers {
match matcher {
CompiledMatcher::Host(_) => {
has_host_matchers = true;
if matcher.matches(req) {
any_host_matched = true;
}
}
_ => {
if !matcher.matches(req) {
trace!(
route_id = %self.id,
matcher_type = ?matcher,
path = %req.path,
"Matcher did not match"
);
return false;
}
}
}
}
if has_host_matchers && !any_host_matched {
trace!(
route_id = %self.id,
host = %req.host,
"No host matcher matched"
);
return false;
}
true
}
fn specificity(&self) -> u32 {
let mut path_score = 0u32;
let mut host_score = 0u32;
let mut condition_score = 0u32;
for matcher in &self.matchers {
match matcher {
CompiledMatcher::Path(_) => path_score = path_score.max(10000),
CompiledMatcher::PathRegex(_) => path_score = path_score.max(5000),
CompiledMatcher::PathPrefix(p) => {
path_score = path_score.max(1000 + p.len() as u32)
}
CompiledMatcher::Host(host) => {
let s = match host {
HostMatcher::Exact(_) => 70,
HostMatcher::Regex(_) => 60,
HostMatcher::Wildcard { .. } => 50,
};
host_score = host_score.max(s);
}
CompiledMatcher::Header { value, .. } => {
condition_score += if value.is_some() { 30 } else { 20 };
}
CompiledMatcher::Method(_) => condition_score += 10,
CompiledMatcher::QueryParam { value, .. } => {
condition_score += if value.is_some() { 25 } else { 15 };
}
}
}
path_score + host_score + condition_score
}
}
impl CompiledMatcher {
fn matches(&self, req: &RequestInfo<'_>) -> bool {
match self {
Self::Path(path) => req.path == *path,
Self::PathPrefix(prefix) => {
if !req.path.starts_with(prefix) {
return false;
}
prefix == "/"
|| req.path.len() == prefix.len()
|| prefix.ends_with('/')
|| req.path.as_bytes()[prefix.len()] == b'/'
|| req.path.as_bytes()[prefix.len()] == b'?'
}
Self::PathRegex(regex) => regex.is_match(req.path),
Self::Host(host_matcher) => host_matcher.matches(req.host),
Self::Header { name, value } => {
if let Some(header_value) = req.headers().get(name) {
value.as_ref().is_none_or(|v| header_value == v)
} else {
false
}
}
Self::Method(methods) => methods.iter().any(|m| m == req.method),
Self::QueryParam { name, value } => {
if let Some(param_value) = req.query_params().get(name) {
value.as_ref().is_none_or(|v| param_value == v)
} else {
false
}
}
}
}
}
impl HostMatcher {
fn parse(pattern: &str) -> Self {
if pattern.starts_with("*.") {
Self::Wildcard {
suffix: pattern[2..].to_string(),
}
} else if pattern.contains('*') || pattern.contains('[') {
if let Ok(regex) = Regex::new(pattern) {
Self::Regex(regex)
} else {
warn!("Invalid host regex pattern: {}, using exact match", pattern);
Self::Exact(pattern.to_string())
}
} else {
Self::Exact(pattern.to_string())
}
}
fn matches(&self, host: &str) -> bool {
let host = host.split(':').next().unwrap_or(host);
match self {
Self::Exact(pattern) => host == pattern,
Self::Wildcard { suffix } => {
host.ends_with(suffix)
&& host.len() > suffix.len()
&& host[..host.len() - suffix.len()].ends_with('.')
}
Self::Regex(regex) => regex.is_match(host),
}
}
}
impl RouteCache {
fn new(max_size: usize) -> Self {
Self {
entries: DashMap::with_capacity(max_size),
max_size,
entry_count: AtomicUsize::new(0),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
}
}
fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
let result = self.entries.get(key);
if result.is_some() {
self.hits.fetch_add(1, Ordering::Relaxed);
}
result
}
fn record_miss(&self) {
self.misses.fetch_add(1, Ordering::Relaxed);
}
fn hit_rate(&self) -> f64 {
let hits = self.hits.load(Ordering::Relaxed);
let misses = self.misses.load(Ordering::Relaxed);
let total = hits + misses;
if total == 0 {
0.0
} else {
hits as f64 / total as f64
}
}
fn insert(&self, key: String, route_id: RouteId) {
let current_count = self.entry_count.load(Ordering::Relaxed);
if current_count >= self.max_size {
self.evict_random();
}
if self.entries.insert(key, route_id).is_none() {
self.entry_count.fetch_add(1, Ordering::Relaxed);
}
}
fn evict_random(&self) {
let to_evict = self.max_size / 10; let mut evicted = 0;
self.entries.retain(|_, _| {
if evicted < to_evict {
evicted += 1;
false } else {
true }
});
self.entry_count
.store(self.entries.len(), Ordering::Relaxed);
}
fn len(&self) -> usize {
self.entries.len()
}
fn clear(&self) {
self.entries.clear();
self.entry_count.store(0, Ordering::Relaxed);
}
}
#[derive(Debug)]
pub struct RequestInfo<'a> {
pub method: &'a str,
pub path: &'a str,
pub host: &'a str,
headers: Option<HashMap<String, String>>,
query_params: Option<HashMap<String, String>>,
}
impl<'a> RequestInfo<'a> {
#[inline]
pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
Self {
method,
path,
host,
headers: None,
query_params: None,
}
}
#[inline]
pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
self.headers = Some(headers);
self
}
#[inline]
pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
self.query_params = Some(params);
self
}
#[inline]
pub fn headers(&self) -> &HashMap<String, String> {
static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
self.headers
.as_ref()
.unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
}
#[inline]
pub fn query_params(&self) -> &HashMap<String, String> {
static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
self.query_params
.as_ref()
.unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
}
fn with_cache_key<R>(&self, f: impl FnOnce(&str) -> R) -> R {
use std::cell::RefCell;
use std::fmt::Write;
thread_local! {
static BUF: RefCell<String> = RefCell::new(String::with_capacity(128));
}
BUF.with(|buf| {
let mut buf = buf.borrow_mut();
buf.clear();
let _ = write!(buf, "{}:{}:{}", self.method, self.host, self.path);
if let Some(ref headers) = self.headers {
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by_key(|(k, _)| k.as_str());
for (k, v) in pairs {
let _ = write!(buf, "\n{k}={v}");
}
}
f(&buf)
})
}
pub fn parse_query_params(path: &str) -> HashMap<String, String> {
let mut params = HashMap::new();
if let Some(query_start) = path.find('?') {
let query = &path[query_start + 1..];
for pair in query.split('&') {
if let Some(eq_pos) = pair.find('=') {
let key = &pair[..eq_pos];
let value = &pair[eq_pos + 1..];
params.insert(
urlencoding::decode(key)
.unwrap_or_else(|_| key.into())
.into_owned(),
urlencoding::decode(value)
.unwrap_or_else(|_| value.into())
.into_owned(),
);
} else {
params.insert(
urlencoding::decode(pair)
.unwrap_or_else(|_| pair.into())
.into_owned(),
String::new(),
);
}
}
}
params
}
pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
where
I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
{
let mut headers = HashMap::new();
for (name, value) in iter {
if let Ok(value_str) = value.to_str() {
headers.insert(name.as_str().to_lowercase(), value_str.to_string());
}
}
headers
}
}
#[derive(Debug, Clone)]
pub struct RouteMatch {
pub route_id: RouteId,
pub config: Arc<RouteConfig>,
}
impl RouteMatch {
#[inline]
pub fn policies(&self) -> &RoutePolicies {
&self.config.policies
}
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub entries: usize,
pub max_size: usize,
pub hit_rate: f64,
}
#[derive(Debug, thiserror::Error)]
pub enum RouteError {
#[error("Invalid regex pattern '{pattern}': {error}")]
InvalidRegex { pattern: String, error: String },
#[error("Invalid route configuration: {0}")]
InvalidConfig(String),
#[error("Duplicate route ID: {0}")]
DuplicateRouteId(String),
}
impl std::fmt::Debug for CompiledMatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Path(p) => write!(f, "Path({})", p),
Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
Self::PathRegex(_) => write!(f, "PathRegex(...)"),
Self::Host(_) => write!(f, "Host(...)"),
Self::Header { name, .. } => write!(f, "Header({})", name),
Self::Method(m) => write!(f, "Method({:?})", m),
Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use zentinel_common::types::Priority;
use zentinel_config::{MatchCondition, RouteConfig};
fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
RouteConfig {
id: id.to_string(),
priority: Priority::NORMAL,
matches,
upstream: Some("test_upstream".to_string()),
service_type: zentinel_config::ServiceType::Web,
policies: Default::default(),
filters: vec![],
builtin_handler: None,
waf_enabled: false,
circuit_breaker: None,
retry_policy: None,
static_files: None,
api_schema: None,
error_pages: None,
websocket: false,
websocket_inspection: false,
inference: None,
shadow: None,
fallback: None,
}
}
#[test]
fn test_path_matching() {
let routes = vec![
create_test_route(
"exact",
vec![MatchCondition::Path("/api/v1/users".to_string())],
),
create_test_route(
"prefix",
vec![MatchCondition::PathPrefix("/api/".to_string())],
),
];
let matcher = RouteMatcher::new(routes, None).unwrap();
let req = RequestInfo {
method: "GET",
path: "/api/v1/users",
host: "example.com",
headers: None,
query_params: None,
};
let result = matcher.match_request(&req).unwrap();
assert_eq!(result.route_id.as_str(), "exact");
}
#[test]
fn test_host_wildcard_matching() {
let routes = vec![create_test_route(
"wildcard",
vec![MatchCondition::Host("*.example.com".to_string())],
)];
let matcher = RouteMatcher::new(routes, None).unwrap();
let req = RequestInfo {
method: "GET",
path: "/",
host: "api.example.com",
headers: None,
query_params: None,
};
let result = matcher.match_request(&req).unwrap();
assert_eq!(result.route_id.as_str(), "wildcard");
}
#[test]
fn test_priority_ordering() {
let mut route1 =
create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
route1.priority = Priority::LOW;
let mut route2 =
create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
route2.priority = Priority::HIGH;
let routes = vec![route1, route2];
let matcher = RouteMatcher::new(routes, None).unwrap();
let req = RequestInfo {
method: "GET",
path: "/test",
host: "example.com",
headers: None,
query_params: None,
};
let result = matcher.match_request(&req).unwrap();
assert_eq!(result.route_id.as_str(), "high");
}
#[test]
fn test_query_param_parsing() {
let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
assert_eq!(params.get("foo"), Some(&"bar".to_string()));
assert_eq!(params.get("baz"), Some(&"qux".to_string()));
assert_eq!(params.get("empty"), Some(&"".to_string()));
}
#[test]
fn test_path_prefix_segment_boundary() {
let routes = vec![
create_test_route("v2", vec![MatchCondition::PathPrefix("/v2".to_string())]),
create_test_route(
"catch-all",
vec![MatchCondition::PathPrefix("/".to_string())],
),
];
let matcher = RouteMatcher::new(routes, None).unwrap();
let req = RequestInfo::new("GET", "/v2", "example.com");
assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
let req = RequestInfo::new("GET", "/v2/", "example.com");
assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
let req = RequestInfo::new("GET", "/v2/anything", "example.com");
assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
let req = RequestInfo::new("GET", "/v2example", "example.com");
assert_eq!(
matcher.match_request(&req).unwrap().route_id.as_str(),
"catch-all"
);
let req = RequestInfo::new("GET", "/v2?foo=bar", "example.com");
assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
}
#[test]
fn test_header_matching_with_specificity() {
let routes = vec![
create_test_route(
"catch-all",
vec![MatchCondition::PathPrefix("/".to_string())],
),
create_test_route(
"header-v2",
vec![
MatchCondition::Header {
name: "version".to_string(),
value: Some("two".to_string()),
},
MatchCondition::PathPrefix("/".to_string()),
],
),
];
let matcher = RouteMatcher::new(routes, None).unwrap();
let req = RequestInfo::new("GET", "/", "example.com");
assert_eq!(
matcher.match_request(&req).unwrap().route_id.as_str(),
"catch-all"
);
let mut headers = HashMap::new();
headers.insert("version".to_string(), "two".to_string());
let req = RequestInfo::new("GET", "/", "example.com").with_headers(headers);
assert_eq!(
matcher.match_request(&req).unwrap().route_id.as_str(),
"header-v2"
);
}
}