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 prometheus::{register_int_counter, IntCounter};
9use regex::Regex;
10use std::collections::HashMap;
11use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
12use std::sync::{Arc, LazyLock};
13use tracing::{debug, info, trace, warn};
14
15/// Entries evicted from the route-match cache to enforce `route-cache-size`.
16static ROUTE_CACHE_EVICTIONS: LazyLock<Option<IntCounter>> = LazyLock::new(|| {
17    register_int_counter!(
18        "zentinel_route_cache_evictions_total",
19        "Route-match cache entries evicted to enforce route-cache-size"
20    )
21    .ok()
22});
23
24use zentinel_common::types::Priority;
25use zentinel_common::RouteId;
26use zentinel_config::{MatchCondition, RouteConfig, RoutePolicies};
27
28/// Route matcher for efficient route selection
29pub struct RouteMatcher {
30    /// Routes sorted by priority (highest first)
31    routes: Vec<CompiledRoute>,
32    /// Default route ID if no match found
33    default_route: Option<RouteId>,
34    /// Cache for frequently matched routes (lock-free concurrent access)
35    cache: Arc<RouteCache>,
36    /// Whether any route requires header matching (optimization flag)
37    needs_headers: bool,
38    /// Whether any route requires query param matching (optimization flag)
39    needs_query_params: bool,
40}
41
42/// Compiled route with pre-processed match conditions
43struct CompiledRoute {
44    /// Route configuration
45    config: Arc<RouteConfig>,
46    /// Route ID for quick lookup
47    id: RouteId,
48    /// Priority for ordering
49    priority: Priority,
50    /// Compiled match conditions
51    matchers: Vec<CompiledMatcher>,
52}
53
54/// Compiled match condition for efficient evaluation
55enum CompiledMatcher {
56    /// Exact path match
57    Path(String),
58    /// Path prefix match
59    PathPrefix(String),
60    /// Regex path match
61    PathRegex(Regex),
62    /// Host match (exact or wildcard)
63    Host(HostMatcher),
64    /// Header presence or value match
65    Header { name: String, value: Option<String> },
66    /// HTTP method match
67    Method(Vec<String>),
68    /// Query parameter match
69    QueryParam { name: String, value: Option<String> },
70}
71
72/// Host matching logic
73enum HostMatcher {
74    /// Exact host match
75    Exact(String),
76    /// Wildcard match (*.example.com)
77    Wildcard { suffix: String },
78    /// Regex match
79    Regex(Regex),
80}
81
82/// Route cache for performance (lock-free concurrent access)
83struct RouteCache {
84    /// Cache entries (cache key -> route ID) - lock-free concurrent map
85    entries: DashMap<String, RouteId>,
86    /// Maximum cache size
87    max_size: usize,
88    /// Current entry count (approximate, for eviction decisions)
89    entry_count: AtomicUsize,
90    /// Cache hits counter
91    hits: AtomicU64,
92    /// Cache misses counter
93    misses: AtomicU64,
94}
95
96impl RouteMatcher {
97    /// Create a new route matcher from configuration with the default
98    /// route-cache size (1000 entries).
99    pub fn new(
100        routes: Vec<RouteConfig>,
101        default_route: Option<String>,
102    ) -> Result<Self, RouteError> {
103        Self::with_cache_size(routes, default_route, 1000)
104    }
105
106    /// Create a new route matcher with an explicit route-cache size
107    /// (`system { route-cache-size N }`).
108    pub fn with_cache_size(
109        routes: Vec<RouteConfig>,
110        default_route: Option<String>,
111        cache_size: usize,
112    ) -> Result<Self, RouteError> {
113        info!(
114            route_count = routes.len(),
115            default_route = ?default_route,
116            "Initializing route matcher"
117        );
118
119        let mut compiled_routes = Vec::new();
120
121        for route in routes {
122            trace!(
123                route_id = %route.id,
124                priority = ?route.priority,
125                match_count = route.matches.len(),
126                "Compiling route"
127            );
128            let compiled = CompiledRoute::compile(route)?;
129            compiled_routes.push(compiled);
130        }
131
132        // Sort by priority (highest first), then by specificity
133        compiled_routes.sort_by(|a, b| {
134            b.priority
135                .cmp(&a.priority)
136                .then_with(|| b.specificity().cmp(&a.specificity()))
137        });
138
139        // Log final route order
140        for (index, route) in compiled_routes.iter().enumerate() {
141            debug!(
142                route_id = %route.id,
143                order = index,
144                priority = ?route.priority,
145                specificity = route.specificity(),
146                "Route compiled and ordered"
147            );
148        }
149
150        // Determine if any routes need headers or query params (optimization)
151        let needs_headers = compiled_routes.iter().any(|r| {
152            r.matchers
153                .iter()
154                .any(|m| matches!(m, CompiledMatcher::Header { .. }))
155        });
156        let needs_query_params = compiled_routes.iter().any(|r| {
157            r.matchers
158                .iter()
159                .any(|m| matches!(m, CompiledMatcher::QueryParam { .. }))
160        });
161
162        info!(
163            compiled_routes = compiled_routes.len(),
164            needs_headers, needs_query_params, "Route matcher initialized"
165        );
166
167        Ok(Self {
168            routes: compiled_routes,
169            default_route: default_route.map(RouteId::new),
170            cache: Arc::new(RouteCache::new(cache_size)),
171            needs_headers,
172            needs_query_params,
173        })
174    }
175
176    /// Check if any route requires header matching
177    #[inline]
178    pub fn needs_headers(&self) -> bool {
179        self.needs_headers
180    }
181
182    /// Check if any route requires query param matching
183    #[inline]
184    pub fn needs_query_params(&self) -> bool {
185        self.needs_query_params
186    }
187
188    /// Match a request to a route
189    pub fn match_request(&self, req: &RequestInfo<'_>) -> Option<RouteMatch> {
190        trace!(
191            method = %req.method,
192            path = %req.path,
193            host = %req.host,
194            "Starting route matching"
195        );
196
197        // Check cache first (lock-free read, zero-allocation on hit)
198        let cached = req.with_cache_key(|key| {
199            self.cache.get(key).map(|r| {
200                let route_id = r.clone();
201                drop(r);
202                route_id
203            })
204        });
205        if let Some(route_id) = cached {
206            trace!(
207                route_id = %route_id,
208                "Route cache hit"
209            );
210            if let Some(route) = self.find_route_by_id(&route_id) {
211                debug!(
212                    route_id = %route_id,
213                    method = %req.method,
214                    path = %req.path,
215                    source = "cache",
216                    "Route matched from cache"
217                );
218                return Some(RouteMatch {
219                    route_id,
220                    config: route.config.clone(),
221                });
222            }
223        }
224
225        // Record cache miss
226        self.cache.record_miss();
227
228        trace!(
229            route_count = self.routes.len(),
230            "Cache miss, evaluating routes"
231        );
232
233        // Evaluate routes in priority order
234        for (index, route) in self.routes.iter().enumerate() {
235            trace!(
236                route_id = %route.id,
237                route_index = index,
238                priority = ?route.priority,
239                matcher_count = route.matchers.len(),
240                "Evaluating route"
241            );
242
243            if route.matches(req) {
244                debug!(
245                    route_id = %route.id,
246                    method = %req.method,
247                    path = %req.path,
248                    host = %req.host,
249                    priority = ?route.priority,
250                    route_index = index,
251                    "Route matched"
252                );
253
254                // Update cache — allocate key only on miss (rare after warmup)
255                req.with_cache_key(|key| {
256                    self.cache.insert(key.to_string(), route.id.clone());
257                });
258
259                trace!(
260                    route_id = %route.id,
261                    "Route added to cache"
262                );
263
264                return Some(RouteMatch {
265                    route_id: route.id.clone(),
266                    config: route.config.clone(),
267                });
268            }
269        }
270
271        // Use default route if configured
272        if let Some(ref default_id) = self.default_route {
273            debug!(
274                route_id = %default_id,
275                method = %req.method,
276                path = %req.path,
277                "Using default route (no explicit match)"
278            );
279            if let Some(route) = self.find_route_by_id(default_id) {
280                return Some(RouteMatch {
281                    route_id: default_id.clone(),
282                    config: route.config.clone(),
283                });
284            }
285        }
286
287        debug!(
288            method = %req.method,
289            path = %req.path,
290            host = %req.host,
291            routes_evaluated = self.routes.len(),
292            "No route matched"
293        );
294        None
295    }
296
297    /// Find a route by ID
298    fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
299        self.routes.iter().find(|r| r.id == *id)
300    }
301
302    /// Clear the route cache
303    pub fn clear_cache(&self) {
304        self.cache.clear();
305    }
306
307    /// Get cache statistics
308    pub fn cache_stats(&self) -> CacheStats {
309        CacheStats {
310            entries: self.cache.len(),
311            max_size: self.cache.max_size,
312            hit_rate: self.cache.hit_rate(),
313        }
314    }
315}
316
317impl CompiledRoute {
318    /// Compile a route configuration into an optimized matcher
319    fn compile(config: RouteConfig) -> Result<Self, RouteError> {
320        let mut matchers = Vec::new();
321
322        for condition in &config.matches {
323            let compiled = match condition {
324                MatchCondition::Path(path) => CompiledMatcher::Path(path.clone()),
325                MatchCondition::PathPrefix(prefix) => CompiledMatcher::PathPrefix(prefix.clone()),
326                MatchCondition::PathRegex(pattern) => {
327                    let regex = Regex::new(pattern).map_err(|e| RouteError::InvalidRegex {
328                        pattern: pattern.clone(),
329                        error: e.to_string(),
330                    })?;
331                    CompiledMatcher::PathRegex(regex)
332                }
333                MatchCondition::Host(host) => CompiledMatcher::Host(HostMatcher::parse(host)),
334                MatchCondition::Header { name, value } => CompiledMatcher::Header {
335                    name: name.to_lowercase(),
336                    value: value.clone(),
337                },
338                MatchCondition::Method(methods) => {
339                    CompiledMatcher::Method(methods.iter().map(|m| m.to_uppercase()).collect())
340                }
341                MatchCondition::QueryParam { name, value } => CompiledMatcher::QueryParam {
342                    name: name.clone(),
343                    value: value.clone(),
344                },
345            };
346            matchers.push(compiled);
347        }
348
349        Ok(Self {
350            id: RouteId::new(&config.id),
351            priority: config.priority,
352            config: Arc::new(config),
353            matchers,
354        })
355    }
356
357    /// Check if this route matches the request.
358    ///
359    /// Host matchers use OR logic (match any host), all other matchers use AND.
360    /// This matches Gateway API semantics where multiple hostnames on an
361    /// HTTPRoute are alternatives, not conjunctions.
362    fn matches(&self, req: &RequestInfo<'_>) -> bool {
363        // Partition matchers into host matchers and non-host matchers
364        let mut has_host_matchers = false;
365        let mut any_host_matched = false;
366
367        for matcher in &self.matchers {
368            match matcher {
369                CompiledMatcher::Host(_) => {
370                    has_host_matchers = true;
371                    if matcher.matches(req) {
372                        any_host_matched = true;
373                    }
374                }
375                _ => {
376                    if !matcher.matches(req) {
377                        trace!(
378                            route_id = %self.id,
379                            matcher_type = ?matcher,
380                            path = %req.path,
381                            "Matcher did not match"
382                        );
383                        return false;
384                    }
385                }
386            }
387        }
388
389        // If there are host matchers, at least one must match (OR logic)
390        if has_host_matchers && !any_host_matched {
391            trace!(
392                route_id = %self.id,
393                host = %req.host,
394                "No host matcher matched"
395            );
396            return false;
397        }
398
399        true
400    }
401
402    /// Calculate route specificity for tie-breaking.
403    ///
404    /// Per Gateway API precedence rules:
405    /// 1. Path specificity is primary (exact > longest prefix > regex)
406    /// 2. Host specificity is secondary (exact > wildcard)
407    /// 3. Header/method/query conditions add specificity
408    ///
409    /// Host matchers use OR logic, so multiple hosts don't increase
410    /// specificity — we use the max host score, not the sum.
411    fn specificity(&self) -> u32 {
412        let mut path_score = 0u32;
413        let mut host_score = 0u32;
414        let mut condition_score = 0u32;
415
416        for matcher in &self.matchers {
417            match matcher {
418                CompiledMatcher::Path(_) => path_score = path_score.max(10000),
419                CompiledMatcher::PathRegex(_) => path_score = path_score.max(5000),
420                CompiledMatcher::PathPrefix(p) => {
421                    path_score = path_score.max(1000 + p.len() as u32)
422                }
423                CompiledMatcher::Host(host) => {
424                    let s = match host {
425                        HostMatcher::Exact(_) => 70,
426                        HostMatcher::Regex(_) => 60,
427                        HostMatcher::Wildcard { .. } => 50,
428                    };
429                    host_score = host_score.max(s);
430                }
431                CompiledMatcher::Header { value, .. } => {
432                    condition_score += if value.is_some() { 30 } else { 20 };
433                }
434                CompiledMatcher::Method(_) => condition_score += 10,
435                CompiledMatcher::QueryParam { value, .. } => {
436                    condition_score += if value.is_some() { 25 } else { 15 };
437                }
438            }
439        }
440
441        path_score + host_score + condition_score
442    }
443}
444
445impl CompiledMatcher {
446    /// Check if this matcher matches the request
447    fn matches(&self, req: &RequestInfo<'_>) -> bool {
448        match self {
449            Self::Path(path) => req.path == *path,
450            Self::PathPrefix(prefix) => {
451                if !req.path.starts_with(prefix) {
452                    return false;
453                }
454                // Enforce segment boundary per Gateway API spec:
455                // PathPrefix "/v2" must NOT match "/v2example", only "/v2", "/v2/", "/v2/anything"
456                prefix == "/"
457                    || req.path.len() == prefix.len()
458                    || prefix.ends_with('/')
459                    || req.path.as_bytes()[prefix.len()] == b'/'
460                    || req.path.as_bytes()[prefix.len()] == b'?'
461            }
462            Self::PathRegex(regex) => regex.is_match(req.path),
463            Self::Host(host_matcher) => host_matcher.matches(req.host),
464            Self::Header { name, value } => {
465                if let Some(header_value) = req.headers().get(name) {
466                    value.as_ref().is_none_or(|v| header_value == v)
467                } else {
468                    false
469                }
470            }
471            Self::Method(methods) => methods.iter().any(|m| m == req.method),
472            Self::QueryParam { name, value } => {
473                if let Some(param_value) = req.query_params().get(name) {
474                    value.as_ref().is_none_or(|v| param_value == v)
475                } else {
476                    false
477                }
478            }
479        }
480    }
481}
482
483impl HostMatcher {
484    /// Parse a host pattern into a matcher
485    fn parse(pattern: &str) -> Self {
486        if pattern.starts_with("*.") {
487            // Wildcard pattern
488            Self::Wildcard {
489                suffix: pattern[2..].to_string(),
490            }
491        } else if pattern.contains('*') || pattern.contains('[') {
492            // Treat as regex if it contains other special characters
493            if let Ok(regex) = Regex::new(pattern) {
494                Self::Regex(regex)
495            } else {
496                // Fall back to exact match if regex compilation fails
497                warn!("Invalid host regex pattern: {}, using exact match", pattern);
498                Self::Exact(pattern.to_string())
499            }
500        } else {
501            // Exact match
502            Self::Exact(pattern.to_string())
503        }
504    }
505
506    /// Check if this matcher matches the host.
507    ///
508    /// Strips any port suffix from the host before matching, per Gateway API
509    /// spec: `Host: example.com:8080` must match hostname `example.com`.
510    fn matches(&self, host: &str) -> bool {
511        // Strip port from host (e.g. "example.com:8080" → "example.com")
512        let host = host.split(':').next().unwrap_or(host);
513        match self {
514            Self::Exact(pattern) => host == pattern,
515            Self::Wildcard { suffix } => {
516                host.ends_with(suffix)
517                    && host.len() > suffix.len()
518                    && host[..host.len() - suffix.len()].ends_with('.')
519            }
520            Self::Regex(regex) => regex.is_match(host),
521        }
522    }
523}
524
525impl RouteCache {
526    /// Create a new route cache
527    fn new(max_size: usize) -> Self {
528        Self {
529            entries: DashMap::with_capacity(max_size),
530            max_size,
531            entry_count: AtomicUsize::new(0),
532            hits: AtomicU64::new(0),
533            misses: AtomicU64::new(0),
534        }
535    }
536
537    /// Get a route from cache (lock-free)
538    fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
539        let result = self.entries.get(key);
540        if result.is_some() {
541            self.hits.fetch_add(1, Ordering::Relaxed);
542        }
543        result
544    }
545
546    /// Record a cache miss
547    fn record_miss(&self) {
548        self.misses.fetch_add(1, Ordering::Relaxed);
549    }
550
551    /// Get the hit rate (0.0 to 1.0)
552    fn hit_rate(&self) -> f64 {
553        let hits = self.hits.load(Ordering::Relaxed);
554        let misses = self.misses.load(Ordering::Relaxed);
555        let total = hits + misses;
556        if total == 0 {
557            0.0
558        } else {
559            hits as f64 / total as f64
560        }
561    }
562
563    /// Insert a route into cache (lock-free)
564    fn insert(&self, key: String, route_id: RouteId) {
565        // Check if we need to evict (approximate check to avoid overhead)
566        let current_count = self.entry_count.load(Ordering::Relaxed);
567        if current_count >= self.max_size {
568            // Evict ~10% of entries randomly for simplicity
569            // This is faster than true LRU and good enough for a cache
570            self.evict_random();
571        }
572
573        if self.entries.insert(key, route_id).is_none() {
574            // Only increment if this was a new entry
575            self.entry_count.fetch_add(1, Ordering::Relaxed);
576        }
577    }
578
579    /// Evict random entries when cache is full
580    fn evict_random(&self) {
581        let to_evict = (self.max_size / 10).max(1); // Evict ~10%
582        let mut evicted = 0;
583
584        // Iterate and remove some entries
585        self.entries.retain(|_, _| {
586            if evicted < to_evict {
587                evicted += 1;
588                false // Remove this entry
589            } else {
590                true // Keep this entry
591            }
592        });
593
594        // Update count (approximate)
595        self.entry_count
596            .store(self.entries.len(), Ordering::Relaxed);
597
598        debug!(
599            evicted = evicted,
600            remaining = self.entries.len(),
601            max_size = self.max_size,
602            "Route cache at capacity; evicted entries"
603        );
604        if let Some(counter) = ROUTE_CACHE_EVICTIONS.as_ref() {
605            counter.inc_by(evicted as u64);
606        }
607    }
608
609    /// Get current cache size
610    fn len(&self) -> usize {
611        self.entries.len()
612    }
613
614    /// Clear all cache entries
615    fn clear(&self) {
616        self.entries.clear();
617        self.entry_count.store(0, Ordering::Relaxed);
618    }
619}
620
621/// Request information for route matching (zero-copy where possible)
622#[derive(Debug)]
623pub struct RequestInfo<'a> {
624    /// HTTP method (borrowed from request header)
625    pub method: &'a str,
626    /// Request path (borrowed from request header)
627    pub path: &'a str,
628    /// Host header value (borrowed from request header)
629    pub host: &'a str,
630    /// Headers for matching (lazy-initialized, only if needed)
631    headers: Option<HashMap<String, String>>,
632    /// Query parameters (lazy-initialized, only if needed)
633    query_params: Option<HashMap<String, String>>,
634}
635
636impl<'a> RequestInfo<'a> {
637    /// Create a new RequestInfo with borrowed references (zero-copy for common case)
638    #[inline]
639    pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
640        Self {
641            method,
642            path,
643            host,
644            headers: None,
645            query_params: None,
646        }
647    }
648
649    /// Set headers for header-based matching (only call if RouteMatcher.needs_headers())
650    #[inline]
651    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
652        self.headers = Some(headers);
653        self
654    }
655
656    /// Set query params for query-based matching (only call if RouteMatcher.needs_query_params())
657    #[inline]
658    pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
659        self.query_params = Some(params);
660        self
661    }
662
663    /// Get headers (returns empty map if not set)
664    #[inline]
665    pub fn headers(&self) -> &HashMap<String, String> {
666        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
667        self.headers
668            .as_ref()
669            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
670    }
671
672    /// Get query params (returns empty map if not set)
673    #[inline]
674    pub fn query_params(&self) -> &HashMap<String, String> {
675        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
676        self.query_params
677            .as_ref()
678            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
679    }
680
681    /// Generate a cache key for this request using a thread-local buffer
682    /// to avoid per-request heap allocation.
683    fn with_cache_key<R>(&self, f: impl FnOnce(&str) -> R) -> R {
684        use std::cell::RefCell;
685        use std::fmt::Write;
686
687        thread_local! {
688            static BUF: RefCell<String> = RefCell::new(String::with_capacity(128));
689        }
690
691        BUF.with(|buf| {
692            let mut buf = buf.borrow_mut();
693            buf.clear();
694            let _ = write!(buf, "{}:{}:{}", self.method, self.host, self.path);
695            // Include headers in cache key when header-based routing is active,
696            // otherwise different header combinations can poison the cache.
697            if let Some(ref headers) = self.headers {
698                let mut pairs: Vec<_> = headers.iter().collect();
699                pairs.sort_by_key(|(k, _)| k.as_str());
700                for (k, v) in pairs {
701                    let _ = write!(buf, "\n{k}={v}");
702                }
703            }
704            f(&buf)
705        })
706    }
707
708    /// Parse query parameters from path (only call when needed)
709    pub fn parse_query_params(path: &str) -> HashMap<String, String> {
710        let mut params = HashMap::new();
711        if let Some(query_start) = path.find('?') {
712            let query = &path[query_start + 1..];
713            for pair in query.split('&') {
714                if let Some(eq_pos) = pair.find('=') {
715                    let key = &pair[..eq_pos];
716                    let value = &pair[eq_pos + 1..];
717                    params.insert(
718                        urlencoding::decode(key)
719                            .unwrap_or_else(|_| key.into())
720                            .into_owned(),
721                        urlencoding::decode(value)
722                            .unwrap_or_else(|_| value.into())
723                            .into_owned(),
724                    );
725                } else {
726                    params.insert(
727                        urlencoding::decode(pair)
728                            .unwrap_or_else(|_| pair.into())
729                            .into_owned(),
730                        String::new(),
731                    );
732                }
733            }
734        }
735        params
736    }
737
738    /// Build headers map from request header iterator (only call when needed)
739    pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
740    where
741        I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
742    {
743        let mut headers = HashMap::new();
744        for (name, value) in iter {
745            if let Ok(value_str) = value.to_str() {
746                headers.insert(name.as_str().to_lowercase(), value_str.to_string());
747            }
748        }
749        headers
750    }
751}
752
753/// Route match result
754#[derive(Debug, Clone)]
755pub struct RouteMatch {
756    pub route_id: RouteId,
757    pub config: Arc<RouteConfig>,
758}
759
760impl RouteMatch {
761    /// Access route policies (convenience accessor to avoid repeated .config.policies)
762    #[inline]
763    pub fn policies(&self) -> &RoutePolicies {
764        &self.config.policies
765    }
766}
767
768/// Cache statistics
769#[derive(Debug, Clone)]
770pub struct CacheStats {
771    pub entries: usize,
772    pub max_size: usize,
773    pub hit_rate: f64,
774}
775
776/// Route matching errors
777#[derive(Debug, thiserror::Error)]
778pub enum RouteError {
779    #[error("Invalid regex pattern '{pattern}': {error}")]
780    InvalidRegex { pattern: String, error: String },
781
782    #[error("Invalid route configuration: {0}")]
783    InvalidConfig(String),
784
785    #[error("Duplicate route ID: {0}")]
786    DuplicateRouteId(String),
787}
788
789impl std::fmt::Debug for CompiledMatcher {
790    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
791        match self {
792            Self::Path(p) => write!(f, "Path({})", p),
793            Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
794            Self::PathRegex(_) => write!(f, "PathRegex(...)"),
795            Self::Host(_) => write!(f, "Host(...)"),
796            Self::Header { name, .. } => write!(f, "Header({})", name),
797            Self::Method(m) => write!(f, "Method({:?})", m),
798            Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
799        }
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use zentinel_common::types::Priority;
807    use zentinel_config::{MatchCondition, RouteConfig};
808
809    #[test]
810    fn route_cache_never_exceeds_max_size() {
811        let cache = RouteCache::new(10);
812        for i in 0..100 {
813            cache.insert(format!("key-{i}"), RouteId::new(format!("route-{i}")));
814            assert!(
815                cache.len() <= 10,
816                "route cache grew past max_size: {}",
817                cache.len()
818            );
819        }
820    }
821
822    fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
823        RouteConfig {
824            id: id.to_string(),
825            priority: Priority::NORMAL,
826            matches,
827            upstream: Some("test_upstream".to_string()),
828            service_type: zentinel_config::ServiceType::Web,
829            policies: Default::default(),
830            filters: vec![],
831            builtin_handler: None,
832            waf_enabled: false,
833            retry_policy: None,
834            static_files: None,
835            api_schema: None,
836            error_pages: None,
837            websocket: false,
838            websocket_inspection: false,
839            inference: None,
840            shadow: None,
841            fallback: None,
842        }
843    }
844
845    #[test]
846    fn test_path_matching() {
847        let routes = vec![
848            create_test_route(
849                "exact",
850                vec![MatchCondition::Path("/api/v1/users".to_string())],
851            ),
852            create_test_route(
853                "prefix",
854                vec![MatchCondition::PathPrefix("/api/".to_string())],
855            ),
856        ];
857
858        let matcher = RouteMatcher::new(routes, None).unwrap();
859
860        let req = RequestInfo {
861            method: "GET",
862            path: "/api/v1/users",
863            host: "example.com",
864            headers: None,
865            query_params: None,
866        };
867
868        let result = matcher.match_request(&req).unwrap();
869        assert_eq!(result.route_id.as_str(), "exact");
870    }
871
872    #[test]
873    fn test_host_wildcard_matching() {
874        let routes = vec![create_test_route(
875            "wildcard",
876            vec![MatchCondition::Host("*.example.com".to_string())],
877        )];
878
879        let matcher = RouteMatcher::new(routes, None).unwrap();
880
881        let req = RequestInfo {
882            method: "GET",
883            path: "/",
884            host: "api.example.com",
885            headers: None,
886            query_params: None,
887        };
888
889        let result = matcher.match_request(&req).unwrap();
890        assert_eq!(result.route_id.as_str(), "wildcard");
891    }
892
893    #[test]
894    fn test_priority_ordering() {
895        let mut route1 =
896            create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
897        route1.priority = Priority::LOW;
898
899        let mut route2 =
900            create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
901        route2.priority = Priority::HIGH;
902
903        let routes = vec![route1, route2];
904        let matcher = RouteMatcher::new(routes, None).unwrap();
905
906        let req = RequestInfo {
907            method: "GET",
908            path: "/test",
909            host: "example.com",
910            headers: None,
911            query_params: None,
912        };
913
914        let result = matcher.match_request(&req).unwrap();
915        assert_eq!(result.route_id.as_str(), "high");
916    }
917
918    #[test]
919    fn test_query_param_parsing() {
920        let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
921        assert_eq!(params.get("foo"), Some(&"bar".to_string()));
922        assert_eq!(params.get("baz"), Some(&"qux".to_string()));
923        assert_eq!(params.get("empty"), Some(&"".to_string()));
924    }
925
926    #[test]
927    fn test_path_prefix_segment_boundary() {
928        let routes = vec![
929            create_test_route("v2", vec![MatchCondition::PathPrefix("/v2".to_string())]),
930            create_test_route(
931                "catch-all",
932                vec![MatchCondition::PathPrefix("/".to_string())],
933            ),
934        ];
935
936        let matcher = RouteMatcher::new(routes, None).unwrap();
937
938        // /v2 exact → v2
939        let req = RequestInfo::new("GET", "/v2", "example.com");
940        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
941
942        // /v2/ with trailing slash → v2
943        let req = RequestInfo::new("GET", "/v2/", "example.com");
944        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
945
946        // /v2/anything → v2
947        let req = RequestInfo::new("GET", "/v2/anything", "example.com");
948        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
949
950        // /v2example must NOT match /v2 prefix — falls to catch-all
951        let req = RequestInfo::new("GET", "/v2example", "example.com");
952        assert_eq!(
953            matcher.match_request(&req).unwrap().route_id.as_str(),
954            "catch-all"
955        );
956
957        // /v2?query → v2
958        let req = RequestInfo::new("GET", "/v2?foo=bar", "example.com");
959        assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
960    }
961
962    #[test]
963    fn test_header_matching_with_specificity() {
964        let routes = vec![
965            create_test_route(
966                "catch-all",
967                vec![MatchCondition::PathPrefix("/".to_string())],
968            ),
969            create_test_route(
970                "header-v2",
971                vec![
972                    MatchCondition::Header {
973                        name: "version".to_string(),
974                        value: Some("two".to_string()),
975                    },
976                    MatchCondition::PathPrefix("/".to_string()),
977                ],
978            ),
979        ];
980
981        let matcher = RouteMatcher::new(routes, None).unwrap();
982
983        // Without headers → catch-all
984        let req = RequestInfo::new("GET", "/", "example.com");
985        assert_eq!(
986            matcher.match_request(&req).unwrap().route_id.as_str(),
987            "catch-all"
988        );
989
990        // With version:two header → header-v2 (more specific)
991        let mut headers = HashMap::new();
992        headers.insert("version".to_string(), "two".to_string());
993        let req = RequestInfo::new("GET", "/", "example.com").with_headers(headers);
994        assert_eq!(
995            matcher.match_request(&req).unwrap().route_id.as_str(),
996            "header-v2"
997        );
998    }
999}