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 regex::Regex;
8use std::collections::HashMap;
9use std::sync::Arc;
10use tracing::{debug, trace, warn};
11
12use sentinel_common::types::Priority;
13use sentinel_common::RouteId;
14use sentinel_config::{MatchCondition, RouteConfig, RoutePolicies};
15
16/// Route matcher for efficient route selection
17pub struct RouteMatcher {
18    /// Routes sorted by priority (highest first)
19    routes: Vec<CompiledRoute>,
20    /// Default route ID if no match found
21    default_route: Option<RouteId>,
22    /// Cache for frequently matched routes (LRU-style)
23    cache: Arc<parking_lot::RwLock<RouteCache>>,
24}
25
26/// Compiled route with pre-processed match conditions
27struct CompiledRoute {
28    /// Route configuration
29    config: Arc<RouteConfig>,
30    /// Route ID for quick lookup
31    id: RouteId,
32    /// Priority for ordering
33    priority: Priority,
34    /// Compiled match conditions
35    matchers: Vec<CompiledMatcher>,
36}
37
38/// Compiled match condition for efficient evaluation
39enum CompiledMatcher {
40    /// Exact path match
41    Path(String),
42    /// Path prefix match
43    PathPrefix(String),
44    /// Regex path match
45    PathRegex(Regex),
46    /// Host match (exact or wildcard)
47    Host(HostMatcher),
48    /// Header presence or value match
49    Header { name: String, value: Option<String> },
50    /// HTTP method match
51    Method(Vec<String>),
52    /// Query parameter match
53    QueryParam { name: String, value: Option<String> },
54}
55
56/// Host matching logic
57enum HostMatcher {
58    /// Exact host match
59    Exact(String),
60    /// Wildcard match (*.example.com)
61    Wildcard { suffix: String },
62    /// Regex match
63    Regex(Regex),
64}
65
66/// Route cache for performance
67struct RouteCache {
68    /// Cache entries (cache key -> route ID)
69    entries: HashMap<String, RouteId>,
70    /// Maximum cache size
71    max_size: usize,
72    /// Access counter for LRU eviction
73    access_counter: u64,
74    /// Access timestamps for cache entries
75    access_times: HashMap<String, u64>,
76}
77
78impl RouteMatcher {
79    /// Create a new route matcher from configuration
80    pub fn new(
81        routes: Vec<RouteConfig>,
82        default_route: Option<String>,
83    ) -> Result<Self, RouteError> {
84        let mut compiled_routes = Vec::new();
85
86        for route in routes {
87            let compiled = CompiledRoute::compile(route)?;
88            compiled_routes.push(compiled);
89        }
90
91        // Sort by priority (highest first), then by specificity
92        compiled_routes.sort_by(|a, b| {
93            b.priority
94                .cmp(&a.priority)
95                .then_with(|| b.specificity().cmp(&a.specificity()))
96        });
97
98        Ok(Self {
99            routes: compiled_routes,
100            default_route: default_route.map(RouteId::new),
101            cache: Arc::new(parking_lot::RwLock::new(RouteCache::new(1000))),
102        })
103    }
104
105    /// Match a request to a route
106    pub fn match_request(&self, req: &RequestInfo) -> Option<RouteMatch> {
107        // Check cache first
108        let cache_key = req.cache_key();
109        if let Some(route_id) = self.cache.write().get(&cache_key) {
110            debug!(route_id = %route_id, "Cache hit for route");
111            if let Some(route) = self.find_route_by_id(&route_id) {
112                return Some(RouteMatch {
113                    route_id,
114                    config: route.config.clone(),
115                    policies: route.config.policies.clone(),
116                });
117            }
118        }
119
120        // Evaluate routes in priority order
121        for route in &self.routes {
122            if route.matches(req) {
123                debug!(
124                    route_id = %route.id,
125                    priority = ?route.priority,
126                    "Route matched"
127                );
128
129                // Update cache
130                self.cache
131                    .write()
132                    .insert(cache_key.clone(), route.id.clone());
133
134                return Some(RouteMatch {
135                    route_id: route.id.clone(),
136                    config: route.config.clone(),
137                    policies: route.config.policies.clone(),
138                });
139            }
140        }
141
142        // Use default route if configured
143        if let Some(ref default_id) = self.default_route {
144            debug!(route_id = %default_id, "Using default route");
145            if let Some(route) = self.find_route_by_id(default_id) {
146                return Some(RouteMatch {
147                    route_id: default_id.clone(),
148                    config: route.config.clone(),
149                    policies: route.config.policies.clone(),
150                });
151            }
152        }
153
154        debug!("No route matched");
155        None
156    }
157
158    /// Find a route by ID
159    fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
160        self.routes.iter().find(|r| r.id == *id)
161    }
162
163    /// Clear the route cache
164    pub fn clear_cache(&self) {
165        self.cache.write().clear();
166    }
167
168    /// Get cache statistics
169    pub fn cache_stats(&self) -> CacheStats {
170        let cache = self.cache.read();
171        CacheStats {
172            entries: cache.entries.len(),
173            max_size: cache.max_size,
174            hit_rate: 0.0, // TODO: Track hits and misses
175        }
176    }
177}
178
179impl CompiledRoute {
180    /// Compile a route configuration into an optimized matcher
181    fn compile(config: RouteConfig) -> Result<Self, RouteError> {
182        let mut matchers = Vec::new();
183
184        for condition in &config.matches {
185            let compiled = match condition {
186                MatchCondition::Path(path) => CompiledMatcher::Path(path.clone()),
187                MatchCondition::PathPrefix(prefix) => CompiledMatcher::PathPrefix(prefix.clone()),
188                MatchCondition::PathRegex(pattern) => {
189                    let regex = Regex::new(pattern).map_err(|e| RouteError::InvalidRegex {
190                        pattern: pattern.clone(),
191                        error: e.to_string(),
192                    })?;
193                    CompiledMatcher::PathRegex(regex)
194                }
195                MatchCondition::Host(host) => CompiledMatcher::Host(HostMatcher::parse(host)),
196                MatchCondition::Header { name, value } => CompiledMatcher::Header {
197                    name: name.to_lowercase(),
198                    value: value.clone(),
199                },
200                MatchCondition::Method(methods) => {
201                    CompiledMatcher::Method(methods.iter().map(|m| m.to_uppercase()).collect())
202                }
203                MatchCondition::QueryParam { name, value } => CompiledMatcher::QueryParam {
204                    name: name.clone(),
205                    value: value.clone(),
206                },
207            };
208            matchers.push(compiled);
209        }
210
211        Ok(Self {
212            id: RouteId::new(&config.id),
213            priority: config.priority,
214            config: Arc::new(config),
215            matchers,
216        })
217    }
218
219    /// Check if this route matches the request
220    fn matches(&self, req: &RequestInfo) -> bool {
221        // All matchers must pass (AND logic)
222        for matcher in &self.matchers {
223            if !matcher.matches(req) {
224                trace!(
225                    route_id = %self.id,
226                    matcher = ?matcher,
227                    "Matcher failed"
228                );
229                return false;
230            }
231        }
232        true
233    }
234
235    /// Calculate route specificity for tie-breaking
236    fn specificity(&self) -> u32 {
237        let mut score = 0;
238        for matcher in &self.matchers {
239            score += match matcher {
240                CompiledMatcher::Path(_) => 1000,     // Exact path is most specific
241                CompiledMatcher::PathRegex(_) => 500, // Regex is moderately specific
242                CompiledMatcher::PathPrefix(_) => 100, // Prefix is least specific
243                CompiledMatcher::Host(_) => 50,
244                CompiledMatcher::Header { value, .. } => {
245                    if value.is_some() {
246                        30
247                    } else {
248                        20
249                    }
250                }
251                CompiledMatcher::Method(_) => 10,
252                CompiledMatcher::QueryParam { value, .. } => {
253                    if value.is_some() {
254                        25
255                    } else {
256                        15
257                    }
258                }
259            };
260        }
261        score
262    }
263}
264
265impl CompiledMatcher {
266    /// Check if this matcher matches the request
267    fn matches(&self, req: &RequestInfo) -> bool {
268        match self {
269            Self::Path(path) => req.path == *path,
270            Self::PathPrefix(prefix) => req.path.starts_with(prefix),
271            Self::PathRegex(regex) => regex.is_match(&req.path),
272            Self::Host(host_matcher) => host_matcher.matches(&req.host),
273            Self::Header { name, value } => {
274                if let Some(header_value) = req.headers.get(name) {
275                    value.as_ref().map_or(true, |v| header_value == v)
276                } else {
277                    false
278                }
279            }
280            Self::Method(methods) => methods.contains(&req.method),
281            Self::QueryParam { name, value } => {
282                if let Some(param_value) = req.query_params.get(name) {
283                    value.as_ref().map_or(true, |v| param_value == v)
284                } else {
285                    false
286                }
287            }
288        }
289    }
290}
291
292impl HostMatcher {
293    /// Parse a host pattern into a matcher
294    fn parse(pattern: &str) -> Self {
295        if pattern.starts_with("*.") {
296            // Wildcard pattern
297            Self::Wildcard {
298                suffix: pattern[2..].to_string(),
299            }
300        } else if pattern.contains('*') || pattern.contains('[') {
301            // Treat as regex if it contains other special characters
302            if let Ok(regex) = Regex::new(pattern) {
303                Self::Regex(regex)
304            } else {
305                // Fall back to exact match if regex compilation fails
306                warn!("Invalid host regex pattern: {}, using exact match", pattern);
307                Self::Exact(pattern.to_string())
308            }
309        } else {
310            // Exact match
311            Self::Exact(pattern.to_string())
312        }
313    }
314
315    /// Check if this matcher matches the host
316    fn matches(&self, host: &str) -> bool {
317        match self {
318            Self::Exact(pattern) => host == pattern,
319            Self::Wildcard { suffix } => {
320                host.ends_with(suffix)
321                    && host.len() > suffix.len()
322                    && host[..host.len() - suffix.len()].ends_with('.')
323            }
324            Self::Regex(regex) => regex.is_match(host),
325        }
326    }
327}
328
329impl RouteCache {
330    /// Create a new route cache
331    fn new(max_size: usize) -> Self {
332        Self {
333            entries: HashMap::new(),
334            max_size,
335            access_counter: 0,
336            access_times: HashMap::new(),
337        }
338    }
339
340    /// Get a route from cache
341    fn get(&mut self, key: &str) -> Option<RouteId> {
342        if let Some(route_id) = self.entries.get(key) {
343            self.access_counter += 1;
344            self.access_times
345                .insert(key.to_string(), self.access_counter);
346            Some(route_id.clone())
347        } else {
348            None
349        }
350    }
351
352    /// Insert a route into cache
353    fn insert(&mut self, key: String, route_id: RouteId) {
354        // Evict least recently used if at capacity
355        if self.entries.len() >= self.max_size {
356            self.evict_lru();
357        }
358
359        self.access_counter += 1;
360        self.access_times.insert(key.clone(), self.access_counter);
361        self.entries.insert(key, route_id);
362    }
363
364    /// Evict the least recently used entry
365    fn evict_lru(&mut self) {
366        if let Some((key, _)) = self
367            .access_times
368            .iter()
369            .min_by_key(|(_, &time)| time)
370            .map(|(k, v)| (k.clone(), *v))
371        {
372            self.entries.remove(&key);
373            self.access_times.remove(&key);
374        }
375    }
376
377    /// Clear all cache entries
378    fn clear(&mut self) {
379        self.entries.clear();
380        self.access_times.clear();
381        self.access_counter = 0;
382    }
383}
384
385/// Request information for route matching
386#[derive(Debug, Clone)]
387pub struct RequestInfo {
388    pub method: String,
389    pub path: String,
390    pub host: String,
391    pub headers: HashMap<String, String>,
392    pub query_params: HashMap<String, String>,
393}
394
395impl RequestInfo {
396    /// Generate a cache key for this request
397    fn cache_key(&self) -> String {
398        format!("{}:{}:{}", self.method, self.host, self.path)
399    }
400
401    /// Parse query parameters from path
402    pub fn parse_query_params(path: &str) -> HashMap<String, String> {
403        let mut params = HashMap::new();
404        if let Some(query_start) = path.find('?') {
405            let query = &path[query_start + 1..];
406            for pair in query.split('&') {
407                if let Some(eq_pos) = pair.find('=') {
408                    let key = &pair[..eq_pos];
409                    let value = &pair[eq_pos + 1..];
410                    params.insert(
411                        urlencoding::decode(key)
412                            .unwrap_or_else(|_| key.into())
413                            .into_owned(),
414                        urlencoding::decode(value)
415                            .unwrap_or_else(|_| value.into())
416                            .into_owned(),
417                    );
418                } else {
419                    params.insert(
420                        urlencoding::decode(pair)
421                            .unwrap_or_else(|_| pair.into())
422                            .into_owned(),
423                        String::new(),
424                    );
425                }
426            }
427        }
428        params
429    }
430}
431
432/// Route match result
433#[derive(Debug, Clone)]
434pub struct RouteMatch {
435    pub route_id: RouteId,
436    pub config: Arc<RouteConfig>,
437    pub policies: RoutePolicies,
438}
439
440/// Cache statistics
441#[derive(Debug, Clone)]
442pub struct CacheStats {
443    pub entries: usize,
444    pub max_size: usize,
445    pub hit_rate: f64,
446}
447
448/// Route matching errors
449#[derive(Debug, thiserror::Error)]
450pub enum RouteError {
451    #[error("Invalid regex pattern '{pattern}': {error}")]
452    InvalidRegex { pattern: String, error: String },
453
454    #[error("Invalid route configuration: {0}")]
455    InvalidConfig(String),
456
457    #[error("Duplicate route ID: {0}")]
458    DuplicateRouteId(String),
459}
460
461impl std::fmt::Debug for CompiledMatcher {
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        match self {
464            Self::Path(p) => write!(f, "Path({})", p),
465            Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
466            Self::PathRegex(_) => write!(f, "PathRegex(...)"),
467            Self::Host(_) => write!(f, "Host(...)"),
468            Self::Header { name, .. } => write!(f, "Header({})", name),
469            Self::Method(m) => write!(f, "Method({:?})", m),
470            Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use sentinel_common::types::Priority;
479    use sentinel_config::{MatchCondition, RouteConfig};
480
481    fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
482        RouteConfig {
483            id: id.to_string(),
484            priority: Priority::Normal,
485            matches,
486            upstream: Some("test_upstream".to_string()),
487            service_type: sentinel_config::ServiceType::Web,
488            policies: Default::default(),
489            filters: vec![],
490            builtin_handler: None,
491            waf_enabled: false,
492            circuit_breaker: None,
493            retry_policy: None,
494            static_files: None,
495            api_schema: None,
496            error_pages: None,
497        }
498    }
499
500    #[test]
501    fn test_path_matching() {
502        let routes = vec![
503            create_test_route(
504                "exact",
505                vec![MatchCondition::Path("/api/v1/users".to_string())],
506            ),
507            create_test_route(
508                "prefix",
509                vec![MatchCondition::PathPrefix("/api/".to_string())],
510            ),
511        ];
512
513        let matcher = RouteMatcher::new(routes, None).unwrap();
514
515        let req = RequestInfo {
516            method: "GET".to_string(),
517            path: "/api/v1/users".to_string(),
518            host: "example.com".to_string(),
519            headers: HashMap::new(),
520            query_params: HashMap::new(),
521        };
522
523        let result = matcher.match_request(&req).unwrap();
524        assert_eq!(result.route_id.as_str(), "exact");
525    }
526
527    #[test]
528    fn test_host_wildcard_matching() {
529        let routes = vec![create_test_route(
530            "wildcard",
531            vec![MatchCondition::Host("*.example.com".to_string())],
532        )];
533
534        let matcher = RouteMatcher::new(routes, None).unwrap();
535
536        let req = RequestInfo {
537            method: "GET".to_string(),
538            path: "/".to_string(),
539            host: "api.example.com".to_string(),
540            headers: HashMap::new(),
541            query_params: HashMap::new(),
542        };
543
544        let result = matcher.match_request(&req).unwrap();
545        assert_eq!(result.route_id.as_str(), "wildcard");
546    }
547
548    #[test]
549    fn test_priority_ordering() {
550        let mut route1 =
551            create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
552        route1.priority = Priority::Low;
553
554        let mut route2 =
555            create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
556        route2.priority = Priority::High;
557
558        let routes = vec![route1, route2];
559        let matcher = RouteMatcher::new(routes, None).unwrap();
560
561        let req = RequestInfo {
562            method: "GET".to_string(),
563            path: "/test".to_string(),
564            host: "example.com".to_string(),
565            headers: HashMap::new(),
566            query_params: HashMap::new(),
567        };
568
569        let result = matcher.match_request(&req).unwrap();
570        assert_eq!(result.route_id.as_str(), "high");
571    }
572
573    #[test]
574    fn test_query_param_parsing() {
575        let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
576        assert_eq!(params.get("foo"), Some(&"bar".to_string()));
577        assert_eq!(params.get("baz"), Some(&"qux".to_string()));
578        assert_eq!(params.get("empty"), Some(&"".to_string()));
579    }
580}