use dashmap::DashMap;
use prometheus::{register_int_counter, IntCounter};
use regex::Regex;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, LazyLock};
use tracing::{debug, info, trace, warn};
static ROUTE_CACHE_EVICTIONS: LazyLock<Option<IntCounter>> = LazyLock::new(|| {
register_int_counter!(
"zentinel_route_cache_evictions_total",
"Route-match cache entries evicted to enforce route-cache-size"
)
.ok()
});
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> {
Self::with_cache_size(routes, default_route, 1000)
}
pub fn with_cache_size(
routes: Vec<RouteConfig>,
default_route: Option<String>,
cache_size: usize,
) -> 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(cache_size)),
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
}
}
}
}
}
fn normalize_host(host: &str) -> String {
let without_port = if host.starts_with('[') {
match host.find(']') {
Some(end) => &host[..=end],
None => host,
}
} else {
host.split(':').next().unwrap_or(host)
};
let without_dot = without_port.strip_suffix('.').unwrap_or(without_port);
without_dot.to_ascii_lowercase()
}
impl HostMatcher {
fn parse(pattern: &str) -> Self {
let pattern = normalize_host(pattern);
if let Some(suffix) = pattern.strip_prefix("*.") {
Self::Wildcard {
suffix: suffix.to_string(),
}
} else if pattern.contains('*') || pattern.contains('[') {
match regex::RegexBuilder::new(&pattern)
.case_insensitive(true)
.build()
{
Ok(regex) => Self::Regex(regex),
Err(_) => {
warn!("Invalid host regex pattern: {}, using exact match", pattern);
Self::Exact(pattern)
}
}
} else {
Self::Exact(pattern)
}
}
fn matches(&self, host: &str) -> bool {
let host = normalize_host(host);
let host = host.as_str();
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).max(1); 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);
debug!(
evicted = evicted,
remaining = self.entries.len(),
max_size = self.max_size,
"Route cache at capacity; evicted entries"
);
if let Some(counter) = ROUTE_CACHE_EVICTIONS.as_ref() {
counter.inc_by(evicted as u64);
}
}
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}");
}
}
if let Some(ref params) = self.query_params {
let mut pairs: Vec<_> = params.iter().collect();
pairs.sort_by_key(|(k, _)| k.as_str());
for (k, v) in pairs {
let _ = write!(buf, "\t{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};
#[test]
fn route_cache_never_exceeds_max_size() {
let cache = RouteCache::new(10);
for i in 0..100 {
cache.insert(format!("key-{i}"), RouteId::new(format!("route-{i}")));
assert!(
cache.len() <= 10,
"route cache grew past max_size: {}",
cache.len()
);
}
}
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,
retry_policy: None,
static_files: None,
api_schema: None,
error_pages: None,
websocket: false,
websocket_inspection: false,
inference: None,
mcp: None,
a2a: 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"
);
}
}
#[cfg(test)]
mod probe_113 {
use super::*;
use zentinel_common::types::Priority;
use zentinel_config::{MatchCondition, RouteConfig};
fn route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
RouteConfig {
id: id.to_string(),
priority: Priority::NORMAL,
matches,
upstream: Some("u".to_string()),
service_type: zentinel_config::ServiceType::Web,
policies: Default::default(),
filters: vec![],
builtin_handler: None,
waf_enabled: false,
retry_policy: None,
static_files: None,
api_schema: None,
error_pages: None,
websocket: false,
websocket_inspection: false,
inference: None,
mcp: None,
a2a: None,
shadow: None,
fallback: None,
}
}
#[test]
fn probe_query_param_matching() {
let build = || {
RouteMatcher::new(
vec![
route(
"v2",
vec![
MatchCondition::PathPrefix("/api".into()),
MatchCondition::QueryParam {
name: "version".into(),
value: Some("v2".into()),
},
],
),
route("fallback", vec![MatchCondition::PathPrefix("/api".into())]),
],
None,
)
.unwrap()
};
let m = build();
let mut p1 = std::collections::HashMap::new();
p1.insert("version".to_string(), "v2".to_string());
let r = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(p1));
println!(
" A version=v2, fresh -> {:?}",
r.map(|r| r.route_id.to_string())
);
let m = build();
let mut p2 = std::collections::HashMap::new();
p2.insert("version".to_string(), "v1".to_string());
let r = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(p2));
println!(
" B version=v1, fresh -> {:?}",
r.map(|r| r.route_id.to_string())
);
let m = build();
let r = m.match_request(&RequestInfo::new("GET", "/api/x", "h"));
println!(
" C no params, fresh -> {:?}",
r.map(|r| r.route_id.to_string())
);
let m = build();
let mut pa = std::collections::HashMap::new();
pa.insert("version".to_string(), "v2".to_string());
let first = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(pa));
let mut pb = std::collections::HashMap::new();
pb.insert("version".to_string(), "v1".to_string());
let second = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(pb));
println!(
" D v2 then v1, shared -> first={:?} second={:?}",
first.map(|r| r.route_id.to_string()),
second.map(|r| r.route_id.to_string())
);
}
}
#[cfg(test)]
mod route_cache_correctness {
use super::*;
use std::collections::HashMap;
use zentinel_common::types::Priority;
use zentinel_config::{MatchCondition, RouteConfig};
fn route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
RouteConfig {
id: id.to_string(),
priority: Priority::NORMAL,
matches,
upstream: Some("u".to_string()),
service_type: zentinel_config::ServiceType::Web,
policies: Default::default(),
filters: vec![],
builtin_handler: None,
waf_enabled: false,
retry_policy: None,
static_files: None,
api_schema: None,
error_pages: None,
websocket: false,
websocket_inspection: false,
inference: None,
mcp: None,
a2a: None,
shadow: None,
fallback: None,
}
}
fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn matched(m: &RouteMatcher, req: &RequestInfo<'_>) -> Option<String> {
m.match_request(req).map(|r| r.route_id.to_string())
}
fn query_matcher() -> RouteMatcher {
RouteMatcher::new(
vec![
route(
"v2",
vec![
MatchCondition::PathPrefix("/api".into()),
MatchCondition::QueryParam {
name: "version".into(),
value: Some("v2".into()),
},
],
),
route("fallback", vec![MatchCondition::PathPrefix("/api".into())]),
],
None,
)
.unwrap()
}
#[test]
fn a_query_parameter_change_is_not_served_from_cache() {
let m = query_matcher();
let first = matched(
&m,
&RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v2")])),
);
let second = matched(
&m,
&RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v1")])),
);
assert_eq!(first.as_deref(), Some("v2"));
assert_eq!(
second.as_deref(),
Some("fallback"),
"the second request was served the first request's route from cache"
);
}
#[test]
fn the_reverse_order_is_also_correct() {
let m = query_matcher();
let first = matched(
&m,
&RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v1")])),
);
let second = matched(
&m,
&RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v2")])),
);
assert_eq!(first.as_deref(), Some("fallback"));
assert_eq!(second.as_deref(), Some("v2"));
}
#[test]
fn an_absent_query_parameter_is_distinct_from_a_present_one() {
let m = query_matcher();
assert_eq!(
matched(
&m,
&RequestInfo::new("GET", "/api/x", "h")
.with_query_params(params(&[("version", "v2")]))
)
.as_deref(),
Some("v2")
);
assert_eq!(
matched(&m, &RequestInfo::new("GET", "/api/x", "h")).as_deref(),
Some("fallback"),
"a request without the parameter must not inherit the parameterised route"
);
}
#[test]
fn parameter_order_does_not_affect_the_cache_key() {
let m = query_matcher();
let a = RequestInfo::new("GET", "/api/x", "h")
.with_query_params(params(&[("version", "v2"), ("page", "1")]));
let b = RequestInfo::new("GET", "/api/x", "h")
.with_query_params(params(&[("page", "1"), ("version", "v2")]));
assert_eq!(matched(&m, &a).as_deref(), Some("v2"));
assert_eq!(matched(&m, &b).as_deref(), Some("v2"));
assert_eq!(
m.cache_stats().entries,
1,
"parameter order changed the cache key"
);
}
#[test]
fn a_header_and_a_query_parameter_do_not_collide() {
let m = RouteMatcher::new(
vec![
route(
"by-header",
vec![
MatchCondition::PathPrefix("/x".into()),
MatchCondition::Header {
name: "tenant".into(),
value: Some("acme".into()),
},
],
),
route(
"by-query",
vec![
MatchCondition::PathPrefix("/x".into()),
MatchCondition::QueryParam {
name: "tenant".into(),
value: Some("acme".into()),
},
],
),
route("fallback", vec![MatchCondition::PathPrefix("/x".into())]),
],
None,
)
.unwrap();
let with_header =
RequestInfo::new("GET", "/x", "h").with_headers(params(&[("tenant", "acme")]));
let with_query =
RequestInfo::new("GET", "/x", "h").with_query_params(params(&[("tenant", "acme")]));
assert_eq!(matched(&m, &with_header).as_deref(), Some("by-header"));
assert_eq!(
matched(&m, &with_query).as_deref(),
Some("by-query"),
"a query parameter was served the header route's cache entry"
);
}
}
#[cfg(test)]
mod route_matching_edges {
use super::*;
use std::collections::HashMap;
use zentinel_common::types::Priority;
use zentinel_config::{MatchCondition, RouteConfig};
fn route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
route_with_priority(id, matches, Priority::NORMAL)
}
fn route_with_priority(
id: &str,
matches: Vec<MatchCondition>,
priority: Priority,
) -> RouteConfig {
RouteConfig {
id: id.to_string(),
priority,
matches,
upstream: Some("u".to_string()),
service_type: zentinel_config::ServiceType::Web,
policies: Default::default(),
filters: vec![],
builtin_handler: None,
waf_enabled: false,
retry_policy: None,
static_files: None,
api_schema: None,
error_pages: None,
websocket: false,
websocket_inspection: false,
inference: None,
mcp: None,
a2a: None,
shadow: None,
fallback: None,
}
}
fn matcher(routes: Vec<RouteConfig>) -> RouteMatcher {
RouteMatcher::new(routes, None).expect("routes should compile")
}
fn hit(m: &RouteMatcher, method: &str, path: &str, host: &str) -> Option<String> {
m.match_request(&RequestInfo::new(method, path, host))
.map(|r| r.route_id.to_string())
}
fn pairs(kv: &[(&str, &str)]) -> HashMap<String, String> {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn host_matching_ignores_case_on_both_sides() {
let m = matcher(vec![route(
"h",
vec![MatchCondition::Host("example.com".into())],
)]);
assert_eq!(hit(&m, "GET", "/", "example.com").as_deref(), Some("h"));
assert_eq!(hit(&m, "GET", "/", "EXAMPLE.COM").as_deref(), Some("h"));
assert_eq!(hit(&m, "GET", "/", "ExAmPlE.cOm").as_deref(), Some("h"));
let m = matcher(vec![route(
"h",
vec![MatchCondition::Host("Example.COM".into())],
)]);
assert_eq!(
hit(&m, "GET", "/", "example.com").as_deref(),
Some("h"),
"a mixed-case config host must still match"
);
}
#[test]
fn a_trailing_dot_does_not_bypass_a_host_route() {
let m = matcher(vec![
route_with_priority(
"restricted",
vec![
MatchCondition::Host("admin.example.com".into()),
MatchCondition::PathPrefix("/".into()),
],
Priority::HIGH,
),
route_with_priority(
"catchall",
vec![MatchCondition::PathPrefix("/".into())],
Priority::LOW,
),
]);
assert_eq!(
hit(&m, "GET", "/", "admin.example.com").as_deref(),
Some("restricted")
);
assert_eq!(
hit(&m, "GET", "/", "admin.example.com.").as_deref(),
Some("restricted"),
"a trailing dot must not route around the host match"
);
assert_eq!(
hit(&m, "GET", "/", "other.com").as_deref(),
Some("catchall")
);
}
#[test]
fn a_host_only_route_loses_to_a_path_catchall() {
let m = matcher(vec![
route(
"host_only",
vec![MatchCondition::Host("admin.example.com".into())],
),
route("catchall", vec![MatchCondition::PathPrefix("/".into())]),
]);
assert_eq!(
hit(&m, "GET", "/", "admin.example.com").as_deref(),
Some("catchall")
);
let m = matcher(vec![
route(
"host_and_path",
vec![
MatchCondition::Host("admin.example.com".into()),
MatchCondition::PathPrefix("/".into()),
],
),
route("catchall", vec![MatchCondition::PathPrefix("/".into())]),
]);
assert_eq!(
hit(&m, "GET", "/", "admin.example.com").as_deref(),
Some("host_and_path")
);
}
#[test]
fn a_port_is_ignored_when_matching_a_host() {
let m = matcher(vec![route(
"h",
vec![MatchCondition::Host("example.com".into())],
)]);
assert_eq!(
hit(&m, "GET", "/", "example.com:8443").as_deref(),
Some("h")
);
assert_eq!(hit(&m, "GET", "/", "example.com:80").as_deref(), Some("h"));
}
#[test]
fn an_ipv6_host_is_not_mangled_by_port_stripping() {
assert_eq!(normalize_host("[::1]:8080"), "[::1]");
assert_eq!(normalize_host("[2001:DB8::1]"), "[2001:db8::1]");
assert_eq!(normalize_host("[::1]"), "[::1]");
}
#[test]
fn wildcard_hosts_also_ignore_case_port_and_trailing_dot() {
let m = matcher(vec![route(
"w",
vec![MatchCondition::Host("*.example.com".into())],
)]);
for host in [
"api.example.com",
"API.EXAMPLE.COM",
"api.example.com:8443",
"api.example.com.",
] {
assert_eq!(
hit(&m, "GET", "/", host).as_deref(),
Some("w"),
"host {host}"
);
}
}
#[test]
fn a_wildcard_does_not_match_its_own_suffix() {
let m = matcher(vec![route(
"w",
vec![MatchCondition::Host("*.example.com".into())],
)]);
assert_eq!(hit(&m, "GET", "/", "example.com").as_deref(), None);
assert_eq!(hit(&m, "GET", "/", "notexample.com").as_deref(), None);
}
#[test]
fn hosts_are_alternatives_but_other_conditions_are_required() {
let m = matcher(vec![route(
"multi",
vec![
MatchCondition::Host("a.com".into()),
MatchCondition::Host("b.com".into()),
MatchCondition::PathPrefix("/x".into()),
],
)]);
assert_eq!(hit(&m, "GET", "/x", "a.com").as_deref(), Some("multi"));
assert_eq!(hit(&m, "GET", "/x", "b.com").as_deref(), Some("multi"));
assert_eq!(
hit(&m, "GET", "/x", "c.com").as_deref(),
None,
"an unlisted host must not match"
);
assert_eq!(
hit(&m, "GET", "/y", "a.com").as_deref(),
None,
"the path is still required"
);
}
#[test]
fn an_exact_path_does_not_match_children_or_a_trailing_slash() {
let m = matcher(vec![route(
"exact",
vec![MatchCondition::Path("/api".into())],
)]);
assert_eq!(hit(&m, "GET", "/api", "h").as_deref(), Some("exact"));
assert_eq!(hit(&m, "GET", "/api/", "h").as_deref(), None);
assert_eq!(hit(&m, "GET", "/api/users", "h").as_deref(), None);
assert_eq!(hit(&m, "GET", "/apiv2", "h").as_deref(), None);
}
#[test]
fn a_prefix_stops_at_a_segment_boundary() {
let m = matcher(vec![route(
"p",
vec![MatchCondition::PathPrefix("/api".into())],
)]);
assert_eq!(hit(&m, "GET", "/api", "h").as_deref(), Some("p"));
assert_eq!(hit(&m, "GET", "/api/", "h").as_deref(), Some("p"));
assert_eq!(hit(&m, "GET", "/api/users", "h").as_deref(), Some("p"));
assert_eq!(hit(&m, "GET", "/apikeys", "h").as_deref(), None);
assert_eq!(hit(&m, "GET", "/apiv2/users", "h").as_deref(), None);
}
#[test]
fn an_invalid_path_regex_is_rejected_at_load() {
for pattern in ["(unclosed", "a{2,1}", "[z-a]"] {
assert!(
RouteMatcher::new(
vec![route("r", vec![MatchCondition::PathRegex(pattern.into())])],
None,
)
.is_err(),
"{pattern:?} should be rejected"
);
}
}
#[test]
fn a_path_regex_is_anchored_as_written() {
let m = matcher(vec![route(
"r",
vec![MatchCondition::PathRegex("^/v[0-9]+/users$".into())],
)]);
assert_eq!(hit(&m, "GET", "/v1/users", "h").as_deref(), Some("r"));
assert_eq!(hit(&m, "GET", "/v42/users", "h").as_deref(), Some("r"));
assert_eq!(hit(&m, "GET", "/v1/users/1", "h").as_deref(), None);
assert_eq!(hit(&m, "GET", "/x/v1/users", "h").as_deref(), None);
}
#[test]
fn a_method_condition_accepts_any_of_its_methods() {
let m = matcher(vec![route(
"rw",
vec![MatchCondition::Method(vec!["POST".into(), "PUT".into()])],
)]);
assert_eq!(hit(&m, "POST", "/x", "h").as_deref(), Some("rw"));
assert_eq!(hit(&m, "PUT", "/x", "h").as_deref(), Some("rw"));
assert_eq!(hit(&m, "GET", "/x", "h").as_deref(), None);
assert_eq!(hit(&m, "DELETE", "/x", "h").as_deref(), None);
}
#[test]
fn a_header_condition_distinguishes_presence_from_value() {
let present = matcher(vec![route(
"any",
vec![MatchCondition::Header {
name: "x-key".into(),
value: None,
}],
)]);
let exact = matcher(vec![route(
"exact",
vec![MatchCondition::Header {
name: "x-key".into(),
value: Some("secret".into()),
}],
)]);
let with_other =
RequestInfo::new("GET", "/x", "h").with_headers(pairs(&[("x-key", "other")]));
let with_secret =
RequestInfo::new("GET", "/x", "h").with_headers(pairs(&[("x-key", "secret")]));
let without = RequestInfo::new("GET", "/x", "h").with_headers(pairs(&[("y", "1")]));
assert!(present.match_request(&with_other).is_some());
assert!(present.match_request(&without).is_none());
assert!(exact.match_request(&with_secret).is_some());
assert!(exact.match_request(&with_other).is_none());
}
#[test]
fn a_query_condition_distinguishes_presence_from_value() {
let present = matcher(vec![route(
"any",
vec![MatchCondition::QueryParam {
name: "debug".into(),
value: None,
}],
)]);
let exact = matcher(vec![route(
"exact",
vec![MatchCondition::QueryParam {
name: "v".into(),
value: Some("2".into()),
}],
)]);
assert!(present
.match_request(
&RequestInfo::new("GET", "/x", "h").with_query_params(pairs(&[("debug", "0")]))
)
.is_some());
assert!(present
.match_request(&RequestInfo::new("GET", "/x", "h"))
.is_none());
assert!(exact
.match_request(
&RequestInfo::new("GET", "/x", "h").with_query_params(pairs(&[("v", "2")]))
)
.is_some());
assert!(exact
.match_request(
&RequestInfo::new("GET", "/x", "h").with_query_params(pairs(&[("v", "1")]))
)
.is_none());
}
#[test]
fn higher_priority_wins_regardless_of_declaration_order() {
let m = matcher(vec![
route_with_priority(
"low",
vec![MatchCondition::PathPrefix("/a".into())],
Priority::LOW,
),
route_with_priority(
"high",
vec![MatchCondition::PathPrefix("/a".into())],
Priority::HIGH,
),
]);
assert_eq!(hit(&m, "GET", "/a", "h").as_deref(), Some("high"));
}
#[test]
fn equal_priorities_resolve_deterministically() {
for _ in 0..10 {
let m = matcher(vec![
route("first", vec![MatchCondition::PathPrefix("/a".into())]),
route("second", vec![MatchCondition::PathPrefix("/a".into())]),
]);
assert_eq!(hit(&m, "GET", "/a", "h").as_deref(), Some("first"));
}
}
#[test]
fn a_route_with_no_conditions_matches_anything() {
let m = matcher(vec![route("catchall", vec![])]);
assert_eq!(
hit(&m, "GET", "/anything", "h").as_deref(),
Some("catchall")
);
assert_eq!(
hit(&m, "POST", "/", "other.host").as_deref(),
Some("catchall")
);
}
#[test]
fn no_match_returns_none_rather_than_an_arbitrary_route() {
let m = matcher(vec![route(
"api",
vec![MatchCondition::PathPrefix("/api".into())],
)]);
assert_eq!(hit(&m, "GET", "/other", "h").as_deref(), None);
}
}