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,
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    /// Check if any route requires header matching
154    #[inline]
155    pub fn needs_headers(&self) -> bool {
156        self.needs_headers
157    }
158
159    /// Check if any route requires query param matching
160    #[inline]
161    pub fn needs_query_params(&self) -> bool {
162        self.needs_query_params
163    }
164
165    /// Match a request to a route
166    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        // Check cache first (lock-free read)
175        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); // Release the ref before further processing
179            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        // Evaluate routes in priority order
207        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                // Update cache (lock-free insert)
228                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        // Use default route if configured
245        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    /// Find a route by ID
272    fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
273        self.routes.iter().find(|r| r.id == *id)
274    }
275
276    /// Clear the route cache
277    pub fn clear_cache(&self) {
278        self.cache.clear();
279    }
280
281    /// Get cache statistics
282    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, // TODO: Track hits and misses
287        }
288    }
289}
290
291impl CompiledRoute {
292    /// Compile a route configuration into an optimized matcher
293    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    /// Check if this route matches the request
332    fn matches(&self, req: &RequestInfo<'_>) -> bool {
333        // All matchers must pass (AND logic)
334        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    /// Calculate route specificity for tie-breaking
357    fn specificity(&self) -> u32 {
358        let mut score = 0;
359        for matcher in &self.matchers {
360            score += match matcher {
361                CompiledMatcher::Path(_) => 1000,     // Exact path is most specific
362                CompiledMatcher::PathRegex(_) => 500, // Regex is moderately specific
363                CompiledMatcher::PathPrefix(_) => 100, // Prefix is least specific
364                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    /// Check if this matcher matches the request
388    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    /// Parse a host pattern into a matcher
415    fn parse(pattern: &str) -> Self {
416        if pattern.starts_with("*.") {
417            // Wildcard pattern
418            Self::Wildcard {
419                suffix: pattern[2..].to_string(),
420            }
421        } else if pattern.contains('*') || pattern.contains('[') {
422            // Treat as regex if it contains other special characters
423            if let Ok(regex) = Regex::new(pattern) {
424                Self::Regex(regex)
425            } else {
426                // Fall back to exact match if regex compilation fails
427                warn!("Invalid host regex pattern: {}, using exact match", pattern);
428                Self::Exact(pattern.to_string())
429            }
430        } else {
431            // Exact match
432            Self::Exact(pattern.to_string())
433        }
434    }
435
436    /// Check if this matcher matches the host
437    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    /// Create a new route cache
452    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    /// Get a route from cache (lock-free)
461    fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
462        self.entries.get(key)
463    }
464
465    /// Insert a route into cache (lock-free)
466    fn insert(&self, key: String, route_id: RouteId) {
467        // Check if we need to evict (approximate check to avoid overhead)
468        let current_count = self.entry_count.load(Ordering::Relaxed);
469        if current_count >= self.max_size {
470            // Evict ~10% of entries randomly for simplicity
471            // This is faster than true LRU and good enough for a cache
472            self.evict_random();
473        }
474
475        if self.entries.insert(key, route_id).is_none() {
476            // Only increment if this was a new entry
477            self.entry_count.fetch_add(1, Ordering::Relaxed);
478        }
479    }
480
481    /// Evict random entries when cache is full
482    fn evict_random(&self) {
483        let to_evict = self.max_size / 10; // Evict ~10%
484        let mut evicted = 0;
485
486        // Iterate and remove some entries
487        self.entries.retain(|_, _| {
488            if evicted < to_evict {
489                evicted += 1;
490                false // Remove this entry
491            } else {
492                true // Keep this entry
493            }
494        });
495
496        // Update count (approximate)
497        self.entry_count.store(self.entries.len(), Ordering::Relaxed);
498    }
499
500    /// Get current cache size
501    fn len(&self) -> usize {
502        self.entries.len()
503    }
504
505    /// Clear all cache entries
506    fn clear(&self) {
507        self.entries.clear();
508        self.entry_count.store(0, Ordering::Relaxed);
509    }
510}
511
512/// Request information for route matching (zero-copy where possible)
513#[derive(Debug)]
514pub struct RequestInfo<'a> {
515    /// HTTP method (borrowed from request header)
516    pub method: &'a str,
517    /// Request path (borrowed from request header)
518    pub path: &'a str,
519    /// Host header value (borrowed from request header)
520    pub host: &'a str,
521    /// Headers for matching (lazy-initialized, only if needed)
522    headers: Option<HashMap<String, String>>,
523    /// Query parameters (lazy-initialized, only if needed)
524    query_params: Option<HashMap<String, String>>,
525}
526
527impl<'a> RequestInfo<'a> {
528    /// Create a new RequestInfo with borrowed references (zero-copy for common case)
529    #[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    /// Set headers for header-based matching (only call if RouteMatcher.needs_headers())
541    #[inline]
542    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
543        self.headers = Some(headers);
544        self
545    }
546
547    /// Set query params for query-based matching (only call if RouteMatcher.needs_query_params())
548    #[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    /// Get headers (returns empty map if not set)
555    #[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    /// Get query params (returns empty map if not set)
562    #[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    /// 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    pub policies: RoutePolicies,
624}
625
626/// Cache statistics
627#[derive(Debug, Clone)]
628pub struct CacheStats {
629    pub entries: usize,
630    pub max_size: usize,
631    pub hit_rate: f64,
632}
633
634/// Route matching errors
635#[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}