Skip to main content

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::{AtomicU64, 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    /// 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    fn matches(&self, req: &RequestInfo<'_>) -> bool {
338        // All matchers must pass (AND logic)
339        for (index, matcher) in self.matchers.iter().enumerate() {
340            let result = matcher.matches(req);
341            if !result {
342                trace!(
343                    route_id = %self.id,
344                    matcher_index = index,
345                    matcher_type = ?matcher,
346                    path = %req.path,
347                    "Matcher did not match"
348                );
349                return false;
350            }
351            trace!(
352                route_id = %self.id,
353                matcher_index = index,
354                matcher_type = ?matcher,
355                "Matcher passed"
356            );
357        }
358        true
359    }
360
361    /// Calculate route specificity for tie-breaking
362    fn specificity(&self) -> u32 {
363        let mut score = 0;
364        for matcher in &self.matchers {
365            score += match matcher {
366                CompiledMatcher::Path(_) => 1000,     // Exact path is most specific
367                CompiledMatcher::PathRegex(_) => 500, // Regex is moderately specific
368                CompiledMatcher::PathPrefix(_) => 100, // Prefix is least specific
369                CompiledMatcher::Host(_) => 50,
370                CompiledMatcher::Header { value, .. } => {
371                    if value.is_some() {
372                        30
373                    } else {
374                        20
375                    }
376                }
377                CompiledMatcher::Method(_) => 10,
378                CompiledMatcher::QueryParam { value, .. } => {
379                    if value.is_some() {
380                        25
381                    } else {
382                        15
383                    }
384                }
385            };
386        }
387        score
388    }
389}
390
391impl CompiledMatcher {
392    /// Check if this matcher matches the request
393    fn matches(&self, req: &RequestInfo<'_>) -> bool {
394        match self {
395            Self::Path(path) => req.path == *path,
396            Self::PathPrefix(prefix) => req.path.starts_with(prefix),
397            Self::PathRegex(regex) => regex.is_match(req.path),
398            Self::Host(host_matcher) => host_matcher.matches(req.host),
399            Self::Header { name, value } => {
400                if let Some(header_value) = req.headers().get(name) {
401                    value.as_ref().is_none_or(|v| header_value == v)
402                } else {
403                    false
404                }
405            }
406            Self::Method(methods) => methods.iter().any(|m| m == req.method),
407            Self::QueryParam { name, value } => {
408                if let Some(param_value) = req.query_params().get(name) {
409                    value.as_ref().is_none_or(|v| param_value == v)
410                } else {
411                    false
412                }
413            }
414        }
415    }
416}
417
418impl HostMatcher {
419    /// Parse a host pattern into a matcher
420    fn parse(pattern: &str) -> Self {
421        if pattern.starts_with("*.") {
422            // Wildcard pattern
423            Self::Wildcard {
424                suffix: pattern[2..].to_string(),
425            }
426        } else if pattern.contains('*') || pattern.contains('[') {
427            // Treat as regex if it contains other special characters
428            if let Ok(regex) = Regex::new(pattern) {
429                Self::Regex(regex)
430            } else {
431                // Fall back to exact match if regex compilation fails
432                warn!("Invalid host regex pattern: {}, using exact match", pattern);
433                Self::Exact(pattern.to_string())
434            }
435        } else {
436            // Exact match
437            Self::Exact(pattern.to_string())
438        }
439    }
440
441    /// Check if this matcher matches the host
442    fn matches(&self, host: &str) -> bool {
443        match self {
444            Self::Exact(pattern) => host == pattern,
445            Self::Wildcard { suffix } => {
446                host.ends_with(suffix)
447                    && host.len() > suffix.len()
448                    && host[..host.len() - suffix.len()].ends_with('.')
449            }
450            Self::Regex(regex) => regex.is_match(host),
451        }
452    }
453}
454
455impl RouteCache {
456    /// Create a new route cache
457    fn new(max_size: usize) -> Self {
458        Self {
459            entries: DashMap::with_capacity(max_size),
460            max_size,
461            entry_count: AtomicUsize::new(0),
462            hits: AtomicU64::new(0),
463            misses: AtomicU64::new(0),
464        }
465    }
466
467    /// Get a route from cache (lock-free)
468    fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
469        let result = self.entries.get(key);
470        if result.is_some() {
471            self.hits.fetch_add(1, Ordering::Relaxed);
472        }
473        result
474    }
475
476    /// Record a cache miss
477    fn record_miss(&self) {
478        self.misses.fetch_add(1, Ordering::Relaxed);
479    }
480
481    /// Get the hit rate (0.0 to 1.0)
482    fn hit_rate(&self) -> f64 {
483        let hits = self.hits.load(Ordering::Relaxed);
484        let misses = self.misses.load(Ordering::Relaxed);
485        let total = hits + misses;
486        if total == 0 {
487            0.0
488        } else {
489            hits as f64 / total as f64
490        }
491    }
492
493    /// Insert a route into cache (lock-free)
494    fn insert(&self, key: String, route_id: RouteId) {
495        // Check if we need to evict (approximate check to avoid overhead)
496        let current_count = self.entry_count.load(Ordering::Relaxed);
497        if current_count >= self.max_size {
498            // Evict ~10% of entries randomly for simplicity
499            // This is faster than true LRU and good enough for a cache
500            self.evict_random();
501        }
502
503        if self.entries.insert(key, route_id).is_none() {
504            // Only increment if this was a new entry
505            self.entry_count.fetch_add(1, Ordering::Relaxed);
506        }
507    }
508
509    /// Evict random entries when cache is full
510    fn evict_random(&self) {
511        let to_evict = self.max_size / 10; // Evict ~10%
512        let mut evicted = 0;
513
514        // Iterate and remove some entries
515        self.entries.retain(|_, _| {
516            if evicted < to_evict {
517                evicted += 1;
518                false // Remove this entry
519            } else {
520                true // Keep this entry
521            }
522        });
523
524        // Update count (approximate)
525        self.entry_count
526            .store(self.entries.len(), Ordering::Relaxed);
527    }
528
529    /// Get current cache size
530    fn len(&self) -> usize {
531        self.entries.len()
532    }
533
534    /// Clear all cache entries
535    fn clear(&self) {
536        self.entries.clear();
537        self.entry_count.store(0, Ordering::Relaxed);
538    }
539}
540
541/// Request information for route matching (zero-copy where possible)
542#[derive(Debug)]
543pub struct RequestInfo<'a> {
544    /// HTTP method (borrowed from request header)
545    pub method: &'a str,
546    /// Request path (borrowed from request header)
547    pub path: &'a str,
548    /// Host header value (borrowed from request header)
549    pub host: &'a str,
550    /// Headers for matching (lazy-initialized, only if needed)
551    headers: Option<HashMap<String, String>>,
552    /// Query parameters (lazy-initialized, only if needed)
553    query_params: Option<HashMap<String, String>>,
554}
555
556impl<'a> RequestInfo<'a> {
557    /// Create a new RequestInfo with borrowed references (zero-copy for common case)
558    #[inline]
559    pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
560        Self {
561            method,
562            path,
563            host,
564            headers: None,
565            query_params: None,
566        }
567    }
568
569    /// Set headers for header-based matching (only call if RouteMatcher.needs_headers())
570    #[inline]
571    pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
572        self.headers = Some(headers);
573        self
574    }
575
576    /// Set query params for query-based matching (only call if RouteMatcher.needs_query_params())
577    #[inline]
578    pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
579        self.query_params = Some(params);
580        self
581    }
582
583    /// Get headers (returns empty map if not set)
584    #[inline]
585    pub fn headers(&self) -> &HashMap<String, String> {
586        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
587        self.headers
588            .as_ref()
589            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
590    }
591
592    /// Get query params (returns empty map if not set)
593    #[inline]
594    pub fn query_params(&self) -> &HashMap<String, String> {
595        static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
596        self.query_params
597            .as_ref()
598            .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
599    }
600
601    /// Generate a cache key for this request using a thread-local buffer
602    /// to avoid per-request heap allocation.
603    fn with_cache_key<R>(&self, f: impl FnOnce(&str) -> R) -> R {
604        use std::cell::RefCell;
605        use std::fmt::Write;
606
607        thread_local! {
608            static BUF: RefCell<String> = RefCell::new(String::with_capacity(128));
609        }
610
611        BUF.with(|buf| {
612            let mut buf = buf.borrow_mut();
613            buf.clear();
614            let _ = write!(buf, "{}:{}:{}", self.method, self.host, self.path);
615            f(&buf)
616        })
617    }
618
619    /// Parse query parameters from path (only call when needed)
620    pub fn parse_query_params(path: &str) -> HashMap<String, String> {
621        let mut params = HashMap::new();
622        if let Some(query_start) = path.find('?') {
623            let query = &path[query_start + 1..];
624            for pair in query.split('&') {
625                if let Some(eq_pos) = pair.find('=') {
626                    let key = &pair[..eq_pos];
627                    let value = &pair[eq_pos + 1..];
628                    params.insert(
629                        urlencoding::decode(key)
630                            .unwrap_or_else(|_| key.into())
631                            .into_owned(),
632                        urlencoding::decode(value)
633                            .unwrap_or_else(|_| value.into())
634                            .into_owned(),
635                    );
636                } else {
637                    params.insert(
638                        urlencoding::decode(pair)
639                            .unwrap_or_else(|_| pair.into())
640                            .into_owned(),
641                        String::new(),
642                    );
643                }
644            }
645        }
646        params
647    }
648
649    /// Build headers map from request header iterator (only call when needed)
650    pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
651    where
652        I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
653    {
654        let mut headers = HashMap::new();
655        for (name, value) in iter {
656            if let Ok(value_str) = value.to_str() {
657                headers.insert(name.as_str().to_lowercase(), value_str.to_string());
658            }
659        }
660        headers
661    }
662}
663
664/// Route match result
665#[derive(Debug, Clone)]
666pub struct RouteMatch {
667    pub route_id: RouteId,
668    pub config: Arc<RouteConfig>,
669}
670
671impl RouteMatch {
672    /// Access route policies (convenience accessor to avoid repeated .config.policies)
673    #[inline]
674    pub fn policies(&self) -> &RoutePolicies {
675        &self.config.policies
676    }
677}
678
679/// Cache statistics
680#[derive(Debug, Clone)]
681pub struct CacheStats {
682    pub entries: usize,
683    pub max_size: usize,
684    pub hit_rate: f64,
685}
686
687/// Route matching errors
688#[derive(Debug, thiserror::Error)]
689pub enum RouteError {
690    #[error("Invalid regex pattern '{pattern}': {error}")]
691    InvalidRegex { pattern: String, error: String },
692
693    #[error("Invalid route configuration: {0}")]
694    InvalidConfig(String),
695
696    #[error("Duplicate route ID: {0}")]
697    DuplicateRouteId(String),
698}
699
700impl std::fmt::Debug for CompiledMatcher {
701    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
702        match self {
703            Self::Path(p) => write!(f, "Path({})", p),
704            Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
705            Self::PathRegex(_) => write!(f, "PathRegex(...)"),
706            Self::Host(_) => write!(f, "Host(...)"),
707            Self::Header { name, .. } => write!(f, "Header({})", name),
708            Self::Method(m) => write!(f, "Method({:?})", m),
709            Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
710        }
711    }
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717    use sentinel_common::types::Priority;
718    use sentinel_config::{MatchCondition, RouteConfig};
719
720    fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
721        RouteConfig {
722            id: id.to_string(),
723            priority: Priority::Normal,
724            matches,
725            upstream: Some("test_upstream".to_string()),
726            service_type: sentinel_config::ServiceType::Web,
727            policies: Default::default(),
728            filters: vec![],
729            builtin_handler: None,
730            waf_enabled: false,
731            circuit_breaker: None,
732            retry_policy: None,
733            static_files: None,
734            api_schema: None,
735            error_pages: None,
736            websocket: false,
737            websocket_inspection: false,
738            inference: None,
739            shadow: None,
740            fallback: None,
741        }
742    }
743
744    #[test]
745    fn test_path_matching() {
746        let routes = vec![
747            create_test_route(
748                "exact",
749                vec![MatchCondition::Path("/api/v1/users".to_string())],
750            ),
751            create_test_route(
752                "prefix",
753                vec![MatchCondition::PathPrefix("/api/".to_string())],
754            ),
755        ];
756
757        let matcher = RouteMatcher::new(routes, None).unwrap();
758
759        let req = RequestInfo {
760            method: "GET",
761            path: "/api/v1/users",
762            host: "example.com",
763            headers: None,
764            query_params: None,
765        };
766
767        let result = matcher.match_request(&req).unwrap();
768        assert_eq!(result.route_id.as_str(), "exact");
769    }
770
771    #[test]
772    fn test_host_wildcard_matching() {
773        let routes = vec![create_test_route(
774            "wildcard",
775            vec![MatchCondition::Host("*.example.com".to_string())],
776        )];
777
778        let matcher = RouteMatcher::new(routes, None).unwrap();
779
780        let req = RequestInfo {
781            method: "GET",
782            path: "/",
783            host: "api.example.com",
784            headers: None,
785            query_params: None,
786        };
787
788        let result = matcher.match_request(&req).unwrap();
789        assert_eq!(result.route_id.as_str(), "wildcard");
790    }
791
792    #[test]
793    fn test_priority_ordering() {
794        let mut route1 =
795            create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
796        route1.priority = Priority::Low;
797
798        let mut route2 =
799            create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
800        route2.priority = Priority::High;
801
802        let routes = vec![route1, route2];
803        let matcher = RouteMatcher::new(routes, None).unwrap();
804
805        let req = RequestInfo {
806            method: "GET",
807            path: "/test",
808            host: "example.com",
809            headers: None,
810            query_params: None,
811        };
812
813        let result = matcher.match_request(&req).unwrap();
814        assert_eq!(result.route_id.as_str(), "high");
815    }
816
817    #[test]
818    fn test_query_param_parsing() {
819        let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
820        assert_eq!(params.get("foo"), Some(&"bar".to_string()));
821        assert_eq!(params.get("baz"), Some(&"qux".to_string()));
822        assert_eq!(params.get("empty"), Some(&"".to_string()));
823    }
824}