1use dashmap::DashMap;
8use regex::Regex;
9use std::collections::HashMap;
10use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
11use std::sync::Arc;
12use tracing::{debug, info, trace, warn};
13
14use zentinel_common::types::Priority;
15use zentinel_common::RouteId;
16use zentinel_config::{MatchCondition, RouteConfig, RoutePolicies};
17
18pub struct RouteMatcher {
20 routes: Vec<CompiledRoute>,
22 default_route: Option<RouteId>,
24 cache: Arc<RouteCache>,
26 needs_headers: bool,
28 needs_query_params: bool,
30}
31
32struct CompiledRoute {
34 config: Arc<RouteConfig>,
36 id: RouteId,
38 priority: Priority,
40 matchers: Vec<CompiledMatcher>,
42}
43
44enum CompiledMatcher {
46 Path(String),
48 PathPrefix(String),
50 PathRegex(Regex),
52 Host(HostMatcher),
54 Header { name: String, value: Option<String> },
56 Method(Vec<String>),
58 QueryParam { name: String, value: Option<String> },
60}
61
62enum HostMatcher {
64 Exact(String),
66 Wildcard { suffix: String },
68 Regex(Regex),
70}
71
72struct RouteCache {
74 entries: DashMap<String, RouteId>,
76 max_size: usize,
78 entry_count: AtomicUsize,
80 hits: AtomicU64,
82 misses: AtomicU64,
84}
85
86impl RouteMatcher {
87 pub fn new(
89 routes: Vec<RouteConfig>,
90 default_route: Option<String>,
91 ) -> Result<Self, RouteError> {
92 info!(
93 route_count = routes.len(),
94 default_route = ?default_route,
95 "Initializing route matcher"
96 );
97
98 let mut compiled_routes = Vec::new();
99
100 for route in routes {
101 trace!(
102 route_id = %route.id,
103 priority = ?route.priority,
104 match_count = route.matches.len(),
105 "Compiling route"
106 );
107 let compiled = CompiledRoute::compile(route)?;
108 compiled_routes.push(compiled);
109 }
110
111 compiled_routes.sort_by(|a, b| {
113 b.priority
114 .cmp(&a.priority)
115 .then_with(|| b.specificity().cmp(&a.specificity()))
116 });
117
118 for (index, route) in compiled_routes.iter().enumerate() {
120 debug!(
121 route_id = %route.id,
122 order = index,
123 priority = ?route.priority,
124 specificity = route.specificity(),
125 "Route compiled and ordered"
126 );
127 }
128
129 let needs_headers = compiled_routes.iter().any(|r| {
131 r.matchers
132 .iter()
133 .any(|m| matches!(m, CompiledMatcher::Header { .. }))
134 });
135 let needs_query_params = compiled_routes.iter().any(|r| {
136 r.matchers
137 .iter()
138 .any(|m| matches!(m, CompiledMatcher::QueryParam { .. }))
139 });
140
141 info!(
142 compiled_routes = compiled_routes.len(),
143 needs_headers, needs_query_params, "Route matcher initialized"
144 );
145
146 Ok(Self {
147 routes: compiled_routes,
148 default_route: default_route.map(RouteId::new),
149 cache: Arc::new(RouteCache::new(1000)),
150 needs_headers,
151 needs_query_params,
152 })
153 }
154
155 #[inline]
157 pub fn needs_headers(&self) -> bool {
158 self.needs_headers
159 }
160
161 #[inline]
163 pub fn needs_query_params(&self) -> bool {
164 self.needs_query_params
165 }
166
167 pub fn match_request(&self, req: &RequestInfo<'_>) -> Option<RouteMatch> {
169 trace!(
170 method = %req.method,
171 path = %req.path,
172 host = %req.host,
173 "Starting route matching"
174 );
175
176 let cached = req.with_cache_key(|key| {
178 self.cache.get(key).map(|r| {
179 let route_id = r.clone();
180 drop(r);
181 route_id
182 })
183 });
184 if let Some(route_id) = cached {
185 trace!(
186 route_id = %route_id,
187 "Route cache hit"
188 );
189 if let Some(route) = self.find_route_by_id(&route_id) {
190 debug!(
191 route_id = %route_id,
192 method = %req.method,
193 path = %req.path,
194 source = "cache",
195 "Route matched from cache"
196 );
197 return Some(RouteMatch {
198 route_id,
199 config: route.config.clone(),
200 });
201 }
202 }
203
204 self.cache.record_miss();
206
207 trace!(
208 route_count = self.routes.len(),
209 "Cache miss, evaluating routes"
210 );
211
212 for (index, route) in self.routes.iter().enumerate() {
214 trace!(
215 route_id = %route.id,
216 route_index = index,
217 priority = ?route.priority,
218 matcher_count = route.matchers.len(),
219 "Evaluating route"
220 );
221
222 if route.matches(req) {
223 debug!(
224 route_id = %route.id,
225 method = %req.method,
226 path = %req.path,
227 host = %req.host,
228 priority = ?route.priority,
229 route_index = index,
230 "Route matched"
231 );
232
233 req.with_cache_key(|key| {
235 self.cache.insert(key.to_string(), route.id.clone());
236 });
237
238 trace!(
239 route_id = %route.id,
240 "Route added to cache"
241 );
242
243 return Some(RouteMatch {
244 route_id: route.id.clone(),
245 config: route.config.clone(),
246 });
247 }
248 }
249
250 if let Some(ref default_id) = self.default_route {
252 debug!(
253 route_id = %default_id,
254 method = %req.method,
255 path = %req.path,
256 "Using default route (no explicit match)"
257 );
258 if let Some(route) = self.find_route_by_id(default_id) {
259 return Some(RouteMatch {
260 route_id: default_id.clone(),
261 config: route.config.clone(),
262 });
263 }
264 }
265
266 debug!(
267 method = %req.method,
268 path = %req.path,
269 host = %req.host,
270 routes_evaluated = self.routes.len(),
271 "No route matched"
272 );
273 None
274 }
275
276 fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
278 self.routes.iter().find(|r| r.id == *id)
279 }
280
281 pub fn clear_cache(&self) {
283 self.cache.clear();
284 }
285
286 pub fn cache_stats(&self) -> CacheStats {
288 CacheStats {
289 entries: self.cache.len(),
290 max_size: self.cache.max_size,
291 hit_rate: self.cache.hit_rate(),
292 }
293 }
294}
295
296impl CompiledRoute {
297 fn compile(config: RouteConfig) -> Result<Self, RouteError> {
299 let mut matchers = Vec::new();
300
301 for condition in &config.matches {
302 let compiled = match condition {
303 MatchCondition::Path(path) => CompiledMatcher::Path(path.clone()),
304 MatchCondition::PathPrefix(prefix) => CompiledMatcher::PathPrefix(prefix.clone()),
305 MatchCondition::PathRegex(pattern) => {
306 let regex = Regex::new(pattern).map_err(|e| RouteError::InvalidRegex {
307 pattern: pattern.clone(),
308 error: e.to_string(),
309 })?;
310 CompiledMatcher::PathRegex(regex)
311 }
312 MatchCondition::Host(host) => CompiledMatcher::Host(HostMatcher::parse(host)),
313 MatchCondition::Header { name, value } => CompiledMatcher::Header {
314 name: name.to_lowercase(),
315 value: value.clone(),
316 },
317 MatchCondition::Method(methods) => {
318 CompiledMatcher::Method(methods.iter().map(|m| m.to_uppercase()).collect())
319 }
320 MatchCondition::QueryParam { name, value } => CompiledMatcher::QueryParam {
321 name: name.clone(),
322 value: value.clone(),
323 },
324 };
325 matchers.push(compiled);
326 }
327
328 Ok(Self {
329 id: RouteId::new(&config.id),
330 priority: config.priority,
331 config: Arc::new(config),
332 matchers,
333 })
334 }
335
336 fn matches(&self, req: &RequestInfo<'_>) -> bool {
342 let mut has_host_matchers = false;
344 let mut any_host_matched = false;
345
346 for matcher in &self.matchers {
347 match matcher {
348 CompiledMatcher::Host(_) => {
349 has_host_matchers = true;
350 if matcher.matches(req) {
351 any_host_matched = true;
352 }
353 }
354 _ => {
355 if !matcher.matches(req) {
356 trace!(
357 route_id = %self.id,
358 matcher_type = ?matcher,
359 path = %req.path,
360 "Matcher did not match"
361 );
362 return false;
363 }
364 }
365 }
366 }
367
368 if has_host_matchers && !any_host_matched {
370 trace!(
371 route_id = %self.id,
372 host = %req.host,
373 "No host matcher matched"
374 );
375 return false;
376 }
377
378 true
379 }
380
381 fn specificity(&self) -> u32 {
391 let mut path_score = 0u32;
392 let mut host_score = 0u32;
393 let mut condition_score = 0u32;
394
395 for matcher in &self.matchers {
396 match matcher {
397 CompiledMatcher::Path(_) => path_score = path_score.max(10000),
398 CompiledMatcher::PathRegex(_) => path_score = path_score.max(5000),
399 CompiledMatcher::PathPrefix(p) => {
400 path_score = path_score.max(1000 + p.len() as u32)
401 }
402 CompiledMatcher::Host(host) => {
403 let s = match host {
404 HostMatcher::Exact(_) => 70,
405 HostMatcher::Regex(_) => 60,
406 HostMatcher::Wildcard { .. } => 50,
407 };
408 host_score = host_score.max(s);
409 }
410 CompiledMatcher::Header { value, .. } => {
411 condition_score += if value.is_some() { 30 } else { 20 };
412 }
413 CompiledMatcher::Method(_) => condition_score += 10,
414 CompiledMatcher::QueryParam { value, .. } => {
415 condition_score += if value.is_some() { 25 } else { 15 };
416 }
417 }
418 }
419
420 path_score + host_score + condition_score
421 }
422}
423
424impl CompiledMatcher {
425 fn matches(&self, req: &RequestInfo<'_>) -> bool {
427 match self {
428 Self::Path(path) => req.path == *path,
429 Self::PathPrefix(prefix) => {
430 if !req.path.starts_with(prefix) {
431 return false;
432 }
433 prefix == "/"
436 || req.path.len() == prefix.len()
437 || prefix.ends_with('/')
438 || req.path.as_bytes()[prefix.len()] == b'/'
439 || req.path.as_bytes()[prefix.len()] == b'?'
440 }
441 Self::PathRegex(regex) => regex.is_match(req.path),
442 Self::Host(host_matcher) => host_matcher.matches(req.host),
443 Self::Header { name, value } => {
444 if let Some(header_value) = req.headers().get(name) {
445 value.as_ref().is_none_or(|v| header_value == v)
446 } else {
447 false
448 }
449 }
450 Self::Method(methods) => methods.iter().any(|m| m == req.method),
451 Self::QueryParam { name, value } => {
452 if let Some(param_value) = req.query_params().get(name) {
453 value.as_ref().is_none_or(|v| param_value == v)
454 } else {
455 false
456 }
457 }
458 }
459 }
460}
461
462impl HostMatcher {
463 fn parse(pattern: &str) -> Self {
465 if pattern.starts_with("*.") {
466 Self::Wildcard {
468 suffix: pattern[2..].to_string(),
469 }
470 } else if pattern.contains('*') || pattern.contains('[') {
471 if let Ok(regex) = Regex::new(pattern) {
473 Self::Regex(regex)
474 } else {
475 warn!("Invalid host regex pattern: {}, using exact match", pattern);
477 Self::Exact(pattern.to_string())
478 }
479 } else {
480 Self::Exact(pattern.to_string())
482 }
483 }
484
485 fn matches(&self, host: &str) -> bool {
490 let host = host.split(':').next().unwrap_or(host);
492 match self {
493 Self::Exact(pattern) => host == pattern,
494 Self::Wildcard { suffix } => {
495 host.ends_with(suffix)
496 && host.len() > suffix.len()
497 && host[..host.len() - suffix.len()].ends_with('.')
498 }
499 Self::Regex(regex) => regex.is_match(host),
500 }
501 }
502}
503
504impl RouteCache {
505 fn new(max_size: usize) -> Self {
507 Self {
508 entries: DashMap::with_capacity(max_size),
509 max_size,
510 entry_count: AtomicUsize::new(0),
511 hits: AtomicU64::new(0),
512 misses: AtomicU64::new(0),
513 }
514 }
515
516 fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
518 let result = self.entries.get(key);
519 if result.is_some() {
520 self.hits.fetch_add(1, Ordering::Relaxed);
521 }
522 result
523 }
524
525 fn record_miss(&self) {
527 self.misses.fetch_add(1, Ordering::Relaxed);
528 }
529
530 fn hit_rate(&self) -> f64 {
532 let hits = self.hits.load(Ordering::Relaxed);
533 let misses = self.misses.load(Ordering::Relaxed);
534 let total = hits + misses;
535 if total == 0 {
536 0.0
537 } else {
538 hits as f64 / total as f64
539 }
540 }
541
542 fn insert(&self, key: String, route_id: RouteId) {
544 let current_count = self.entry_count.load(Ordering::Relaxed);
546 if current_count >= self.max_size {
547 self.evict_random();
550 }
551
552 if self.entries.insert(key, route_id).is_none() {
553 self.entry_count.fetch_add(1, Ordering::Relaxed);
555 }
556 }
557
558 fn evict_random(&self) {
560 let to_evict = self.max_size / 10; let mut evicted = 0;
562
563 self.entries.retain(|_, _| {
565 if evicted < to_evict {
566 evicted += 1;
567 false } else {
569 true }
571 });
572
573 self.entry_count
575 .store(self.entries.len(), Ordering::Relaxed);
576 }
577
578 fn len(&self) -> usize {
580 self.entries.len()
581 }
582
583 fn clear(&self) {
585 self.entries.clear();
586 self.entry_count.store(0, Ordering::Relaxed);
587 }
588}
589
590#[derive(Debug)]
592pub struct RequestInfo<'a> {
593 pub method: &'a str,
595 pub path: &'a str,
597 pub host: &'a str,
599 headers: Option<HashMap<String, String>>,
601 query_params: Option<HashMap<String, String>>,
603}
604
605impl<'a> RequestInfo<'a> {
606 #[inline]
608 pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
609 Self {
610 method,
611 path,
612 host,
613 headers: None,
614 query_params: None,
615 }
616 }
617
618 #[inline]
620 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
621 self.headers = Some(headers);
622 self
623 }
624
625 #[inline]
627 pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
628 self.query_params = Some(params);
629 self
630 }
631
632 #[inline]
634 pub fn headers(&self) -> &HashMap<String, String> {
635 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
636 self.headers
637 .as_ref()
638 .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
639 }
640
641 #[inline]
643 pub fn query_params(&self) -> &HashMap<String, String> {
644 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
645 self.query_params
646 .as_ref()
647 .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
648 }
649
650 fn with_cache_key<R>(&self, f: impl FnOnce(&str) -> R) -> R {
653 use std::cell::RefCell;
654 use std::fmt::Write;
655
656 thread_local! {
657 static BUF: RefCell<String> = RefCell::new(String::with_capacity(128));
658 }
659
660 BUF.with(|buf| {
661 let mut buf = buf.borrow_mut();
662 buf.clear();
663 let _ = write!(buf, "{}:{}:{}", self.method, self.host, self.path);
664 if let Some(ref headers) = self.headers {
667 let mut pairs: Vec<_> = headers.iter().collect();
668 pairs.sort_by_key(|(k, _)| k.as_str());
669 for (k, v) in pairs {
670 let _ = write!(buf, "\n{k}={v}");
671 }
672 }
673 f(&buf)
674 })
675 }
676
677 pub fn parse_query_params(path: &str) -> HashMap<String, String> {
679 let mut params = HashMap::new();
680 if let Some(query_start) = path.find('?') {
681 let query = &path[query_start + 1..];
682 for pair in query.split('&') {
683 if let Some(eq_pos) = pair.find('=') {
684 let key = &pair[..eq_pos];
685 let value = &pair[eq_pos + 1..];
686 params.insert(
687 urlencoding::decode(key)
688 .unwrap_or_else(|_| key.into())
689 .into_owned(),
690 urlencoding::decode(value)
691 .unwrap_or_else(|_| value.into())
692 .into_owned(),
693 );
694 } else {
695 params.insert(
696 urlencoding::decode(pair)
697 .unwrap_or_else(|_| pair.into())
698 .into_owned(),
699 String::new(),
700 );
701 }
702 }
703 }
704 params
705 }
706
707 pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
709 where
710 I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
711 {
712 let mut headers = HashMap::new();
713 for (name, value) in iter {
714 if let Ok(value_str) = value.to_str() {
715 headers.insert(name.as_str().to_lowercase(), value_str.to_string());
716 }
717 }
718 headers
719 }
720}
721
722#[derive(Debug, Clone)]
724pub struct RouteMatch {
725 pub route_id: RouteId,
726 pub config: Arc<RouteConfig>,
727}
728
729impl RouteMatch {
730 #[inline]
732 pub fn policies(&self) -> &RoutePolicies {
733 &self.config.policies
734 }
735}
736
737#[derive(Debug, Clone)]
739pub struct CacheStats {
740 pub entries: usize,
741 pub max_size: usize,
742 pub hit_rate: f64,
743}
744
745#[derive(Debug, thiserror::Error)]
747pub enum RouteError {
748 #[error("Invalid regex pattern '{pattern}': {error}")]
749 InvalidRegex { pattern: String, error: String },
750
751 #[error("Invalid route configuration: {0}")]
752 InvalidConfig(String),
753
754 #[error("Duplicate route ID: {0}")]
755 DuplicateRouteId(String),
756}
757
758impl std::fmt::Debug for CompiledMatcher {
759 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
760 match self {
761 Self::Path(p) => write!(f, "Path({})", p),
762 Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
763 Self::PathRegex(_) => write!(f, "PathRegex(...)"),
764 Self::Host(_) => write!(f, "Host(...)"),
765 Self::Header { name, .. } => write!(f, "Header({})", name),
766 Self::Method(m) => write!(f, "Method({:?})", m),
767 Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
768 }
769 }
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775 use zentinel_common::types::Priority;
776 use zentinel_config::{MatchCondition, RouteConfig};
777
778 fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
779 RouteConfig {
780 id: id.to_string(),
781 priority: Priority::NORMAL,
782 matches,
783 upstream: Some("test_upstream".to_string()),
784 service_type: zentinel_config::ServiceType::Web,
785 policies: Default::default(),
786 filters: vec![],
787 builtin_handler: None,
788 waf_enabled: false,
789 circuit_breaker: None,
790 retry_policy: None,
791 static_files: None,
792 api_schema: None,
793 error_pages: None,
794 websocket: false,
795 websocket_inspection: false,
796 inference: None,
797 shadow: None,
798 fallback: None,
799 }
800 }
801
802 #[test]
803 fn test_path_matching() {
804 let routes = vec![
805 create_test_route(
806 "exact",
807 vec![MatchCondition::Path("/api/v1/users".to_string())],
808 ),
809 create_test_route(
810 "prefix",
811 vec![MatchCondition::PathPrefix("/api/".to_string())],
812 ),
813 ];
814
815 let matcher = RouteMatcher::new(routes, None).unwrap();
816
817 let req = RequestInfo {
818 method: "GET",
819 path: "/api/v1/users",
820 host: "example.com",
821 headers: None,
822 query_params: None,
823 };
824
825 let result = matcher.match_request(&req).unwrap();
826 assert_eq!(result.route_id.as_str(), "exact");
827 }
828
829 #[test]
830 fn test_host_wildcard_matching() {
831 let routes = vec![create_test_route(
832 "wildcard",
833 vec![MatchCondition::Host("*.example.com".to_string())],
834 )];
835
836 let matcher = RouteMatcher::new(routes, None).unwrap();
837
838 let req = RequestInfo {
839 method: "GET",
840 path: "/",
841 host: "api.example.com",
842 headers: None,
843 query_params: None,
844 };
845
846 let result = matcher.match_request(&req).unwrap();
847 assert_eq!(result.route_id.as_str(), "wildcard");
848 }
849
850 #[test]
851 fn test_priority_ordering() {
852 let mut route1 =
853 create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
854 route1.priority = Priority::LOW;
855
856 let mut route2 =
857 create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
858 route2.priority = Priority::HIGH;
859
860 let routes = vec![route1, route2];
861 let matcher = RouteMatcher::new(routes, None).unwrap();
862
863 let req = RequestInfo {
864 method: "GET",
865 path: "/test",
866 host: "example.com",
867 headers: None,
868 query_params: None,
869 };
870
871 let result = matcher.match_request(&req).unwrap();
872 assert_eq!(result.route_id.as_str(), "high");
873 }
874
875 #[test]
876 fn test_query_param_parsing() {
877 let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
878 assert_eq!(params.get("foo"), Some(&"bar".to_string()));
879 assert_eq!(params.get("baz"), Some(&"qux".to_string()));
880 assert_eq!(params.get("empty"), Some(&"".to_string()));
881 }
882
883 #[test]
884 fn test_path_prefix_segment_boundary() {
885 let routes = vec![
886 create_test_route("v2", vec![MatchCondition::PathPrefix("/v2".to_string())]),
887 create_test_route(
888 "catch-all",
889 vec![MatchCondition::PathPrefix("/".to_string())],
890 ),
891 ];
892
893 let matcher = RouteMatcher::new(routes, None).unwrap();
894
895 let req = RequestInfo::new("GET", "/v2", "example.com");
897 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
898
899 let req = RequestInfo::new("GET", "/v2/", "example.com");
901 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
902
903 let req = RequestInfo::new("GET", "/v2/anything", "example.com");
905 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
906
907 let req = RequestInfo::new("GET", "/v2example", "example.com");
909 assert_eq!(
910 matcher.match_request(&req).unwrap().route_id.as_str(),
911 "catch-all"
912 );
913
914 let req = RequestInfo::new("GET", "/v2?foo=bar", "example.com");
916 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
917 }
918
919 #[test]
920 fn test_header_matching_with_specificity() {
921 let routes = vec![
922 create_test_route(
923 "catch-all",
924 vec![MatchCondition::PathPrefix("/".to_string())],
925 ),
926 create_test_route(
927 "header-v2",
928 vec![
929 MatchCondition::Header {
930 name: "version".to_string(),
931 value: Some("two".to_string()),
932 },
933 MatchCondition::PathPrefix("/".to_string()),
934 ],
935 ),
936 ];
937
938 let matcher = RouteMatcher::new(routes, None).unwrap();
939
940 let req = RequestInfo::new("GET", "/", "example.com");
942 assert_eq!(
943 matcher.match_request(&req).unwrap().route_id.as_str(),
944 "catch-all"
945 );
946
947 let mut headers = HashMap::new();
949 headers.insert("version".to_string(), "two".to_string());
950 let req = RequestInfo::new("GET", "/", "example.com").with_headers(headers);
951 assert_eq!(
952 matcher.match_request(&req).unwrap().route_id.as_str(),
953 "header-v2"
954 );
955 }
956}