1use 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
15static 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
28pub struct RouteMatcher {
30 routes: Vec<CompiledRoute>,
32 default_route: Option<RouteId>,
34 cache: Arc<RouteCache>,
36 needs_headers: bool,
38 needs_query_params: bool,
40}
41
42struct CompiledRoute {
44 config: Arc<RouteConfig>,
46 id: RouteId,
48 priority: Priority,
50 matchers: Vec<CompiledMatcher>,
52}
53
54enum CompiledMatcher {
56 Path(String),
58 PathPrefix(String),
60 PathRegex(Regex),
62 Host(HostMatcher),
64 Header { name: String, value: Option<String> },
66 Method(Vec<String>),
68 QueryParam { name: String, value: Option<String> },
70}
71
72enum HostMatcher {
74 Exact(String),
76 Wildcard { suffix: String },
78 Regex(Regex),
80}
81
82struct RouteCache {
84 entries: DashMap<String, RouteId>,
86 max_size: usize,
88 entry_count: AtomicUsize,
90 hits: AtomicU64,
92 misses: AtomicU64,
94}
95
96impl RouteMatcher {
97 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 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 compiled_routes.sort_by(|a, b| {
134 b.priority
135 .cmp(&a.priority)
136 .then_with(|| b.specificity().cmp(&a.specificity()))
137 });
138
139 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 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 #[inline]
178 pub fn needs_headers(&self) -> bool {
179 self.needs_headers
180 }
181
182 #[inline]
184 pub fn needs_query_params(&self) -> bool {
185 self.needs_query_params
186 }
187
188 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 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 self.cache.record_miss();
227
228 trace!(
229 route_count = self.routes.len(),
230 "Cache miss, evaluating routes"
231 );
232
233 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 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 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 fn find_route_by_id(&self, id: &RouteId) -> Option<&CompiledRoute> {
299 self.routes.iter().find(|r| r.id == *id)
300 }
301
302 pub fn clear_cache(&self) {
304 self.cache.clear();
305 }
306
307 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 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 fn matches(&self, req: &RequestInfo<'_>) -> bool {
363 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 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 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 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 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 fn parse(pattern: &str) -> Self {
486 if pattern.starts_with("*.") {
487 Self::Wildcard {
489 suffix: pattern[2..].to_string(),
490 }
491 } else if pattern.contains('*') || pattern.contains('[') {
492 if let Ok(regex) = Regex::new(pattern) {
494 Self::Regex(regex)
495 } else {
496 warn!("Invalid host regex pattern: {}, using exact match", pattern);
498 Self::Exact(pattern.to_string())
499 }
500 } else {
501 Self::Exact(pattern.to_string())
503 }
504 }
505
506 fn matches(&self, host: &str) -> bool {
511 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 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 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 fn record_miss(&self) {
548 self.misses.fetch_add(1, Ordering::Relaxed);
549 }
550
551 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 fn insert(&self, key: String, route_id: RouteId) {
565 let current_count = self.entry_count.load(Ordering::Relaxed);
567 if current_count >= self.max_size {
568 self.evict_random();
571 }
572
573 if self.entries.insert(key, route_id).is_none() {
574 self.entry_count.fetch_add(1, Ordering::Relaxed);
576 }
577 }
578
579 fn evict_random(&self) {
581 let to_evict = (self.max_size / 10).max(1); let mut evicted = 0;
583
584 self.entries.retain(|_, _| {
586 if evicted < to_evict {
587 evicted += 1;
588 false } else {
590 true }
592 });
593
594 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 fn len(&self) -> usize {
611 self.entries.len()
612 }
613
614 fn clear(&self) {
616 self.entries.clear();
617 self.entry_count.store(0, Ordering::Relaxed);
618 }
619}
620
621#[derive(Debug)]
623pub struct RequestInfo<'a> {
624 pub method: &'a str,
626 pub path: &'a str,
628 pub host: &'a str,
630 headers: Option<HashMap<String, String>>,
632 query_params: Option<HashMap<String, String>>,
634}
635
636impl<'a> RequestInfo<'a> {
637 #[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 #[inline]
651 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
652 self.headers = Some(headers);
653 self
654 }
655
656 #[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 #[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 #[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 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 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 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 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#[derive(Debug, Clone)]
755pub struct RouteMatch {
756 pub route_id: RouteId,
757 pub config: Arc<RouteConfig>,
758}
759
760impl RouteMatch {
761 #[inline]
763 pub fn policies(&self) -> &RoutePolicies {
764 &self.config.policies
765 }
766}
767
768#[derive(Debug, Clone)]
770pub struct CacheStats {
771 pub entries: usize,
772 pub max_size: usize,
773 pub hit_rate: f64,
774}
775
776#[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 let req = RequestInfo::new("GET", "/v2", "example.com");
940 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
941
942 let req = RequestInfo::new("GET", "/v2/", "example.com");
944 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
945
946 let req = RequestInfo::new("GET", "/v2/anything", "example.com");
948 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
949
950 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 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 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 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}