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 sentinel_common::types::Priority;
15use sentinel_common::RouteId;
16use sentinel_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 cache_key = req.cache_key();
178 if let Some(route_id_ref) = self.cache.get(&cache_key) {
179 let route_id = route_id_ref.clone();
180 drop(route_id_ref); trace!(
182 route_id = %route_id,
183 cache_key = %cache_key,
184 "Route cache hit"
185 );
186 if let Some(route) = self.find_route_by_id(&route_id) {
187 debug!(
188 route_id = %route_id,
189 method = %req.method,
190 path = %req.path,
191 source = "cache",
192 "Route matched from cache"
193 );
194 return Some(RouteMatch {
195 route_id,
196 config: route.config.clone(),
197 });
198 }
199 }
200
201 self.cache.record_miss();
203
204 trace!(
205 cache_key = %cache_key,
206 route_count = self.routes.len(),
207 "Cache miss, evaluating routes"
208 );
209
210 for (index, route) in self.routes.iter().enumerate() {
212 trace!(
213 route_id = %route.id,
214 route_index = index,
215 priority = ?route.priority,
216 matcher_count = route.matchers.len(),
217 "Evaluating route"
218 );
219
220 if route.matches(req) {
221 debug!(
222 route_id = %route.id,
223 method = %req.method,
224 path = %req.path,
225 host = %req.host,
226 priority = ?route.priority,
227 route_index = index,
228 "Route matched"
229 );
230
231 self.cache.insert(cache_key.clone(), route.id.clone());
233
234 trace!(
235 route_id = %route.id,
236 cache_key = %cache_key,
237 "Route added to cache"
238 );
239
240 return Some(RouteMatch {
241 route_id: route.id.clone(),
242 config: route.config.clone(),
243 });
244 }
245 }
246
247 if let Some(ref default_id) = self.default_route {
249 debug!(
250 route_id = %default_id,
251 method = %req.method,
252 path = %req.path,
253 "Using default route (no explicit match)"
254 );
255 if let Some(route) = self.find_route_by_id(default_id) {
256 return Some(RouteMatch {
257 route_id: default_id.clone(),
258 config: route.config.clone(),
259 });
260 }
261 }
262
263 debug!(
264 method = %req.method,
265 path = %req.path,
266 host = %req.host,
267 routes_evaluated = self.routes.len(),
268 "No route matched"
269 );
270 None
271 }
272
273 fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
275 self.routes.iter().find(|r| r.id == *id)
276 }
277
278 pub fn clear_cache(&self) {
280 self.cache.clear();
281 }
282
283 pub fn cache_stats(&self) -> CacheStats {
285 CacheStats {
286 entries: self.cache.len(),
287 max_size: self.cache.max_size,
288 hit_rate: self.cache.hit_rate(),
289 }
290 }
291}
292
293impl CompiledRoute {
294 fn compile(config: RouteConfig) -> Result<Self, RouteError> {
296 let mut matchers = Vec::new();
297
298 for condition in &config.matches {
299 let compiled = match condition {
300 MatchCondition::Path(path) => CompiledMatcher::Path(path.clone()),
301 MatchCondition::PathPrefix(prefix) => CompiledMatcher::PathPrefix(prefix.clone()),
302 MatchCondition::PathRegex(pattern) => {
303 let regex = Regex::new(pattern).map_err(|e| RouteError::InvalidRegex {
304 pattern: pattern.clone(),
305 error: e.to_string(),
306 })?;
307 CompiledMatcher::PathRegex(regex)
308 }
309 MatchCondition::Host(host) => CompiledMatcher::Host(HostMatcher::parse(host)),
310 MatchCondition::Header { name, value } => CompiledMatcher::Header {
311 name: name.to_lowercase(),
312 value: value.clone(),
313 },
314 MatchCondition::Method(methods) => {
315 CompiledMatcher::Method(methods.iter().map(|m| m.to_uppercase()).collect())
316 }
317 MatchCondition::QueryParam { name, value } => CompiledMatcher::QueryParam {
318 name: name.clone(),
319 value: value.clone(),
320 },
321 };
322 matchers.push(compiled);
323 }
324
325 Ok(Self {
326 id: RouteId::new(&config.id),
327 priority: config.priority,
328 config: Arc::new(config),
329 matchers,
330 })
331 }
332
333 fn matches(&self, req: &RequestInfo<'_>) -> bool {
335 for (index, matcher) in self.matchers.iter().enumerate() {
337 let result = matcher.matches(req);
338 if !result {
339 trace!(
340 route_id = %self.id,
341 matcher_index = index,
342 matcher_type = ?matcher,
343 path = %req.path,
344 "Matcher did not match"
345 );
346 return false;
347 }
348 trace!(
349 route_id = %self.id,
350 matcher_index = index,
351 matcher_type = ?matcher,
352 "Matcher passed"
353 );
354 }
355 true
356 }
357
358 fn specificity(&self) -> u32 {
360 let mut score = 0;
361 for matcher in &self.matchers {
362 score += match matcher {
363 CompiledMatcher::Path(_) => 1000, CompiledMatcher::PathRegex(_) => 500, CompiledMatcher::PathPrefix(_) => 100, CompiledMatcher::Host(_) => 50,
367 CompiledMatcher::Header { value, .. } => {
368 if value.is_some() {
369 30
370 } else {
371 20
372 }
373 }
374 CompiledMatcher::Method(_) => 10,
375 CompiledMatcher::QueryParam { value, .. } => {
376 if value.is_some() {
377 25
378 } else {
379 15
380 }
381 }
382 };
383 }
384 score
385 }
386}
387
388impl CompiledMatcher {
389 fn matches(&self, req: &RequestInfo<'_>) -> bool {
391 match self {
392 Self::Path(path) => req.path == *path,
393 Self::PathPrefix(prefix) => req.path.starts_with(prefix),
394 Self::PathRegex(regex) => regex.is_match(req.path),
395 Self::Host(host_matcher) => host_matcher.matches(req.host),
396 Self::Header { name, value } => {
397 if let Some(header_value) = req.headers().get(name) {
398 value.as_ref().is_none_or(|v| header_value == v)
399 } else {
400 false
401 }
402 }
403 Self::Method(methods) => methods.iter().any(|m| m == req.method),
404 Self::QueryParam { name, value } => {
405 if let Some(param_value) = req.query_params().get(name) {
406 value.as_ref().is_none_or(|v| param_value == v)
407 } else {
408 false
409 }
410 }
411 }
412 }
413}
414
415impl HostMatcher {
416 fn parse(pattern: &str) -> Self {
418 if pattern.starts_with("*.") {
419 Self::Wildcard {
421 suffix: pattern[2..].to_string(),
422 }
423 } else if pattern.contains('*') || pattern.contains('[') {
424 if let Ok(regex) = Regex::new(pattern) {
426 Self::Regex(regex)
427 } else {
428 warn!("Invalid host regex pattern: {}, using exact match", pattern);
430 Self::Exact(pattern.to_string())
431 }
432 } else {
433 Self::Exact(pattern.to_string())
435 }
436 }
437
438 fn matches(&self, host: &str) -> bool {
440 match self {
441 Self::Exact(pattern) => host == pattern,
442 Self::Wildcard { suffix } => {
443 host.ends_with(suffix)
444 && host.len() > suffix.len()
445 && host[..host.len() - suffix.len()].ends_with('.')
446 }
447 Self::Regex(regex) => regex.is_match(host),
448 }
449 }
450}
451
452impl RouteCache {
453 fn new(max_size: usize) -> Self {
455 Self {
456 entries: DashMap::with_capacity(max_size),
457 max_size,
458 entry_count: AtomicUsize::new(0),
459 hits: AtomicU64::new(0),
460 misses: AtomicU64::new(0),
461 }
462 }
463
464 fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
466 let result = self.entries.get(key);
467 if result.is_some() {
468 self.hits.fetch_add(1, Ordering::Relaxed);
469 }
470 result
471 }
472
473 fn record_miss(&self) {
475 self.misses.fetch_add(1, Ordering::Relaxed);
476 }
477
478 fn hit_rate(&self) -> f64 {
480 let hits = self.hits.load(Ordering::Relaxed);
481 let misses = self.misses.load(Ordering::Relaxed);
482 let total = hits + misses;
483 if total == 0 {
484 0.0
485 } else {
486 hits as f64 / total as f64
487 }
488 }
489
490 fn insert(&self, key: String, route_id: RouteId) {
492 let current_count = self.entry_count.load(Ordering::Relaxed);
494 if current_count >= self.max_size {
495 self.evict_random();
498 }
499
500 if self.entries.insert(key, route_id).is_none() {
501 self.entry_count.fetch_add(1, Ordering::Relaxed);
503 }
504 }
505
506 fn evict_random(&self) {
508 let to_evict = self.max_size / 10; let mut evicted = 0;
510
511 self.entries.retain(|_, _| {
513 if evicted < to_evict {
514 evicted += 1;
515 false } else {
517 true }
519 });
520
521 self.entry_count
523 .store(self.entries.len(), Ordering::Relaxed);
524 }
525
526 fn len(&self) -> usize {
528 self.entries.len()
529 }
530
531 fn clear(&self) {
533 self.entries.clear();
534 self.entry_count.store(0, Ordering::Relaxed);
535 }
536}
537
538#[derive(Debug)]
540pub struct RequestInfo<'a> {
541 pub method: &'a str,
543 pub path: &'a str,
545 pub host: &'a str,
547 headers: Option<HashMap<String, String>>,
549 query_params: Option<HashMap<String, String>>,
551}
552
553impl<'a> RequestInfo<'a> {
554 #[inline]
556 pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
557 Self {
558 method,
559 path,
560 host,
561 headers: None,
562 query_params: None,
563 }
564 }
565
566 #[inline]
568 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
569 self.headers = Some(headers);
570 self
571 }
572
573 #[inline]
575 pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
576 self.query_params = Some(params);
577 self
578 }
579
580 #[inline]
582 pub fn headers(&self) -> &HashMap<String, String> {
583 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
584 self.headers
585 .as_ref()
586 .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
587 }
588
589 #[inline]
591 pub fn query_params(&self) -> &HashMap<String, String> {
592 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
593 self.query_params
594 .as_ref()
595 .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
596 }
597
598 fn cache_key(&self) -> String {
600 format!("{}:{}:{}", self.method, self.host, self.path)
601 }
602
603 pub fn parse_query_params(path: &str) -> HashMap<String, String> {
605 let mut params = HashMap::new();
606 if let Some(query_start) = path.find('?') {
607 let query = &path[query_start + 1..];
608 for pair in query.split('&') {
609 if let Some(eq_pos) = pair.find('=') {
610 let key = &pair[..eq_pos];
611 let value = &pair[eq_pos + 1..];
612 params.insert(
613 urlencoding::decode(key)
614 .unwrap_or_else(|_| key.into())
615 .into_owned(),
616 urlencoding::decode(value)
617 .unwrap_or_else(|_| value.into())
618 .into_owned(),
619 );
620 } else {
621 params.insert(
622 urlencoding::decode(pair)
623 .unwrap_or_else(|_| pair.into())
624 .into_owned(),
625 String::new(),
626 );
627 }
628 }
629 }
630 params
631 }
632
633 pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
635 where
636 I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
637 {
638 let mut headers = HashMap::new();
639 for (name, value) in iter {
640 if let Ok(value_str) = value.to_str() {
641 headers.insert(name.as_str().to_lowercase(), value_str.to_string());
642 }
643 }
644 headers
645 }
646}
647
648#[derive(Debug, Clone)]
650pub struct RouteMatch {
651 pub route_id: RouteId,
652 pub config: Arc<RouteConfig>,
653}
654
655impl RouteMatch {
656 #[inline]
658 pub fn policies(&self) -> &RoutePolicies {
659 &self.config.policies
660 }
661}
662
663#[derive(Debug, Clone)]
665pub struct CacheStats {
666 pub entries: usize,
667 pub max_size: usize,
668 pub hit_rate: f64,
669}
670
671#[derive(Debug, thiserror::Error)]
673pub enum RouteError {
674 #[error("Invalid regex pattern '{pattern}': {error}")]
675 InvalidRegex { pattern: String, error: String },
676
677 #[error("Invalid route configuration: {0}")]
678 InvalidConfig(String),
679
680 #[error("Duplicate route ID: {0}")]
681 DuplicateRouteId(String),
682}
683
684impl std::fmt::Debug for CompiledMatcher {
685 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
686 match self {
687 Self::Path(p) => write!(f, "Path({})", p),
688 Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
689 Self::PathRegex(_) => write!(f, "PathRegex(...)"),
690 Self::Host(_) => write!(f, "Host(...)"),
691 Self::Header { name, .. } => write!(f, "Header({})", name),
692 Self::Method(m) => write!(f, "Method({:?})", m),
693 Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
694 }
695 }
696}
697
698#[cfg(test)]
699mod tests {
700 use super::*;
701 use sentinel_common::types::Priority;
702 use sentinel_config::{MatchCondition, RouteConfig};
703
704 fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
705 RouteConfig {
706 id: id.to_string(),
707 priority: Priority::Normal,
708 matches,
709 upstream: Some("test_upstream".to_string()),
710 service_type: sentinel_config::ServiceType::Web,
711 policies: Default::default(),
712 filters: vec![],
713 builtin_handler: None,
714 waf_enabled: false,
715 circuit_breaker: None,
716 retry_policy: None,
717 static_files: None,
718 api_schema: None,
719 error_pages: None,
720 websocket: false,
721 websocket_inspection: false,
722 }
723 }
724
725 #[test]
726 fn test_path_matching() {
727 let routes = vec![
728 create_test_route(
729 "exact",
730 vec![MatchCondition::Path("/api/v1/users".to_string())],
731 ),
732 create_test_route(
733 "prefix",
734 vec![MatchCondition::PathPrefix("/api/".to_string())],
735 ),
736 ];
737
738 let matcher = RouteMatcher::new(routes, None).unwrap();
739
740 let req = RequestInfo {
741 method: "GET",
742 path: "/api/v1/users",
743 host: "example.com",
744 headers: None,
745 query_params: None,
746 };
747
748 let result = matcher.match_request(&req).unwrap();
749 assert_eq!(result.route_id.as_str(), "exact");
750 }
751
752 #[test]
753 fn test_host_wildcard_matching() {
754 let routes = vec![create_test_route(
755 "wildcard",
756 vec![MatchCondition::Host("*.example.com".to_string())],
757 )];
758
759 let matcher = RouteMatcher::new(routes, None).unwrap();
760
761 let req = RequestInfo {
762 method: "GET",
763 path: "/",
764 host: "api.example.com",
765 headers: None,
766 query_params: None,
767 };
768
769 let result = matcher.match_request(&req).unwrap();
770 assert_eq!(result.route_id.as_str(), "wildcard");
771 }
772
773 #[test]
774 fn test_priority_ordering() {
775 let mut route1 =
776 create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
777 route1.priority = Priority::Low;
778
779 let mut route2 =
780 create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
781 route2.priority = Priority::High;
782
783 let routes = vec![route1, route2];
784 let matcher = RouteMatcher::new(routes, None).unwrap();
785
786 let req = RequestInfo {
787 method: "GET",
788 path: "/test",
789 host: "example.com",
790 headers: None,
791 query_params: None,
792 };
793
794 let result = matcher.match_request(&req).unwrap();
795 assert_eq!(result.route_id.as_str(), "high");
796 }
797
798 #[test]
799 fn test_query_param_parsing() {
800 let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
801 assert_eq!(params.get("foo"), Some(&"bar".to_string()));
802 assert_eq!(params.get("baz"), Some(&"qux".to_string()));
803 assert_eq!(params.get("empty"), Some(&"".to_string()));
804 }
805}