sentinel_proxy/
routing.rs

1//! Route matching and selection module for Sentinel proxy
2//!
3//! This module implements the routing logic for matching incoming requests
4//! to configured routes based on various criteria (path, host, headers, etc.)
5//! with support for priority-based evaluation.
6
7use 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
18/// Route matcher for efficient route selection
19pub struct RouteMatcher {
20    /// Routes sorted by priority (highest first)
21    routes: Vec<CompiledRoute>,
22    /// Default route ID if no match found
23    default_route: Option<RouteId>,
24    /// Cache for frequently matched routes (lock-free concurrent access)
25    cache: Arc<RouteCache>,
26    /// Whether any route requires header matching (optimization flag)
27    needs_headers: bool,
28    /// Whether any route requires query param matching (optimization flag)
29    needs_query_params: bool,
30}
31
32/// Compiled route with pre-processed match conditions
33struct CompiledRoute {
34    /// Route configuration
35    config: Arc<RouteConfig>,
36    /// Route ID for quick lookup
37    id: RouteId,
38    /// Priority for ordering
39    priority: Priority,
40    /// Compiled match conditions
41    matchers: Vec<CompiledMatcher>,
42}
43
44/// Compiled match condition for efficient evaluation
45enum CompiledMatcher {
46    /// Exact path match
47    Path(String),
48    /// Path prefix match
49    PathPrefix(String),
50    /// Regex path match
51    PathRegex(Regex),
52    /// Host match (exact or wildcard)
53    Host(HostMatcher),
54    /// Header presence or value match
55    Header { name: String, value: Option<String> },
56    /// HTTP method match
57    Method(Vec<String>),
58    /// Query parameter match
59    QueryParam { name: String, value: Option<String> },
60}
61
62/// Host matching logic
63enum HostMatcher {
64    /// Exact host match
65    Exact(String),
66    /// Wildcard match (*.example.com)
67    Wildcard { suffix: String },
68    /// Regex match
69    Regex(Regex),
70}
71
72/// Route cache for performance (lock-free concurrent access)
73struct RouteCache {
74    /// Cache entries (cache key -> route ID) - lock-free concurrent map
75    entries: DashMap<String, RouteId>,
76    /// Maximum cache size
77    max_size: usize,
78    /// Current entry count (approximate, for eviction decisions)
79    entry_count: AtomicUsize,
80}
81
82impl RouteMatcher {
83    /// Create a new route matcher from configuration
84    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        // Sort by priority (highest first), then by specificity
108        compiled_routes.sort_by(|a, b| {
109            b.priority
110                .cmp(&a.priority)
111                .then_with(|| b.specificity().cmp(&a.specificity()))
112        });
113
114        // Log final route order
115        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        // Determine if any routes need headers or query params (optimization)
126        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    /// Check if any route requires header matching
152    #[inline]
153    pub fn needs_headers(&self) -> bool {
154        self.needs_headers
155    }
156
157    /// Check if any route requires query param matching
158    #[inline]
159    pub fn needs_query_params(&self) -> bool {
160        self.needs_query_params
161    }
162
163    /// Match a request to a route
164    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        // Check cache first (lock-free read)
173        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); // Release the ref before further processing
177            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        // Evaluate routes in priority order
204        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                // Update cache (lock-free insert)
225                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        // Use default route if configured
241        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    /// Find a route by ID
267    fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
268        self.routes.iter().find(|r| r.id == *id)
269    }
270
271    /// Clear the route cache
272    pub fn clear_cache(&self) {
273        self.cache.clear();
274    }
275
276    /// Get cache statistics
277    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, // TODO: Track hits and misses
282        }
283    }
284}
285
286impl CompiledRoute {
287    /// Compile a route configuration into an optimized matcher
288    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    /// Check if this route matches the request
327    fn matches(&self, req: &RequestInfo<'_>) -> bool {
328        // All matchers must pass (AND logic)
329        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    /// Calculate route specificity for tie-breaking
352    fn specificity(&self) -> u32 {
353        let mut score = 0;
354        for matcher in &self.matchers {
355            score += match matcher {
356                CompiledMatcher::Path(_) => 1000,     // Exact path is most specific
357                CompiledMatcher::PathRegex(_) => 500, // Regex is moderately specific
358                CompiledMatcher::PathPrefix(_) => 100, // Prefix is least specific
359                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    /// Check if this matcher matches the request
383    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    /// Parse a host pattern into a matcher
410    fn parse(pattern: &str) -> Self {
411        if pattern.starts_with("*.") {
412            // Wildcard pattern
413            Self::Wildcard {
414                suffix: pattern[2..].to_string(),
415            }
416        } else if pattern.contains('*') || pattern.contains('[') {
417            // Treat as regex if it contains other special characters
418            if let Ok(regex) = Regex::new(pattern) {
419                Self::Regex(regex)
420            } else {
421                // Fall back to exact match if regex compilation fails
422                warn!("Invalid host regex pattern: {}, using exact match", pattern);
423                Self::Exact(pattern.to_string())
424            }
425        } else {
426            // Exact match
427            Self::Exact(pattern.to_string())
428        }
429    }
430
431    /// Check if this matcher matches the host
432    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    /// Create a new route cache
447    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    /// Get a route from cache (lock-free)
456    fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
457        self.entries.get(key)
458    }
459
460    /// Insert a route into cache (lock-free)
461    fn insert(&self, key: String, route_id: RouteId) {
462        // Check if we need to evict (approximate check to avoid overhead)
463        let current_count = self.entry_count.load(Ordering::Relaxed);
464        if current_count >= self.max_size {
465            // Evict ~10% of entries randomly for simplicity
466            // This is faster than true LRU and good enough for a cache
467            self.evict_random();
468        }
469
470        if self.entries.insert(key, route_id).is_none() {
471            // Only increment if this was a new entry
472            self.entry_count.fetch_add(1, Ordering::Relaxed);
473        }
474    }
475
476    /// Evict random entries when cache is full
477    fn evict_random(&self) {
478        let to_evict = self.max_size / 10; // Evict ~10%
479        let mut evicted = 0;
480
481        // Iterate and remove some entries
482        self.entries.retain(|_, _| {
483            if evicted < to_evict {
484                evicted += 1;
485                false // Remove this entry
486            } else {
487                true // Keep this entry
488            }
489        });
490
491        // Update count (approximate)
492        self.entry_count
493            .store(self.entries.len(), Ordering::Relaxed);
494    }
495
496    /// Get current cache size
497    fn len(&self) -> usize {
498        self.entries.len()
499    }
500
501    /// Clear all cache entries
502    fn clear(&self) {
503        self.entries.clear();
504        self.entry_count.store(0, Ordering::Relaxed);
505    }
506}
507
508/// Request information for route matching (zero-copy where possible)
509#[derive(Debug)]
510pub struct RequestInfo<'a> {
511    /// HTTP method (borrowed from request header)
512    pub method: &'a str,
513    /// Request path (borrowed from request header)
514    pub path: &'a str,
515    /// Host header value (borrowed from request header)
516    pub host: &'a str,
517    /// Headers for matching (lazy-initialized, only if needed)
518    headers: Option<HashMap<String, String>>,
519    /// Query parameters (lazy-initialized, only if needed)
520    query_params: Option<HashMap<String, String>>,
521}
522
523impl<'a> RequestInfo<'a> {
524    /// Create a new RequestInfo with borrowed references (zero-copy for common case)
525    #[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    /// Set headers for header-based matching (only call if RouteMatcher.needs_headers())
537    #[inline]
538    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
539        self.headers = Some(headers);
540        self
541    }
542
543    /// Set query params for query-based matching (only call if RouteMatcher.needs_query_params())
544    #[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    /// Get headers (returns empty map if not set)
551    #[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    /// Get query params (returns empty map if not set)
560    #[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    /// Generate a cache key for this request
569    fn cache_key(&self) -> String {
570        format!("{}:{}:{}", self.method, self.host, self.path)
571    }
572
573    /// Parse query parameters from path (only call when needed)
574    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    /// Build headers map from request header iterator (only call when needed)
604    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/// Route match result
619#[derive(Debug, Clone)]
620pub struct RouteMatch {
621    pub route_id: RouteId,
622    pub config: Arc<RouteConfig>,
623}
624
625impl RouteMatch {
626    /// Access route policies (convenience accessor to avoid repeated .config.policies)
627    #[inline]
628    pub fn policies(&self) -> &RoutePolicies {
629        &self.config.policies
630    }
631}
632
633/// Cache statistics
634#[derive(Debug, Clone)]
635pub struct CacheStats {
636    pub entries: usize,
637    pub max_size: usize,
638    pub hit_rate: f64,
639}
640
641/// Route matching errors
642#[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}