1use dashmap::DashMap;
8use regex::Regex;
9use std::collections::HashMap;
10use std::sync::atomic::{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}
81
82impl RouteMatcher {
83 pub fn new(
85 routes: Vec<RouteConfig>,
86 default_route: Option<String>,
87 ) -> Result<Self, RouteError> {
88 info!(
89 route_count = routes.len(),
90 default_route = ?default_route,
91 "Initializing route matcher"
92 );
93
94 let mut compiled_routes = Vec::new();
95
96 for route in routes {
97 trace!(
98 route_id = %route.id,
99 priority = ?route.priority,
100 match_count = route.matches.len(),
101 "Compiling route"
102 );
103 let compiled = CompiledRoute::compile(route)?;
104 compiled_routes.push(compiled);
105 }
106
107 compiled_routes.sort_by(|a, b| {
109 b.priority
110 .cmp(&a.priority)
111 .then_with(|| b.specificity().cmp(&a.specificity()))
112 });
113
114 for (index, route) in compiled_routes.iter().enumerate() {
116 debug!(
117 route_id = %route.id,
118 order = index,
119 priority = ?route.priority,
120 specificity = route.specificity(),
121 "Route compiled and ordered"
122 );
123 }
124
125 let needs_headers = compiled_routes.iter().any(|r| {
127 r.matchers
128 .iter()
129 .any(|m| matches!(m, CompiledMatcher::Header { .. }))
130 });
131 let needs_query_params = compiled_routes.iter().any(|r| {
132 r.matchers
133 .iter()
134 .any(|m| matches!(m, CompiledMatcher::QueryParam { .. }))
135 });
136
137 info!(
138 compiled_routes = compiled_routes.len(),
139 needs_headers,
140 needs_query_params,
141 "Route matcher initialized"
142 );
143
144 Ok(Self {
145 routes: compiled_routes,
146 default_route: default_route.map(RouteId::new),
147 cache: Arc::new(RouteCache::new(1000)),
148 needs_headers,
149 needs_query_params,
150 })
151 }
152
153 #[inline]
155 pub fn needs_headers(&self) -> bool {
156 self.needs_headers
157 }
158
159 #[inline]
161 pub fn needs_query_params(&self) -> bool {
162 self.needs_query_params
163 }
164
165 pub fn match_request(&self, req: &RequestInfo<'_>) -> Option<RouteMatch> {
167 trace!(
168 method = %req.method,
169 path = %req.path,
170 host = %req.host,
171 "Starting route matching"
172 );
173
174 let cache_key = req.cache_key();
176 if let Some(route_id_ref) = self.cache.get(&cache_key) {
177 let route_id = route_id_ref.clone();
178 drop(route_id_ref); trace!(
180 route_id = %route_id,
181 cache_key = %cache_key,
182 "Route cache hit"
183 );
184 if let Some(route) = self.find_route_by_id(&route_id) {
185 debug!(
186 route_id = %route_id,
187 method = %req.method,
188 path = %req.path,
189 source = "cache",
190 "Route matched from cache"
191 );
192 return Some(RouteMatch {
193 route_id,
194 config: route.config.clone(),
195 policies: route.config.policies.clone(),
196 });
197 }
198 }
199
200 trace!(
201 cache_key = %cache_key,
202 route_count = self.routes.len(),
203 "Cache miss, evaluating routes"
204 );
205
206 for (index, route) in self.routes.iter().enumerate() {
208 trace!(
209 route_id = %route.id,
210 route_index = index,
211 priority = ?route.priority,
212 matcher_count = route.matchers.len(),
213 "Evaluating route"
214 );
215
216 if route.matches(req) {
217 debug!(
218 route_id = %route.id,
219 method = %req.method,
220 path = %req.path,
221 host = %req.host,
222 priority = ?route.priority,
223 route_index = index,
224 "Route matched"
225 );
226
227 self.cache.insert(cache_key.clone(), route.id.clone());
229
230 trace!(
231 route_id = %route.id,
232 cache_key = %cache_key,
233 "Route added to cache"
234 );
235
236 return Some(RouteMatch {
237 route_id: route.id.clone(),
238 config: route.config.clone(),
239 policies: route.config.policies.clone(),
240 });
241 }
242 }
243
244 if let Some(ref default_id) = self.default_route {
246 debug!(
247 route_id = %default_id,
248 method = %req.method,
249 path = %req.path,
250 "Using default route (no explicit match)"
251 );
252 if let Some(route) = self.find_route_by_id(default_id) {
253 return Some(RouteMatch {
254 route_id: default_id.clone(),
255 config: route.config.clone(),
256 policies: route.config.policies.clone(),
257 });
258 }
259 }
260
261 debug!(
262 method = %req.method,
263 path = %req.path,
264 host = %req.host,
265 routes_evaluated = self.routes.len(),
266 "No route matched"
267 );
268 None
269 }
270
271 fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
273 self.routes.iter().find(|r| r.id == *id)
274 }
275
276 pub fn clear_cache(&self) {
278 self.cache.clear();
279 }
280
281 pub fn cache_stats(&self) -> CacheStats {
283 CacheStats {
284 entries: self.cache.len(),
285 max_size: self.cache.max_size,
286 hit_rate: 0.0, }
288 }
289}
290
291impl CompiledRoute {
292 fn compile(config: RouteConfig) -> Result<Self, RouteError> {
294 let mut matchers = Vec::new();
295
296 for condition in &config.matches {
297 let compiled = match condition {
298 MatchCondition::Path(path) => CompiledMatcher::Path(path.clone()),
299 MatchCondition::PathPrefix(prefix) => CompiledMatcher::PathPrefix(prefix.clone()),
300 MatchCondition::PathRegex(pattern) => {
301 let regex = Regex::new(pattern).map_err(|e| RouteError::InvalidRegex {
302 pattern: pattern.clone(),
303 error: e.to_string(),
304 })?;
305 CompiledMatcher::PathRegex(regex)
306 }
307 MatchCondition::Host(host) => CompiledMatcher::Host(HostMatcher::parse(host)),
308 MatchCondition::Header { name, value } => CompiledMatcher::Header {
309 name: name.to_lowercase(),
310 value: value.clone(),
311 },
312 MatchCondition::Method(methods) => {
313 CompiledMatcher::Method(methods.iter().map(|m| m.to_uppercase()).collect())
314 }
315 MatchCondition::QueryParam { name, value } => CompiledMatcher::QueryParam {
316 name: name.clone(),
317 value: value.clone(),
318 },
319 };
320 matchers.push(compiled);
321 }
322
323 Ok(Self {
324 id: RouteId::new(&config.id),
325 priority: config.priority,
326 config: Arc::new(config),
327 matchers,
328 })
329 }
330
331 fn matches(&self, req: &RequestInfo<'_>) -> bool {
333 for (index, matcher) in self.matchers.iter().enumerate() {
335 let result = matcher.matches(req);
336 if !result {
337 trace!(
338 route_id = %self.id,
339 matcher_index = index,
340 matcher_type = ?matcher,
341 path = %req.path,
342 "Matcher did not match"
343 );
344 return false;
345 }
346 trace!(
347 route_id = %self.id,
348 matcher_index = index,
349 matcher_type = ?matcher,
350 "Matcher passed"
351 );
352 }
353 true
354 }
355
356 fn specificity(&self) -> u32 {
358 let mut score = 0;
359 for matcher in &self.matchers {
360 score += match matcher {
361 CompiledMatcher::Path(_) => 1000, CompiledMatcher::PathRegex(_) => 500, CompiledMatcher::PathPrefix(_) => 100, CompiledMatcher::Host(_) => 50,
365 CompiledMatcher::Header { value, .. } => {
366 if value.is_some() {
367 30
368 } else {
369 20
370 }
371 }
372 CompiledMatcher::Method(_) => 10,
373 CompiledMatcher::QueryParam { value, .. } => {
374 if value.is_some() {
375 25
376 } else {
377 15
378 }
379 }
380 };
381 }
382 score
383 }
384}
385
386impl CompiledMatcher {
387 fn matches(&self, req: &RequestInfo<'_>) -> bool {
389 match self {
390 Self::Path(path) => req.path == *path,
391 Self::PathPrefix(prefix) => req.path.starts_with(prefix),
392 Self::PathRegex(regex) => regex.is_match(req.path),
393 Self::Host(host_matcher) => host_matcher.matches(req.host),
394 Self::Header { name, value } => {
395 if let Some(header_value) = req.headers().get(name) {
396 value.as_ref().map_or(true, |v| header_value == v)
397 } else {
398 false
399 }
400 }
401 Self::Method(methods) => methods.iter().any(|m| m == req.method),
402 Self::QueryParam { name, value } => {
403 if let Some(param_value) = req.query_params().get(name) {
404 value.as_ref().map_or(true, |v| param_value == v)
405 } else {
406 false
407 }
408 }
409 }
410 }
411}
412
413impl HostMatcher {
414 fn parse(pattern: &str) -> Self {
416 if pattern.starts_with("*.") {
417 Self::Wildcard {
419 suffix: pattern[2..].to_string(),
420 }
421 } else if pattern.contains('*') || pattern.contains('[') {
422 if let Ok(regex) = Regex::new(pattern) {
424 Self::Regex(regex)
425 } else {
426 warn!("Invalid host regex pattern: {}, using exact match", pattern);
428 Self::Exact(pattern.to_string())
429 }
430 } else {
431 Self::Exact(pattern.to_string())
433 }
434 }
435
436 fn matches(&self, host: &str) -> bool {
438 match self {
439 Self::Exact(pattern) => host == pattern,
440 Self::Wildcard { suffix } => {
441 host.ends_with(suffix)
442 && host.len() > suffix.len()
443 && host[..host.len() - suffix.len()].ends_with('.')
444 }
445 Self::Regex(regex) => regex.is_match(host),
446 }
447 }
448}
449
450impl RouteCache {
451 fn new(max_size: usize) -> Self {
453 Self {
454 entries: DashMap::with_capacity(max_size),
455 max_size,
456 entry_count: AtomicUsize::new(0),
457 }
458 }
459
460 fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
462 self.entries.get(key)
463 }
464
465 fn insert(&self, key: String, route_id: RouteId) {
467 let current_count = self.entry_count.load(Ordering::Relaxed);
469 if current_count >= self.max_size {
470 self.evict_random();
473 }
474
475 if self.entries.insert(key, route_id).is_none() {
476 self.entry_count.fetch_add(1, Ordering::Relaxed);
478 }
479 }
480
481 fn evict_random(&self) {
483 let to_evict = self.max_size / 10; let mut evicted = 0;
485
486 self.entries.retain(|_, _| {
488 if evicted < to_evict {
489 evicted += 1;
490 false } else {
492 true }
494 });
495
496 self.entry_count.store(self.entries.len(), Ordering::Relaxed);
498 }
499
500 fn len(&self) -> usize {
502 self.entries.len()
503 }
504
505 fn clear(&self) {
507 self.entries.clear();
508 self.entry_count.store(0, Ordering::Relaxed);
509 }
510}
511
512#[derive(Debug)]
514pub struct RequestInfo<'a> {
515 pub method: &'a str,
517 pub path: &'a str,
519 pub host: &'a str,
521 headers: Option<HashMap<String, String>>,
523 query_params: Option<HashMap<String, String>>,
525}
526
527impl<'a> RequestInfo<'a> {
528 #[inline]
530 pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
531 Self {
532 method,
533 path,
534 host,
535 headers: None,
536 query_params: None,
537 }
538 }
539
540 #[inline]
542 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
543 self.headers = Some(headers);
544 self
545 }
546
547 #[inline]
549 pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
550 self.query_params = Some(params);
551 self
552 }
553
554 #[inline]
556 pub fn headers(&self) -> &HashMap<String, String> {
557 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
558 self.headers.as_ref().unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
559 }
560
561 #[inline]
563 pub fn query_params(&self) -> &HashMap<String, String> {
564 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
565 self.query_params.as_ref().unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
566 }
567
568 fn cache_key(&self) -> String {
570 format!("{}:{}:{}", self.method, self.host, self.path)
571 }
572
573 pub fn parse_query_params(path: &str) -> HashMap<String, String> {
575 let mut params = HashMap::new();
576 if let Some(query_start) = path.find('?') {
577 let query = &path[query_start + 1..];
578 for pair in query.split('&') {
579 if let Some(eq_pos) = pair.find('=') {
580 let key = &pair[..eq_pos];
581 let value = &pair[eq_pos + 1..];
582 params.insert(
583 urlencoding::decode(key)
584 .unwrap_or_else(|_| key.into())
585 .into_owned(),
586 urlencoding::decode(value)
587 .unwrap_or_else(|_| value.into())
588 .into_owned(),
589 );
590 } else {
591 params.insert(
592 urlencoding::decode(pair)
593 .unwrap_or_else(|_| pair.into())
594 .into_owned(),
595 String::new(),
596 );
597 }
598 }
599 }
600 params
601 }
602
603 pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
605 where
606 I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
607 {
608 let mut headers = HashMap::new();
609 for (name, value) in iter {
610 if let Ok(value_str) = value.to_str() {
611 headers.insert(name.as_str().to_lowercase(), value_str.to_string());
612 }
613 }
614 headers
615 }
616}
617
618#[derive(Debug, Clone)]
620pub struct RouteMatch {
621 pub route_id: RouteId,
622 pub config: Arc<RouteConfig>,
623 pub policies: RoutePolicies,
624}
625
626#[derive(Debug, Clone)]
628pub struct CacheStats {
629 pub entries: usize,
630 pub max_size: usize,
631 pub hit_rate: f64,
632}
633
634#[derive(Debug, thiserror::Error)]
636pub enum RouteError {
637 #[error("Invalid regex pattern '{pattern}': {error}")]
638 InvalidRegex { pattern: String, error: String },
639
640 #[error("Invalid route configuration: {0}")]
641 InvalidConfig(String),
642
643 #[error("Duplicate route ID: {0}")]
644 DuplicateRouteId(String),
645}
646
647impl std::fmt::Debug for CompiledMatcher {
648 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
649 match self {
650 Self::Path(p) => write!(f, "Path({})", p),
651 Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
652 Self::PathRegex(_) => write!(f, "PathRegex(...)"),
653 Self::Host(_) => write!(f, "Host(...)"),
654 Self::Header { name, .. } => write!(f, "Header({})", name),
655 Self::Method(m) => write!(f, "Method({:?})", m),
656 Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
657 }
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664 use sentinel_common::types::Priority;
665 use sentinel_config::{MatchCondition, RouteConfig};
666
667 fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
668 RouteConfig {
669 id: id.to_string(),
670 priority: Priority::Normal,
671 matches,
672 upstream: Some("test_upstream".to_string()),
673 service_type: sentinel_config::ServiceType::Web,
674 policies: Default::default(),
675 filters: vec![],
676 builtin_handler: None,
677 waf_enabled: false,
678 circuit_breaker: None,
679 retry_policy: None,
680 static_files: None,
681 api_schema: None,
682 error_pages: None,
683 }
684 }
685
686 #[test]
687 fn test_path_matching() {
688 let routes = vec![
689 create_test_route(
690 "exact",
691 vec![MatchCondition::Path("/api/v1/users".to_string())],
692 ),
693 create_test_route(
694 "prefix",
695 vec![MatchCondition::PathPrefix("/api/".to_string())],
696 ),
697 ];
698
699 let matcher = RouteMatcher::new(routes, None).unwrap();
700
701 let req = RequestInfo {
702 method: "GET".to_string(),
703 path: "/api/v1/users".to_string(),
704 host: "example.com".to_string(),
705 headers: HashMap::new(),
706 query_params: HashMap::new(),
707 };
708
709 let result = matcher.match_request(&req).unwrap();
710 assert_eq!(result.route_id.as_str(), "exact");
711 }
712
713 #[test]
714 fn test_host_wildcard_matching() {
715 let routes = vec![create_test_route(
716 "wildcard",
717 vec![MatchCondition::Host("*.example.com".to_string())],
718 )];
719
720 let matcher = RouteMatcher::new(routes, None).unwrap();
721
722 let req = RequestInfo {
723 method: "GET".to_string(),
724 path: "/".to_string(),
725 host: "api.example.com".to_string(),
726 headers: HashMap::new(),
727 query_params: HashMap::new(),
728 };
729
730 let result = matcher.match_request(&req).unwrap();
731 assert_eq!(result.route_id.as_str(), "wildcard");
732 }
733
734 #[test]
735 fn test_priority_ordering() {
736 let mut route1 =
737 create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
738 route1.priority = Priority::Low;
739
740 let mut route2 =
741 create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
742 route2.priority = Priority::High;
743
744 let routes = vec![route1, route2];
745 let matcher = RouteMatcher::new(routes, None).unwrap();
746
747 let req = RequestInfo {
748 method: "GET".to_string(),
749 path: "/test".to_string(),
750 host: "example.com".to_string(),
751 headers: HashMap::new(),
752 query_params: HashMap::new(),
753 };
754
755 let result = matcher.match_request(&req).unwrap();
756 assert_eq!(result.route_id.as_str(), "high");
757 }
758
759 #[test]
760 fn test_query_param_parsing() {
761 let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
762 assert_eq!(params.get("foo"), Some(&"bar".to_string()));
763 assert_eq!(params.get("baz"), Some(&"qux".to_string()));
764 assert_eq!(params.get("empty"), Some(&"".to_string()));
765 }
766}