Skip to main content

zentinel_proxy/
routing.rs

1//! Route matching and selection module for Zentinel 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::{AtomicU64, AtomicUsize, Ordering};
11use std::sync::Arc;
12use tracing::{debug, info, trace, warn};
13
14use zentinel_common::types::Priority;
15use zentinel_common::RouteId;
16use zentinel_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    /// Cache hits counter
81    hits: AtomicU64,
82    /// Cache misses counter
83    misses: AtomicU64,
84}
85
86impl RouteMatcher {
87    /// Create a new route matcher from configuration
88    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        // Sort by priority (highest first), then by specificity
112        compiled_routes.sort_by(|a, b| {
113            b.priority
114                .cmp(&a.priority)
115                .then_with(|| b.specificity().cmp(&a.specificity()))
116        });
117
118        // Log final route order
119        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        // Determine if any routes need headers or query params (optimization)
130        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    /// Check if any route requires header matching
156    #[inline]
157    pub fn needs_headers(&self) -> bool {
158        self.needs_headers
159    }
160
161    /// Check if any route requires query param matching
162    #[inline]
163    pub fn needs_query_params(&self) -> bool {
164        self.needs_query_params
165    }
166
167    /// Match a request to a route
168    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        // Check cache first (lock-free read, zero-allocation on hit)
177        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        // Record cache miss
205        self.cache.record_miss();
206
207        trace!(
208            route_count = self.routes.len(),
209            "Cache miss, evaluating routes"
210        );
211
212        // Evaluate routes in priority order
213        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                // Update cache — allocate key only on miss (rare after warmup)
234                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        // Use default route if configured
251        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    /// Find a route by ID
277    fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
278        self.routes.iter().find(|r| r.id == *id)
279    }
280
281    /// Clear the route cache
282    pub fn clear_cache(&self) {
283        self.cache.clear();
284    }
285
286    /// Get cache statistics
287    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    /// Compile a route configuration into an optimized matcher
298    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    /// Check if this route matches the request.
337    ///
338    /// Host matchers use OR logic (match any host), all other matchers use AND.
339    /// This matches Gateway API semantics where multiple hostnames on an
340    /// HTTPRoute are alternatives, not conjunctions.
341    fn matches(&self, req: &RequestInfo<'_>) -> bool {
342        // Partition matchers into host matchers and non-host matchers
343        let mut has_host_matchers = false;
344        let mut any_host_matched = false;
345
346        for matcher in &self.matchers {
347            match matcher {
348                CompiledMatcher::Host(_) => {
349                    has_host_matchers = true;
350                    if matcher.matches(req) {
351                        any_host_matched = true;
352                    }
353                }
354                _ => {
355                    if !matcher.matches(req) {
356                        trace!(
357                            route_id = %self.id,
358                            matcher_type = ?matcher,
359                            path = %req.path,
360                            "Matcher did not match"
361                        );
362                        return false;
363                    }
364                }
365            }
366        }
367
368        // If there are host matchers, at least one must match (OR logic)
369        if has_host_matchers && !any_host_matched {
370            trace!(
371                route_id = %self.id,
372                host = %req.host,
373                "No host matcher matched"
374            );
375            return false;
376        }
377
378        true
379    }
380
381    /// Calculate route specificity for tie-breaking.
382    ///
383    /// Per Gateway API precedence rules:
384    /// 1. Path specificity is primary (exact > longest prefix > regex)
385    /// 2. Host specificity is secondary (exact > wildcard)
386    /// 3. Header/method/query conditions add specificity
387    ///
388    /// Host matchers use OR logic, so multiple hosts don't increase
389    /// specificity — we use the max host score, not the sum.
390    fn specificity(&self) -> u32 {
391        let mut path_score = 0u32;
392        let mut host_score = 0u32;
393        let mut condition_score = 0u32;
394
395        for matcher in &self.matchers {
396            match matcher {
397                CompiledMatcher::Path(_) => path_score = path_score.max(10000),
398                CompiledMatcher::PathRegex(_) => path_score = path_score.max(5000),
399                CompiledMatcher::PathPrefix(p) => {
400                    path_score = path_score.max(1000 + p.len() as u32)
401                }
402                CompiledMatcher::Host(host) => {
403                    let s = match host {
404                        HostMatcher::Exact(_) => 70,
405                        HostMatcher::Regex(_) => 60,
406                        HostMatcher::Wildcard { .. } => 50,
407                    };
408                    host_score = host_score.max(s);
409                }
410                CompiledMatcher::Header { value, .. } => {
411                    condition_score += if value.is_some() { 30 } else { 20 };
412                }
413                CompiledMatcher::Method(_) => condition_score += 10,
414                CompiledMatcher::QueryParam { value, .. } => {
415                    condition_score += if value.is_some() { 25 } else { 15 };
416                }
417            }
418        }
419
420        path_score + host_score + condition_score
421    }
422}
423
424impl CompiledMatcher {
425    /// Check if this matcher matches the request
426    fn matches(&self, req: &RequestInfo<'_>) -> bool {
427        match self {
428            Self::Path(path) => req.path == *path,
429            Self::PathPrefix(prefix) => {
430                if !req.path.starts_with(prefix) {
431                    return false;
432                }
433                // Enforce segment boundary per Gateway API spec:
434                // PathPrefix "/v2" must NOT match "/v2example", only "/v2", "/v2/", "/v2/anything"
435                prefix == "/"
436                    || req.path.len() == prefix.len()
437                    || prefix.ends_with('/')
438                    || req.path.as_bytes()[prefix.len()] == b'/'
439                    || req.path.as_bytes()[prefix.len()] == b'?'
440            }
441            Self::PathRegex(regex) => regex.is_match(req.path),
442            Self::Host(host_matcher) => host_matcher.matches(req.host),
443            Self::Header { name, value } => {
444                if let Some(header_value) = req.headers().get(name) {
445                    value.as_ref().is_none_or(|v| header_value == v)
446                } else {
447                    false
448                }
449            }
450            Self::Method(methods) => methods.iter().any(|m| m == req.method),
451            Self::QueryParam { name, value } => {
452                if let Some(param_value) = req.query_params().get(name) {
453                    value.as_ref().is_none_or(|v| param_value == v)
454                } else {
455                    false
456                }
457            }
458        }
459    }
460}
461
462impl HostMatcher {
463    /// Parse a host pattern into a matcher
464    fn parse(pattern: &str) -> Self {
465        if pattern.starts_with("*.") {
466            // Wildcard pattern
467            Self::Wildcard {
468                suffix: pattern[2..].to_string(),
469            }
470        } else if pattern.contains('*') || pattern.contains('[') {
471            // Treat as regex if it contains other special characters
472            if let Ok(regex) = Regex::new(pattern) {
473                Self::Regex(regex)
474            } else {
475                // Fall back to exact match if regex compilation fails
476                warn!("Invalid host regex pattern: {}, using exact match", pattern);
477                Self::Exact(pattern.to_string())
478            }
479        } else {
480            // Exact match
481            Self::Exact(pattern.to_string())
482        }
483    }
484
485    /// Check if this matcher matches the host.
486    ///
487    /// Strips any port suffix from the host before matching, per Gateway API
488    /// spec: `Host: example.com:8080` must match hostname `example.com`.
489    fn matches(&self, host: &str) -> bool {
490        // Strip port from host (e.g. "example.com:8080" → "example.com")
491        let host = host.split(':').next().unwrap_or(host);
492        match self {
493            Self::Exact(pattern) => host == pattern,
494            Self::Wildcard { suffix } => {
495                host.ends_with(suffix)
496                    && host.len() > suffix.len()
497                    && host[..host.len() - suffix.len()].ends_with('.')
498            }
499            Self::Regex(regex) => regex.is_match(host),
500        }
501    }
502}
503
504impl RouteCache {
505    /// Create a new route cache
506    fn new(max_size: usize) -> Self {
507        Self {
508            entries: DashMap::with_capacity(max_size),
509            max_size,
510            entry_count: AtomicUsize::new(0),
511            hits: AtomicU64::new(0),
512            misses: AtomicU64::new(0),
513        }
514    }
515
516    /// Get a route from cache (lock-free)
517    fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
518        let result = self.entries.get(key);
519        if result.is_some() {
520            self.hits.fetch_add(1, Ordering::Relaxed);
521        }
522        result
523    }
524
525    /// Record a cache miss
526    fn record_miss(&self) {
527        self.misses.fetch_add(1, Ordering::Relaxed);
528    }
529
530    /// Get the hit rate (0.0 to 1.0)
531    fn hit_rate(&self) -> f64 {
532        let hits = self.hits.load(Ordering::Relaxed);
533        let misses = self.misses.load(Ordering::Relaxed);
534        let total = hits + misses;
535        if total == 0 {
536            0.0
537        } else {
538            hits as f64 / total as f64
539        }
540    }
541
542    /// Insert a route into cache (lock-free)
543    fn insert(&self, key: String, route_id: RouteId) {
544        // Check if we need to evict (approximate check to avoid overhead)
545        let current_count = self.entry_count.load(Ordering::Relaxed);
546        if current_count >= self.max_size {
547            // Evict ~10% of entries randomly for simplicity
548            // This is faster than true LRU and good enough for a cache
549            self.evict_random();
550        }
551
552        if self.entries.insert(key, route_id).is_none() {
553            // Only increment if this was a new entry
554            self.entry_count.fetch_add(1, Ordering::Relaxed);
555        }
556    }
557
558    /// Evict random entries when cache is full
559    fn evict_random(&self) {
560        let to_evict = self.max_size / 10; // Evict ~10%
561        let mut evicted = 0;
562
563        // Iterate and remove some entries
564        self.entries.retain(|_, _| {
565            if evicted < to_evict {
566                evicted += 1;
567                false // Remove this entry
568            } else {
569                true // Keep this entry
570            }
571        });
572
573        // Update count (approximate)
574        self.entry_count
575            .store(self.entries.len(), Ordering::Relaxed);
576    }
577
578    /// Get current cache size
579    fn len(&self) -> usize {
580        self.entries.len()
581    }
582
583    /// Clear all cache entries
584    fn clear(&self) {
585        self.entries.clear();
586        self.entry_count.store(0, Ordering::Relaxed);
587    }
588}
589
590/// Request information for route matching (zero-copy where possible)
591#[derive(Debug)]
592pub struct RequestInfo<'a> {
593    /// HTTP method (borrowed from request header)
594    pub method: &'a str,
595    /// Request path (borrowed from request header)
596    pub path: &'a str,
597    /// Host header value (borrowed from request header)
598    pub host: &'a str,
599    /// Headers for matching (lazy-initialized, only if needed)
600    headers: Option<HashMap<String, String>>,
601    /// Query parameters (lazy-initialized, only if needed)
602    query_params: Option<HashMap<String, String>>,
603}
604
605impl<'a> RequestInfo<'a> {
606    /// Create a new RequestInfo with borrowed references (zero-copy for common case)
607    #[inline]
608    pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
609        Self {
610            method,
611            path,
612            host,
613            headers: None,
614            query_params: None,
615        }
616    }
617
618    /// Set headers for header-based matching (only call if RouteMatcher.needs_headers())
619    #[inline]
620    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
621        self.headers = Some(headers);
622        self
623    }
624
625    /// Set query params for query-based matching (only call if RouteMatcher.needs_query_params())
626    #[inline]
627    pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
628        self.query_params = Some(params);
629        self
630    }
631
632    /// Get headers (returns empty map if not set)
633    #[inline]
634    pub fn headers(&self) -> &HashMap<String, String> {
635        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
636        self.headers
637            .as_ref()
638            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
639    }
640
641    /// Get query params (returns empty map if not set)
642    #[inline]
643    pub fn query_params(&self) -> &HashMap<String, String> {
644        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
645        self.query_params
646            .as_ref()
647            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
648    }
649
650    /// Generate a cache key for this request using a thread-local buffer
651    /// to avoid per-request heap allocation.
652    fn with_cache_key<R>(&self, f: impl FnOnce(&str) -> R) -> R {
653        use std::cell::RefCell;
654        use std::fmt::Write;
655
656        thread_local! {
657            static BUF: RefCell<String> = RefCell::new(String::with_capacity(128));
658        }
659
660        BUF.with(|buf| {
661            let mut buf = buf.borrow_mut();
662            buf.clear();
663            let _ = write!(buf, "{}:{}:{}", self.method, self.host, self.path);
664            // Include headers in cache key when header-based routing is active,
665            // otherwise different header combinations can poison the cache.
666            if let Some(ref headers) = self.headers {
667                let mut pairs: Vec<_> = headers.iter().collect();
668                pairs.sort_by_key(|(k, _)| k.as_str());
669                for (k, v) in pairs {
670                    let _ = write!(buf, "\n{k}={v}");
671                }
672            }
673            f(&buf)
674        })
675    }
676
677    /// Parse query parameters from path (only call when needed)
678    pub fn parse_query_params(path: &str) -> HashMap<String, String> {
679        let mut params = HashMap::new();
680        if let Some(query_start) = path.find('?') {
681            let query = &path[query_start + 1..];
682            for pair in query.split('&') {
683                if let Some(eq_pos) = pair.find('=') {
684                    let key = &pair[..eq_pos];
685                    let value = &pair[eq_pos + 1..];
686                    params.insert(
687                        urlencoding::decode(key)
688                            .unwrap_or_else(|_| key.into())
689                            .into_owned(),
690                        urlencoding::decode(value)
691                            .unwrap_or_else(|_| value.into())
692                            .into_owned(),
693                    );
694                } else {
695                    params.insert(
696                        urlencoding::decode(pair)
697                            .unwrap_or_else(|_| pair.into())
698                            .into_owned(),
699                        String::new(),
700                    );
701                }
702            }
703        }
704        params
705    }
706
707    /// Build headers map from request header iterator (only call when needed)
708    pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
709    where
710        I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
711    {
712        let mut headers = HashMap::new();
713        for (name, value) in iter {
714            if let Ok(value_str) = value.to_str() {
715                headers.insert(name.as_str().to_lowercase(), value_str.to_string());
716            }
717        }
718        headers
719    }
720}
721
722/// Route match result
723#[derive(Debug, Clone)]
724pub struct RouteMatch {
725    pub route_id: RouteId,
726    pub config: Arc<RouteConfig>,
727}
728
729impl RouteMatch {
730    /// Access route policies (convenience accessor to avoid repeated .config.policies)
731    #[inline]
732    pub fn policies(&self) -> &RoutePolicies {
733        &self.config.policies
734    }
735}
736
737/// Cache statistics
738#[derive(Debug, Clone)]
739pub struct CacheStats {
740    pub entries: usize,
741    pub max_size: usize,
742    pub hit_rate: f64,
743}
744
745/// Route matching errors
746#[derive(Debug, thiserror::Error)]
747pub enum RouteError {
748    #[error("Invalid regex pattern '{pattern}': {error}")]
749    InvalidRegex { pattern: String, error: String },
750
751    #[error("Invalid route configuration: {0}")]
752    InvalidConfig(String),
753
754    #[error("Duplicate route ID: {0}")]
755    DuplicateRouteId(String),
756}
757
758impl std::fmt::Debug for CompiledMatcher {
759    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
760        match self {
761            Self::Path(p) => write!(f, "Path({})", p),
762            Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
763            Self::PathRegex(_) => write!(f, "PathRegex(...)"),
764            Self::Host(_) => write!(f, "Host(...)"),
765            Self::Header { name, .. } => write!(f, "Header({})", name),
766            Self::Method(m) => write!(f, "Method({:?})", m),
767            Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
768        }
769    }
770}
771
772#[cfg(test)]
773mod tests {
774    use super::*;
775    use zentinel_common::types::Priority;
776    use zentinel_config::{MatchCondition, RouteConfig};
777
778    fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
779        RouteConfig {
780            id: id.to_string(),
781            priority: Priority::NORMAL,
782            matches,
783            upstream: Some("test_upstream".to_string()),
784            service_type: zentinel_config::ServiceType::Web,
785            policies: Default::default(),
786            filters: vec![],
787            builtin_handler: None,
788            waf_enabled: false,
789            circuit_breaker: None,
790            retry_policy: None,
791            static_files: None,
792            api_schema: None,
793            error_pages: None,
794            websocket: false,
795            websocket_inspection: false,
796            inference: None,
797            shadow: None,
798            fallback: None,
799        }
800    }
801
802    #[test]
803    fn test_path_matching() {
804        let routes = vec![
805            create_test_route(
806                "exact",
807                vec![MatchCondition::Path("/api/v1/users".to_string())],
808            ),
809            create_test_route(
810                "prefix",
811                vec![MatchCondition::PathPrefix("/api/".to_string())],
812            ),
813        ];
814
815        let matcher = RouteMatcher::new(routes, None).unwrap();
816
817        let req = RequestInfo {
818            method: "GET",
819            path: "/api/v1/users",
820            host: "example.com",
821            headers: None,
822            query_params: None,
823        };
824
825        let result = matcher.match_request(&req).unwrap();
826        assert_eq!(result.route_id.as_str(), "exact");
827    }
828
829    #[test]
830    fn test_host_wildcard_matching() {
831        let routes = vec![create_test_route(
832            "wildcard",
833            vec![MatchCondition::Host("*.example.com".to_string())],
834        )];
835
836        let matcher = RouteMatcher::new(routes, None).unwrap();
837
838        let req = RequestInfo {
839            method: "GET",
840            path: "/",
841            host: "api.example.com",
842            headers: None,
843            query_params: None,
844        };
845
846        let result = matcher.match_request(&req).unwrap();
847        assert_eq!(result.route_id.as_str(), "wildcard");
848    }
849
850    #[test]
851    fn test_priority_ordering() {
852        let mut route1 =
853            create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
854        route1.priority = Priority::LOW;
855
856        let mut route2 =
857            create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
858        route2.priority = Priority::HIGH;
859
860        let routes = vec![route1, route2];
861        let matcher = RouteMatcher::new(routes, None).unwrap();
862
863        let req = RequestInfo {
864            method: "GET",
865            path: "/test",
866            host: "example.com",
867            headers: None,
868            query_params: None,
869        };
870
871        let result = matcher.match_request(&req).unwrap();
872        assert_eq!(result.route_id.as_str(), "high");
873    }
874
875    #[test]
876    fn test_query_param_parsing() {
877        let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
878        assert_eq!(params.get("foo"), Some(&"bar".to_string()));
879        assert_eq!(params.get("baz"), Some(&"qux".to_string()));
880        assert_eq!(params.get("empty"), Some(&"".to_string()));
881    }
882
883    #[test]
884    fn test_path_prefix_segment_boundary() {
885        let routes = vec![
886            create_test_route("v2", vec![MatchCondition::PathPrefix("/v2".to_string())]),
887            create_test_route(
888                "catch-all",
889                vec![MatchCondition::PathPrefix("/".to_string())],
890            ),
891        ];
892
893        let matcher = RouteMatcher::new(routes, None).unwrap();
894
895        // /v2 exact → v2
896        let req = RequestInfo::new("GET", "/v2", "example.com");
897        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
898
899        // /v2/ with trailing slash → v2
900        let req = RequestInfo::new("GET", "/v2/", "example.com");
901        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
902
903        // /v2/anything → v2
904        let req = RequestInfo::new("GET", "/v2/anything", "example.com");
905        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
906
907        // /v2example must NOT match /v2 prefix — falls to catch-all
908        let req = RequestInfo::new("GET", "/v2example", "example.com");
909        assert_eq!(
910            matcher.match_request(&req).unwrap().route_id.as_str(),
911            "catch-all"
912        );
913
914        // /v2?query → v2
915        let req = RequestInfo::new("GET", "/v2?foo=bar", "example.com");
916        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
917    }
918
919    #[test]
920    fn test_header_matching_with_specificity() {
921        let routes = vec![
922            create_test_route(
923                "catch-all",
924                vec![MatchCondition::PathPrefix("/".to_string())],
925            ),
926            create_test_route(
927                "header-v2",
928                vec![
929                    MatchCondition::Header {
930                        name: "version".to_string(),
931                        value: Some("two".to_string()),
932                    },
933                    MatchCondition::PathPrefix("/".to_string()),
934                ],
935            ),
936        ];
937
938        let matcher = RouteMatcher::new(routes, None).unwrap();
939
940        // Without headers → catch-all
941        let req = RequestInfo::new("GET", "/", "example.com");
942        assert_eq!(
943            matcher.match_request(&req).unwrap().route_id.as_str(),
944            "catch-all"
945        );
946
947        // With version:two header → header-v2 (more specific)
948        let mut headers = HashMap::new();
949        headers.insert("version".to_string(), "two".to_string());
950        let req = RequestInfo::new("GET", "/", "example.com").with_headers(headers);
951        assert_eq!(
952            matcher.match_request(&req).unwrap().route_id.as_str(),
953            "header-v2"
954        );
955    }
956}