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 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 {
338 for (index, matcher) in self.matchers.iter().enumerate() {
340 let result = matcher.matches(req);
341 if !result {
342 trace!(
343 route_id = %self.id,
344 matcher_index = index,
345 matcher_type = ?matcher,
346 path = %req.path,
347 "Matcher did not match"
348 );
349 return false;
350 }
351 trace!(
352 route_id = %self.id,
353 matcher_index = index,
354 matcher_type = ?matcher,
355 "Matcher passed"
356 );
357 }
358 true
359 }
360
361 fn specificity(&self) -> u32 {
363 let mut score = 0;
364 for matcher in &self.matchers {
365 score += match matcher {
366 CompiledMatcher::Path(_) => 1000, CompiledMatcher::PathRegex(_) => 500, CompiledMatcher::PathPrefix(_) => 100, CompiledMatcher::Host(_) => 50,
370 CompiledMatcher::Header { value, .. } => {
371 if value.is_some() {
372 30
373 } else {
374 20
375 }
376 }
377 CompiledMatcher::Method(_) => 10,
378 CompiledMatcher::QueryParam { value, .. } => {
379 if value.is_some() {
380 25
381 } else {
382 15
383 }
384 }
385 };
386 }
387 score
388 }
389}
390
391impl CompiledMatcher {
392 fn matches(&self, req: &RequestInfo<'_>) -> bool {
394 match self {
395 Self::Path(path) => req.path == *path,
396 Self::PathPrefix(prefix) => req.path.starts_with(prefix),
397 Self::PathRegex(regex) => regex.is_match(req.path),
398 Self::Host(host_matcher) => host_matcher.matches(req.host),
399 Self::Header { name, value } => {
400 if let Some(header_value) = req.headers().get(name) {
401 value.as_ref().is_none_or(|v| header_value == v)
402 } else {
403 false
404 }
405 }
406 Self::Method(methods) => methods.iter().any(|m| m == req.method),
407 Self::QueryParam { name, value } => {
408 if let Some(param_value) = req.query_params().get(name) {
409 value.as_ref().is_none_or(|v| param_value == v)
410 } else {
411 false
412 }
413 }
414 }
415 }
416}
417
418impl HostMatcher {
419 fn parse(pattern: &str) -> Self {
421 if pattern.starts_with("*.") {
422 Self::Wildcard {
424 suffix: pattern[2..].to_string(),
425 }
426 } else if pattern.contains('*') || pattern.contains('[') {
427 if let Ok(regex) = Regex::new(pattern) {
429 Self::Regex(regex)
430 } else {
431 warn!("Invalid host regex pattern: {}, using exact match", pattern);
433 Self::Exact(pattern.to_string())
434 }
435 } else {
436 Self::Exact(pattern.to_string())
438 }
439 }
440
441 fn matches(&self, host: &str) -> bool {
443 match self {
444 Self::Exact(pattern) => host == pattern,
445 Self::Wildcard { suffix } => {
446 host.ends_with(suffix)
447 && host.len() > suffix.len()
448 && host[..host.len() - suffix.len()].ends_with('.')
449 }
450 Self::Regex(regex) => regex.is_match(host),
451 }
452 }
453}
454
455impl RouteCache {
456 fn new(max_size: usize) -> Self {
458 Self {
459 entries: DashMap::with_capacity(max_size),
460 max_size,
461 entry_count: AtomicUsize::new(0),
462 hits: AtomicU64::new(0),
463 misses: AtomicU64::new(0),
464 }
465 }
466
467 fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
469 let result = self.entries.get(key);
470 if result.is_some() {
471 self.hits.fetch_add(1, Ordering::Relaxed);
472 }
473 result
474 }
475
476 fn record_miss(&self) {
478 self.misses.fetch_add(1, Ordering::Relaxed);
479 }
480
481 fn hit_rate(&self) -> f64 {
483 let hits = self.hits.load(Ordering::Relaxed);
484 let misses = self.misses.load(Ordering::Relaxed);
485 let total = hits + misses;
486 if total == 0 {
487 0.0
488 } else {
489 hits as f64 / total as f64
490 }
491 }
492
493 fn insert(&self, key: String, route_id: RouteId) {
495 let current_count = self.entry_count.load(Ordering::Relaxed);
497 if current_count >= self.max_size {
498 self.evict_random();
501 }
502
503 if self.entries.insert(key, route_id).is_none() {
504 self.entry_count.fetch_add(1, Ordering::Relaxed);
506 }
507 }
508
509 fn evict_random(&self) {
511 let to_evict = self.max_size / 10; let mut evicted = 0;
513
514 self.entries.retain(|_, _| {
516 if evicted < to_evict {
517 evicted += 1;
518 false } else {
520 true }
522 });
523
524 self.entry_count
526 .store(self.entries.len(), Ordering::Relaxed);
527 }
528
529 fn len(&self) -> usize {
531 self.entries.len()
532 }
533
534 fn clear(&self) {
536 self.entries.clear();
537 self.entry_count.store(0, Ordering::Relaxed);
538 }
539}
540
541#[derive(Debug)]
543pub struct RequestInfo<'a> {
544 pub method: &'a str,
546 pub path: &'a str,
548 pub host: &'a str,
550 headers: Option<HashMap<String, String>>,
552 query_params: Option<HashMap<String, String>>,
554}
555
556impl<'a> RequestInfo<'a> {
557 #[inline]
559 pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
560 Self {
561 method,
562 path,
563 host,
564 headers: None,
565 query_params: None,
566 }
567 }
568
569 #[inline]
571 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
572 self.headers = Some(headers);
573 self
574 }
575
576 #[inline]
578 pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
579 self.query_params = Some(params);
580 self
581 }
582
583 #[inline]
585 pub fn headers(&self) -> &HashMap<String, String> {
586 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
587 self.headers
588 .as_ref()
589 .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
590 }
591
592 #[inline]
594 pub fn query_params(&self) -> &HashMap<String, String> {
595 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
596 self.query_params
597 .as_ref()
598 .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
599 }
600
601 fn with_cache_key<R>(&self, f: impl FnOnce(&str) -> R) -> R {
604 use std::cell::RefCell;
605 use std::fmt::Write;
606
607 thread_local! {
608 static BUF: RefCell<String> = RefCell::new(String::with_capacity(128));
609 }
610
611 BUF.with(|buf| {
612 let mut buf = buf.borrow_mut();
613 buf.clear();
614 let _ = write!(buf, "{}:{}:{}", self.method, self.host, self.path);
615 f(&buf)
616 })
617 }
618
619 pub fn parse_query_params(path: &str) -> HashMap<String, String> {
621 let mut params = HashMap::new();
622 if let Some(query_start) = path.find('?') {
623 let query = &path[query_start + 1..];
624 for pair in query.split('&') {
625 if let Some(eq_pos) = pair.find('=') {
626 let key = &pair[..eq_pos];
627 let value = &pair[eq_pos + 1..];
628 params.insert(
629 urlencoding::decode(key)
630 .unwrap_or_else(|_| key.into())
631 .into_owned(),
632 urlencoding::decode(value)
633 .unwrap_or_else(|_| value.into())
634 .into_owned(),
635 );
636 } else {
637 params.insert(
638 urlencoding::decode(pair)
639 .unwrap_or_else(|_| pair.into())
640 .into_owned(),
641 String::new(),
642 );
643 }
644 }
645 }
646 params
647 }
648
649 pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
651 where
652 I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
653 {
654 let mut headers = HashMap::new();
655 for (name, value) in iter {
656 if let Ok(value_str) = value.to_str() {
657 headers.insert(name.as_str().to_lowercase(), value_str.to_string());
658 }
659 }
660 headers
661 }
662}
663
664#[derive(Debug, Clone)]
666pub struct RouteMatch {
667 pub route_id: RouteId,
668 pub config: Arc<RouteConfig>,
669}
670
671impl RouteMatch {
672 #[inline]
674 pub fn policies(&self) -> &RoutePolicies {
675 &self.config.policies
676 }
677}
678
679#[derive(Debug, Clone)]
681pub struct CacheStats {
682 pub entries: usize,
683 pub max_size: usize,
684 pub hit_rate: f64,
685}
686
687#[derive(Debug, thiserror::Error)]
689pub enum RouteError {
690 #[error("Invalid regex pattern '{pattern}': {error}")]
691 InvalidRegex { pattern: String, error: String },
692
693 #[error("Invalid route configuration: {0}")]
694 InvalidConfig(String),
695
696 #[error("Duplicate route ID: {0}")]
697 DuplicateRouteId(String),
698}
699
700impl std::fmt::Debug for CompiledMatcher {
701 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
702 match self {
703 Self::Path(p) => write!(f, "Path({})", p),
704 Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
705 Self::PathRegex(_) => write!(f, "PathRegex(...)"),
706 Self::Host(_) => write!(f, "Host(...)"),
707 Self::Header { name, .. } => write!(f, "Header({})", name),
708 Self::Method(m) => write!(f, "Method({:?})", m),
709 Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
710 }
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717 use sentinel_common::types::Priority;
718 use sentinel_config::{MatchCondition, RouteConfig};
719
720 fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
721 RouteConfig {
722 id: id.to_string(),
723 priority: Priority::Normal,
724 matches,
725 upstream: Some("test_upstream".to_string()),
726 service_type: sentinel_config::ServiceType::Web,
727 policies: Default::default(),
728 filters: vec![],
729 builtin_handler: None,
730 waf_enabled: false,
731 circuit_breaker: None,
732 retry_policy: None,
733 static_files: None,
734 api_schema: None,
735 error_pages: None,
736 websocket: false,
737 websocket_inspection: false,
738 inference: None,
739 shadow: None,
740 fallback: None,
741 }
742 }
743
744 #[test]
745 fn test_path_matching() {
746 let routes = vec![
747 create_test_route(
748 "exact",
749 vec![MatchCondition::Path("/api/v1/users".to_string())],
750 ),
751 create_test_route(
752 "prefix",
753 vec![MatchCondition::PathPrefix("/api/".to_string())],
754 ),
755 ];
756
757 let matcher = RouteMatcher::new(routes, None).unwrap();
758
759 let req = RequestInfo {
760 method: "GET",
761 path: "/api/v1/users",
762 host: "example.com",
763 headers: None,
764 query_params: None,
765 };
766
767 let result = matcher.match_request(&req).unwrap();
768 assert_eq!(result.route_id.as_str(), "exact");
769 }
770
771 #[test]
772 fn test_host_wildcard_matching() {
773 let routes = vec![create_test_route(
774 "wildcard",
775 vec![MatchCondition::Host("*.example.com".to_string())],
776 )];
777
778 let matcher = RouteMatcher::new(routes, None).unwrap();
779
780 let req = RequestInfo {
781 method: "GET",
782 path: "/",
783 host: "api.example.com",
784 headers: None,
785 query_params: None,
786 };
787
788 let result = matcher.match_request(&req).unwrap();
789 assert_eq!(result.route_id.as_str(), "wildcard");
790 }
791
792 #[test]
793 fn test_priority_ordering() {
794 let mut route1 =
795 create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
796 route1.priority = Priority::Low;
797
798 let mut route2 =
799 create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
800 route2.priority = Priority::High;
801
802 let routes = vec![route1, route2];
803 let matcher = RouteMatcher::new(routes, None).unwrap();
804
805 let req = RequestInfo {
806 method: "GET",
807 path: "/test",
808 host: "example.com",
809 headers: None,
810 query_params: None,
811 };
812
813 let result = matcher.match_request(&req).unwrap();
814 assert_eq!(result.route_id.as_str(), "high");
815 }
816
817 #[test]
818 fn test_query_param_parsing() {
819 let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
820 assert_eq!(params.get("foo"), Some(&"bar".to_string()));
821 assert_eq!(params.get("baz"), Some(&"qux".to_string()));
822 assert_eq!(params.get("empty"), Some(&"".to_string()));
823 }
824}