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, needs_query_params, "Route matcher initialized"
140 );
141
142 Ok(Self {
143 routes: compiled_routes,
144 default_route: default_route.map(RouteId::new),
145 cache: Arc::new(RouteCache::new(1000)),
146 needs_headers,
147 needs_query_params,
148 })
149 }
150
151 #[inline]
153 pub fn needs_headers(&self) -> bool {
154 self.needs_headers
155 }
156
157 #[inline]
159 pub fn needs_query_params(&self) -> bool {
160 self.needs_query_params
161 }
162
163 pub fn match_request(&self, req: &RequestInfo<'_>) -> Option<RouteMatch> {
165 trace!(
166 method = %req.method,
167 path = %req.path,
168 host = %req.host,
169 "Starting route matching"
170 );
171
172 let cache_key = req.cache_key();
174 if let Some(route_id_ref) = self.cache.get(&cache_key) {
175 let route_id = route_id_ref.clone();
176 drop(route_id_ref); trace!(
178 route_id = %route_id,
179 cache_key = %cache_key,
180 "Route cache hit"
181 );
182 if let Some(route) = self.find_route_by_id(&route_id) {
183 debug!(
184 route_id = %route_id,
185 method = %req.method,
186 path = %req.path,
187 source = "cache",
188 "Route matched from cache"
189 );
190 return Some(RouteMatch {
191 route_id,
192 config: route.config.clone(),
193 });
194 }
195 }
196
197 trace!(
198 cache_key = %cache_key,
199 route_count = self.routes.len(),
200 "Cache miss, evaluating routes"
201 );
202
203 for (index, route) in self.routes.iter().enumerate() {
205 trace!(
206 route_id = %route.id,
207 route_index = index,
208 priority = ?route.priority,
209 matcher_count = route.matchers.len(),
210 "Evaluating route"
211 );
212
213 if route.matches(req) {
214 debug!(
215 route_id = %route.id,
216 method = %req.method,
217 path = %req.path,
218 host = %req.host,
219 priority = ?route.priority,
220 route_index = index,
221 "Route matched"
222 );
223
224 self.cache.insert(cache_key.clone(), route.id.clone());
226
227 trace!(
228 route_id = %route.id,
229 cache_key = %cache_key,
230 "Route added to cache"
231 );
232
233 return Some(RouteMatch {
234 route_id: route.id.clone(),
235 config: route.config.clone(),
236 });
237 }
238 }
239
240 if let Some(ref default_id) = self.default_route {
242 debug!(
243 route_id = %default_id,
244 method = %req.method,
245 path = %req.path,
246 "Using default route (no explicit match)"
247 );
248 if let Some(route) = self.find_route_by_id(default_id) {
249 return Some(RouteMatch {
250 route_id: default_id.clone(),
251 config: route.config.clone(),
252 });
253 }
254 }
255
256 debug!(
257 method = %req.method,
258 path = %req.path,
259 host = %req.host,
260 routes_evaluated = self.routes.len(),
261 "No route matched"
262 );
263 None
264 }
265
266 fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
268 self.routes.iter().find(|r| r.id == *id)
269 }
270
271 pub fn clear_cache(&self) {
273 self.cache.clear();
274 }
275
276 pub fn cache_stats(&self) -> CacheStats {
278 CacheStats {
279 entries: self.cache.len(),
280 max_size: self.cache.max_size,
281 hit_rate: 0.0, }
283 }
284}
285
286impl CompiledRoute {
287 fn compile(config: RouteConfig) -> Result<Self, RouteError> {
289 let mut matchers = Vec::new();
290
291 for condition in &config.matches {
292 let compiled = match condition {
293 MatchCondition::Path(path) => CompiledMatcher::Path(path.clone()),
294 MatchCondition::PathPrefix(prefix) => CompiledMatcher::PathPrefix(prefix.clone()),
295 MatchCondition::PathRegex(pattern) => {
296 let regex = Regex::new(pattern).map_err(|e| RouteError::InvalidRegex {
297 pattern: pattern.clone(),
298 error: e.to_string(),
299 })?;
300 CompiledMatcher::PathRegex(regex)
301 }
302 MatchCondition::Host(host) => CompiledMatcher::Host(HostMatcher::parse(host)),
303 MatchCondition::Header { name, value } => CompiledMatcher::Header {
304 name: name.to_lowercase(),
305 value: value.clone(),
306 },
307 MatchCondition::Method(methods) => {
308 CompiledMatcher::Method(methods.iter().map(|m| m.to_uppercase()).collect())
309 }
310 MatchCondition::QueryParam { name, value } => CompiledMatcher::QueryParam {
311 name: name.clone(),
312 value: value.clone(),
313 },
314 };
315 matchers.push(compiled);
316 }
317
318 Ok(Self {
319 id: RouteId::new(&config.id),
320 priority: config.priority,
321 config: Arc::new(config),
322 matchers,
323 })
324 }
325
326 fn matches(&self, req: &RequestInfo<'_>) -> bool {
328 for (index, matcher) in self.matchers.iter().enumerate() {
330 let result = matcher.matches(req);
331 if !result {
332 trace!(
333 route_id = %self.id,
334 matcher_index = index,
335 matcher_type = ?matcher,
336 path = %req.path,
337 "Matcher did not match"
338 );
339 return false;
340 }
341 trace!(
342 route_id = %self.id,
343 matcher_index = index,
344 matcher_type = ?matcher,
345 "Matcher passed"
346 );
347 }
348 true
349 }
350
351 fn specificity(&self) -> u32 {
353 let mut score = 0;
354 for matcher in &self.matchers {
355 score += match matcher {
356 CompiledMatcher::Path(_) => 1000, CompiledMatcher::PathRegex(_) => 500, CompiledMatcher::PathPrefix(_) => 100, CompiledMatcher::Host(_) => 50,
360 CompiledMatcher::Header { value, .. } => {
361 if value.is_some() {
362 30
363 } else {
364 20
365 }
366 }
367 CompiledMatcher::Method(_) => 10,
368 CompiledMatcher::QueryParam { value, .. } => {
369 if value.is_some() {
370 25
371 } else {
372 15
373 }
374 }
375 };
376 }
377 score
378 }
379}
380
381impl CompiledMatcher {
382 fn matches(&self, req: &RequestInfo<'_>) -> bool {
384 match self {
385 Self::Path(path) => req.path == *path,
386 Self::PathPrefix(prefix) => req.path.starts_with(prefix),
387 Self::PathRegex(regex) => regex.is_match(req.path),
388 Self::Host(host_matcher) => host_matcher.matches(req.host),
389 Self::Header { name, value } => {
390 if let Some(header_value) = req.headers().get(name) {
391 value.as_ref().is_none_or(|v| header_value == v)
392 } else {
393 false
394 }
395 }
396 Self::Method(methods) => methods.iter().any(|m| m == req.method),
397 Self::QueryParam { name, value } => {
398 if let Some(param_value) = req.query_params().get(name) {
399 value.as_ref().is_none_or(|v| param_value == v)
400 } else {
401 false
402 }
403 }
404 }
405 }
406}
407
408impl HostMatcher {
409 fn parse(pattern: &str) -> Self {
411 if pattern.starts_with("*.") {
412 Self::Wildcard {
414 suffix: pattern[2..].to_string(),
415 }
416 } else if pattern.contains('*') || pattern.contains('[') {
417 if let Ok(regex) = Regex::new(pattern) {
419 Self::Regex(regex)
420 } else {
421 warn!("Invalid host regex pattern: {}, using exact match", pattern);
423 Self::Exact(pattern.to_string())
424 }
425 } else {
426 Self::Exact(pattern.to_string())
428 }
429 }
430
431 fn matches(&self, host: &str) -> bool {
433 match self {
434 Self::Exact(pattern) => host == pattern,
435 Self::Wildcard { suffix } => {
436 host.ends_with(suffix)
437 && host.len() > suffix.len()
438 && host[..host.len() - suffix.len()].ends_with('.')
439 }
440 Self::Regex(regex) => regex.is_match(host),
441 }
442 }
443}
444
445impl RouteCache {
446 fn new(max_size: usize) -> Self {
448 Self {
449 entries: DashMap::with_capacity(max_size),
450 max_size,
451 entry_count: AtomicUsize::new(0),
452 }
453 }
454
455 fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
457 self.entries.get(key)
458 }
459
460 fn insert(&self, key: String, route_id: RouteId) {
462 let current_count = self.entry_count.load(Ordering::Relaxed);
464 if current_count >= self.max_size {
465 self.evict_random();
468 }
469
470 if self.entries.insert(key, route_id).is_none() {
471 self.entry_count.fetch_add(1, Ordering::Relaxed);
473 }
474 }
475
476 fn evict_random(&self) {
478 let to_evict = self.max_size / 10; let mut evicted = 0;
480
481 self.entries.retain(|_, _| {
483 if evicted < to_evict {
484 evicted += 1;
485 false } else {
487 true }
489 });
490
491 self.entry_count
493 .store(self.entries.len(), Ordering::Relaxed);
494 }
495
496 fn len(&self) -> usize {
498 self.entries.len()
499 }
500
501 fn clear(&self) {
503 self.entries.clear();
504 self.entry_count.store(0, Ordering::Relaxed);
505 }
506}
507
508#[derive(Debug)]
510pub struct RequestInfo<'a> {
511 pub method: &'a str,
513 pub path: &'a str,
515 pub host: &'a str,
517 headers: Option<HashMap<String, String>>,
519 query_params: Option<HashMap<String, String>>,
521}
522
523impl<'a> RequestInfo<'a> {
524 #[inline]
526 pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
527 Self {
528 method,
529 path,
530 host,
531 headers: None,
532 query_params: None,
533 }
534 }
535
536 #[inline]
538 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
539 self.headers = Some(headers);
540 self
541 }
542
543 #[inline]
545 pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
546 self.query_params = Some(params);
547 self
548 }
549
550 #[inline]
552 pub fn headers(&self) -> &HashMap<String, String> {
553 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
554 self.headers
555 .as_ref()
556 .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
557 }
558
559 #[inline]
561 pub fn query_params(&self) -> &HashMap<String, String> {
562 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
563 self.query_params
564 .as_ref()
565 .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}
624
625impl RouteMatch {
626 #[inline]
628 pub fn policies(&self) -> &RoutePolicies {
629 &self.config.policies
630 }
631}
632
633#[derive(Debug, Clone)]
635pub struct CacheStats {
636 pub entries: usize,
637 pub max_size: usize,
638 pub hit_rate: f64,
639}
640
641#[derive(Debug, thiserror::Error)]
643pub enum RouteError {
644 #[error("Invalid regex pattern '{pattern}': {error}")]
645 InvalidRegex { pattern: String, error: String },
646
647 #[error("Invalid route configuration: {0}")]
648 InvalidConfig(String),
649
650 #[error("Duplicate route ID: {0}")]
651 DuplicateRouteId(String),
652}
653
654impl std::fmt::Debug for CompiledMatcher {
655 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656 match self {
657 Self::Path(p) => write!(f, "Path({})", p),
658 Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
659 Self::PathRegex(_) => write!(f, "PathRegex(...)"),
660 Self::Host(_) => write!(f, "Host(...)"),
661 Self::Header { name, .. } => write!(f, "Header({})", name),
662 Self::Method(m) => write!(f, "Method({:?})", m),
663 Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
664 }
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use sentinel_common::types::Priority;
672 use sentinel_config::{MatchCondition, RouteConfig};
673
674 fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
675 RouteConfig {
676 id: id.to_string(),
677 priority: Priority::Normal,
678 matches,
679 upstream: Some("test_upstream".to_string()),
680 service_type: sentinel_config::ServiceType::Web,
681 policies: Default::default(),
682 filters: vec![],
683 builtin_handler: None,
684 waf_enabled: false,
685 circuit_breaker: None,
686 retry_policy: None,
687 static_files: None,
688 api_schema: None,
689 error_pages: None,
690 websocket: false,
691 websocket_inspection: false,
692 }
693 }
694
695 #[test]
696 fn test_path_matching() {
697 let routes = vec![
698 create_test_route(
699 "exact",
700 vec![MatchCondition::Path("/api/v1/users".to_string())],
701 ),
702 create_test_route(
703 "prefix",
704 vec![MatchCondition::PathPrefix("/api/".to_string())],
705 ),
706 ];
707
708 let matcher = RouteMatcher::new(routes, None).unwrap();
709
710 let req = RequestInfo {
711 method: "GET",
712 path: "/api/v1/users",
713 host: "example.com",
714 headers: None,
715 query_params: None,
716 };
717
718 let result = matcher.match_request(&req).unwrap();
719 assert_eq!(result.route_id.as_str(), "exact");
720 }
721
722 #[test]
723 fn test_host_wildcard_matching() {
724 let routes = vec![create_test_route(
725 "wildcard",
726 vec![MatchCondition::Host("*.example.com".to_string())],
727 )];
728
729 let matcher = RouteMatcher::new(routes, None).unwrap();
730
731 let req = RequestInfo {
732 method: "GET",
733 path: "/",
734 host: "api.example.com",
735 headers: None,
736 query_params: None,
737 };
738
739 let result = matcher.match_request(&req).unwrap();
740 assert_eq!(result.route_id.as_str(), "wildcard");
741 }
742
743 #[test]
744 fn test_priority_ordering() {
745 let mut route1 =
746 create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
747 route1.priority = Priority::Low;
748
749 let mut route2 =
750 create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
751 route2.priority = Priority::High;
752
753 let routes = vec![route1, route2];
754 let matcher = RouteMatcher::new(routes, None).unwrap();
755
756 let req = RequestInfo {
757 method: "GET",
758 path: "/test",
759 host: "example.com",
760 headers: None,
761 query_params: None,
762 };
763
764 let result = matcher.match_request(&req).unwrap();
765 assert_eq!(result.route_id.as_str(), "high");
766 }
767
768 #[test]
769 fn test_query_param_parsing() {
770 let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
771 assert_eq!(params.get("foo"), Some(&"bar".to_string()));
772 assert_eq!(params.get("baz"), Some(&"qux".to_string()));
773 assert_eq!(params.get("empty"), Some(&"".to_string()));
774 }
775}