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
483fn normalize_host(host: &str) -> String {
496 let without_port = if host.starts_with('[') {
500 match host.find(']') {
501 Some(end) => &host[..=end],
502 None => host,
503 }
504 } else {
505 host.split(':').next().unwrap_or(host)
506 };
507
508 let without_dot = without_port.strip_suffix('.').unwrap_or(without_port);
509 without_dot.to_ascii_lowercase()
510}
511
512impl HostMatcher {
513 fn parse(pattern: &str) -> Self {
519 let pattern = normalize_host(pattern);
520 if let Some(suffix) = pattern.strip_prefix("*.") {
521 Self::Wildcard {
523 suffix: suffix.to_string(),
524 }
525 } else if pattern.contains('*') || pattern.contains('[') {
526 match regex::RegexBuilder::new(&pattern)
530 .case_insensitive(true)
531 .build()
532 {
533 Ok(regex) => Self::Regex(regex),
534 Err(_) => {
535 warn!("Invalid host regex pattern: {}, using exact match", pattern);
537 Self::Exact(pattern)
538 }
539 }
540 } else {
541 Self::Exact(pattern)
543 }
544 }
545
546 fn matches(&self, host: &str) -> bool {
548 let host = normalize_host(host);
549 let host = host.as_str();
550 match self {
551 Self::Exact(pattern) => host == pattern,
552 Self::Wildcard { suffix } => {
553 host.ends_with(suffix)
554 && host.len() > suffix.len()
555 && host[..host.len() - suffix.len()].ends_with('.')
556 }
557 Self::Regex(regex) => regex.is_match(host),
558 }
559 }
560}
561
562impl RouteCache {
563 fn new(max_size: usize) -> Self {
565 Self {
566 entries: DashMap::with_capacity(max_size),
567 max_size,
568 entry_count: AtomicUsize::new(0),
569 hits: AtomicU64::new(0),
570 misses: AtomicU64::new(0),
571 }
572 }
573
574 fn get(&self, key: &str) -> Option<dashmap::mapref::one::Ref<'_, String, RouteId>> {
576 let result = self.entries.get(key);
577 if result.is_some() {
578 self.hits.fetch_add(1, Ordering::Relaxed);
579 }
580 result
581 }
582
583 fn record_miss(&self) {
585 self.misses.fetch_add(1, Ordering::Relaxed);
586 }
587
588 fn hit_rate(&self) -> f64 {
590 let hits = self.hits.load(Ordering::Relaxed);
591 let misses = self.misses.load(Ordering::Relaxed);
592 let total = hits + misses;
593 if total == 0 {
594 0.0
595 } else {
596 hits as f64 / total as f64
597 }
598 }
599
600 fn insert(&self, key: String, route_id: RouteId) {
602 let current_count = self.entry_count.load(Ordering::Relaxed);
604 if current_count >= self.max_size {
605 self.evict_random();
608 }
609
610 if self.entries.insert(key, route_id).is_none() {
611 self.entry_count.fetch_add(1, Ordering::Relaxed);
613 }
614 }
615
616 fn evict_random(&self) {
618 let to_evict = (self.max_size / 10).max(1); let mut evicted = 0;
620
621 self.entries.retain(|_, _| {
623 if evicted < to_evict {
624 evicted += 1;
625 false } else {
627 true }
629 });
630
631 self.entry_count
633 .store(self.entries.len(), Ordering::Relaxed);
634
635 debug!(
636 evicted = evicted,
637 remaining = self.entries.len(),
638 max_size = self.max_size,
639 "Route cache at capacity; evicted entries"
640 );
641 if let Some(counter) = ROUTE_CACHE_EVICTIONS.as_ref() {
642 counter.inc_by(evicted as u64);
643 }
644 }
645
646 fn len(&self) -> usize {
648 self.entries.len()
649 }
650
651 fn clear(&self) {
653 self.entries.clear();
654 self.entry_count.store(0, Ordering::Relaxed);
655 }
656}
657
658#[derive(Debug)]
660pub struct RequestInfo<'a> {
661 pub method: &'a str,
663 pub path: &'a str,
665 pub host: &'a str,
667 headers: Option<HashMap<String, String>>,
669 query_params: Option<HashMap<String, String>>,
671}
672
673impl<'a> RequestInfo<'a> {
674 #[inline]
676 pub fn new(method: &'a str, path: &'a str, host: &'a str) -> Self {
677 Self {
678 method,
679 path,
680 host,
681 headers: None,
682 query_params: None,
683 }
684 }
685
686 #[inline]
688 pub fn with_headers(mut self, headers: HashMap<String, String>) -> Self {
689 self.headers = Some(headers);
690 self
691 }
692
693 #[inline]
695 pub fn with_query_params(mut self, params: HashMap<String, String>) -> Self {
696 self.query_params = Some(params);
697 self
698 }
699
700 #[inline]
702 pub fn headers(&self) -> &HashMap<String, String> {
703 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
704 self.headers
705 .as_ref()
706 .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
707 }
708
709 #[inline]
711 pub fn query_params(&self) -> &HashMap<String, String> {
712 static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
713 self.query_params
714 .as_ref()
715 .unwrap_or_else(|| EMPTY.get_or_init(HashMap::new))
716 }
717
718 fn with_cache_key<R>(&self, f: impl FnOnce(&str) -> R) -> R {
721 use std::cell::RefCell;
722 use std::fmt::Write;
723
724 thread_local! {
725 static BUF: RefCell<String> = RefCell::new(String::with_capacity(128));
726 }
727
728 BUF.with(|buf| {
729 let mut buf = buf.borrow_mut();
730 buf.clear();
731 let _ = write!(buf, "{}:{}:{}", self.method, self.host, self.path);
732 if let Some(ref headers) = self.headers {
735 let mut pairs: Vec<_> = headers.iter().collect();
736 pairs.sort_by_key(|(k, _)| k.as_str());
737 for (k, v) in pairs {
738 let _ = write!(buf, "\n{k}={v}");
739 }
740 }
741 if let Some(ref params) = self.query_params {
751 let mut pairs: Vec<_> = params.iter().collect();
752 pairs.sort_by_key(|(k, _)| k.as_str());
753 for (k, v) in pairs {
754 let _ = write!(buf, "\t{k}={v}");
755 }
756 }
757 f(&buf)
758 })
759 }
760
761 pub fn parse_query_params(path: &str) -> HashMap<String, String> {
763 let mut params = HashMap::new();
764 if let Some(query_start) = path.find('?') {
765 let query = &path[query_start + 1..];
766 for pair in query.split('&') {
767 if let Some(eq_pos) = pair.find('=') {
768 let key = &pair[..eq_pos];
769 let value = &pair[eq_pos + 1..];
770 params.insert(
771 urlencoding::decode(key)
772 .unwrap_or_else(|_| key.into())
773 .into_owned(),
774 urlencoding::decode(value)
775 .unwrap_or_else(|_| value.into())
776 .into_owned(),
777 );
778 } else {
779 params.insert(
780 urlencoding::decode(pair)
781 .unwrap_or_else(|_| pair.into())
782 .into_owned(),
783 String::new(),
784 );
785 }
786 }
787 }
788 params
789 }
790
791 pub fn build_headers<'b, I>(iter: I) -> HashMap<String, String>
793 where
794 I: Iterator<Item = (&'b http::header::HeaderName, &'b http::header::HeaderValue)>,
795 {
796 let mut headers = HashMap::new();
797 for (name, value) in iter {
798 if let Ok(value_str) = value.to_str() {
799 headers.insert(name.as_str().to_lowercase(), value_str.to_string());
800 }
801 }
802 headers
803 }
804}
805
806#[derive(Debug, Clone)]
808pub struct RouteMatch {
809 pub route_id: RouteId,
810 pub config: Arc<RouteConfig>,
811}
812
813impl RouteMatch {
814 #[inline]
816 pub fn policies(&self) -> &RoutePolicies {
817 &self.config.policies
818 }
819}
820
821#[derive(Debug, Clone)]
823pub struct CacheStats {
824 pub entries: usize,
825 pub max_size: usize,
826 pub hit_rate: f64,
827}
828
829#[derive(Debug, thiserror::Error)]
831pub enum RouteError {
832 #[error("Invalid regex pattern '{pattern}': {error}")]
833 InvalidRegex { pattern: String, error: String },
834
835 #[error("Invalid route configuration: {0}")]
836 InvalidConfig(String),
837
838 #[error("Duplicate route ID: {0}")]
839 DuplicateRouteId(String),
840}
841
842impl std::fmt::Debug for CompiledMatcher {
843 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
844 match self {
845 Self::Path(p) => write!(f, "Path({})", p),
846 Self::PathPrefix(p) => write!(f, "PathPrefix({})", p),
847 Self::PathRegex(_) => write!(f, "PathRegex(...)"),
848 Self::Host(_) => write!(f, "Host(...)"),
849 Self::Header { name, .. } => write!(f, "Header({})", name),
850 Self::Method(m) => write!(f, "Method({:?})", m),
851 Self::QueryParam { name, .. } => write!(f, "QueryParam({})", name),
852 }
853 }
854}
855
856#[cfg(test)]
857mod tests {
858 use super::*;
859 use zentinel_common::types::Priority;
860 use zentinel_config::{MatchCondition, RouteConfig};
861
862 #[test]
863 fn route_cache_never_exceeds_max_size() {
864 let cache = RouteCache::new(10);
865 for i in 0..100 {
866 cache.insert(format!("key-{i}"), RouteId::new(format!("route-{i}")));
867 assert!(
868 cache.len() <= 10,
869 "route cache grew past max_size: {}",
870 cache.len()
871 );
872 }
873 }
874
875 fn create_test_route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
876 RouteConfig {
877 id: id.to_string(),
878 priority: Priority::NORMAL,
879 matches,
880 upstream: Some("test_upstream".to_string()),
881 service_type: zentinel_config::ServiceType::Web,
882 policies: Default::default(),
883 filters: vec![],
884 builtin_handler: None,
885 waf_enabled: false,
886 retry_policy: None,
887 static_files: None,
888 api_schema: None,
889 error_pages: None,
890 websocket: false,
891 websocket_inspection: false,
892 inference: None,
893 mcp: None,
894 a2a: None,
895 shadow: None,
896 fallback: None,
897 }
898 }
899
900 #[test]
901 fn test_path_matching() {
902 let routes = vec![
903 create_test_route(
904 "exact",
905 vec![MatchCondition::Path("/api/v1/users".to_string())],
906 ),
907 create_test_route(
908 "prefix",
909 vec![MatchCondition::PathPrefix("/api/".to_string())],
910 ),
911 ];
912
913 let matcher = RouteMatcher::new(routes, None).unwrap();
914
915 let req = RequestInfo {
916 method: "GET",
917 path: "/api/v1/users",
918 host: "example.com",
919 headers: None,
920 query_params: None,
921 };
922
923 let result = matcher.match_request(&req).unwrap();
924 assert_eq!(result.route_id.as_str(), "exact");
925 }
926
927 #[test]
928 fn test_host_wildcard_matching() {
929 let routes = vec![create_test_route(
930 "wildcard",
931 vec![MatchCondition::Host("*.example.com".to_string())],
932 )];
933
934 let matcher = RouteMatcher::new(routes, None).unwrap();
935
936 let req = RequestInfo {
937 method: "GET",
938 path: "/",
939 host: "api.example.com",
940 headers: None,
941 query_params: None,
942 };
943
944 let result = matcher.match_request(&req).unwrap();
945 assert_eq!(result.route_id.as_str(), "wildcard");
946 }
947
948 #[test]
949 fn test_priority_ordering() {
950 let mut route1 =
951 create_test_route("low", vec![MatchCondition::PathPrefix("/".to_string())]);
952 route1.priority = Priority::LOW;
953
954 let mut route2 =
955 create_test_route("high", vec![MatchCondition::PathPrefix("/".to_string())]);
956 route2.priority = Priority::HIGH;
957
958 let routes = vec![route1, route2];
959 let matcher = RouteMatcher::new(routes, None).unwrap();
960
961 let req = RequestInfo {
962 method: "GET",
963 path: "/test",
964 host: "example.com",
965 headers: None,
966 query_params: None,
967 };
968
969 let result = matcher.match_request(&req).unwrap();
970 assert_eq!(result.route_id.as_str(), "high");
971 }
972
973 #[test]
974 fn test_query_param_parsing() {
975 let params = RequestInfo::parse_query_params("/path?foo=bar&baz=qux&empty=");
976 assert_eq!(params.get("foo"), Some(&"bar".to_string()));
977 assert_eq!(params.get("baz"), Some(&"qux".to_string()));
978 assert_eq!(params.get("empty"), Some(&"".to_string()));
979 }
980
981 #[test]
982 fn test_path_prefix_segment_boundary() {
983 let routes = vec![
984 create_test_route("v2", vec![MatchCondition::PathPrefix("/v2".to_string())]),
985 create_test_route(
986 "catch-all",
987 vec![MatchCondition::PathPrefix("/".to_string())],
988 ),
989 ];
990
991 let matcher = RouteMatcher::new(routes, None).unwrap();
992
993 let req = RequestInfo::new("GET", "/v2", "example.com");
995 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
996
997 let req = RequestInfo::new("GET", "/v2/", "example.com");
999 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
1000
1001 let req = RequestInfo::new("GET", "/v2/anything", "example.com");
1003 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
1004
1005 let req = RequestInfo::new("GET", "/v2example", "example.com");
1007 assert_eq!(
1008 matcher.match_request(&req).unwrap().route_id.as_str(),
1009 "catch-all"
1010 );
1011
1012 let req = RequestInfo::new("GET", "/v2?foo=bar", "example.com");
1014 assert_eq!(matcher.match_request(&req).unwrap().route_id.as_str(), "v2");
1015 }
1016
1017 #[test]
1018 fn test_header_matching_with_specificity() {
1019 let routes = vec![
1020 create_test_route(
1021 "catch-all",
1022 vec![MatchCondition::PathPrefix("/".to_string())],
1023 ),
1024 create_test_route(
1025 "header-v2",
1026 vec![
1027 MatchCondition::Header {
1028 name: "version".to_string(),
1029 value: Some("two".to_string()),
1030 },
1031 MatchCondition::PathPrefix("/".to_string()),
1032 ],
1033 ),
1034 ];
1035
1036 let matcher = RouteMatcher::new(routes, None).unwrap();
1037
1038 let req = RequestInfo::new("GET", "/", "example.com");
1040 assert_eq!(
1041 matcher.match_request(&req).unwrap().route_id.as_str(),
1042 "catch-all"
1043 );
1044
1045 let mut headers = HashMap::new();
1047 headers.insert("version".to_string(), "two".to_string());
1048 let req = RequestInfo::new("GET", "/", "example.com").with_headers(headers);
1049 assert_eq!(
1050 matcher.match_request(&req).unwrap().route_id.as_str(),
1051 "header-v2"
1052 );
1053 }
1054}
1055
1056#[cfg(test)]
1057mod probe_113 {
1058 use super::*;
1059 use zentinel_common::types::Priority;
1060 use zentinel_config::{MatchCondition, RouteConfig};
1061
1062 fn route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
1063 RouteConfig {
1064 id: id.to_string(),
1065 priority: Priority::NORMAL,
1066 matches,
1067 upstream: Some("u".to_string()),
1068 service_type: zentinel_config::ServiceType::Web,
1069 policies: Default::default(),
1070 filters: vec![],
1071 builtin_handler: None,
1072 waf_enabled: false,
1073 retry_policy: None,
1074 static_files: None,
1075 api_schema: None,
1076 error_pages: None,
1077 websocket: false,
1078 websocket_inspection: false,
1079 inference: None,
1080 mcp: None,
1081 a2a: None,
1082 shadow: None,
1083 fallback: None,
1084 }
1085 }
1086
1087 #[test]
1088 fn probe_query_param_matching() {
1089 let build = || {
1090 RouteMatcher::new(
1091 vec![
1092 route(
1093 "v2",
1094 vec![
1095 MatchCondition::PathPrefix("/api".into()),
1096 MatchCondition::QueryParam {
1097 name: "version".into(),
1098 value: Some("v2".into()),
1099 },
1100 ],
1101 ),
1102 route("fallback", vec![MatchCondition::PathPrefix("/api".into())]),
1103 ],
1104 None,
1105 )
1106 .unwrap()
1107 };
1108
1109 let m = build();
1111 let mut p1 = std::collections::HashMap::new();
1112 p1.insert("version".to_string(), "v2".to_string());
1113 let r = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(p1));
1114 println!(
1115 " A version=v2, fresh -> {:?}",
1116 r.map(|r| r.route_id.to_string())
1117 );
1118
1119 let m = build();
1121 let mut p2 = std::collections::HashMap::new();
1122 p2.insert("version".to_string(), "v1".to_string());
1123 let r = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(p2));
1124 println!(
1125 " B version=v1, fresh -> {:?}",
1126 r.map(|r| r.route_id.to_string())
1127 );
1128
1129 let m = build();
1131 let r = m.match_request(&RequestInfo::new("GET", "/api/x", "h"));
1132 println!(
1133 " C no params, fresh -> {:?}",
1134 r.map(|r| r.route_id.to_string())
1135 );
1136
1137 let m = build();
1139 let mut pa = std::collections::HashMap::new();
1140 pa.insert("version".to_string(), "v2".to_string());
1141 let first = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(pa));
1142 let mut pb = std::collections::HashMap::new();
1143 pb.insert("version".to_string(), "v1".to_string());
1144 let second = m.match_request(&RequestInfo::new("GET", "/api/x", "h").with_query_params(pb));
1145 println!(
1146 " D v2 then v1, shared -> first={:?} second={:?}",
1147 first.map(|r| r.route_id.to_string()),
1148 second.map(|r| r.route_id.to_string())
1149 );
1150 }
1151}
1152
1153#[cfg(test)]
1154mod route_cache_correctness {
1155 use super::*;
1156 use std::collections::HashMap;
1157 use zentinel_common::types::Priority;
1158 use zentinel_config::{MatchCondition, RouteConfig};
1159
1160 fn route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
1161 RouteConfig {
1162 id: id.to_string(),
1163 priority: Priority::NORMAL,
1164 matches,
1165 upstream: Some("u".to_string()),
1166 service_type: zentinel_config::ServiceType::Web,
1167 policies: Default::default(),
1168 filters: vec![],
1169 builtin_handler: None,
1170 waf_enabled: false,
1171 retry_policy: None,
1172 static_files: None,
1173 api_schema: None,
1174 error_pages: None,
1175 websocket: false,
1176 websocket_inspection: false,
1177 inference: None,
1178 mcp: None,
1179 a2a: None,
1180 shadow: None,
1181 fallback: None,
1182 }
1183 }
1184
1185 fn params(pairs: &[(&str, &str)]) -> HashMap<String, String> {
1186 pairs
1187 .iter()
1188 .map(|(k, v)| (k.to_string(), v.to_string()))
1189 .collect()
1190 }
1191
1192 fn matched(m: &RouteMatcher, req: &RequestInfo<'_>) -> Option<String> {
1193 m.match_request(req).map(|r| r.route_id.to_string())
1194 }
1195
1196 fn query_matcher() -> RouteMatcher {
1197 RouteMatcher::new(
1198 vec![
1199 route(
1200 "v2",
1201 vec![
1202 MatchCondition::PathPrefix("/api".into()),
1203 MatchCondition::QueryParam {
1204 name: "version".into(),
1205 value: Some("v2".into()),
1206 },
1207 ],
1208 ),
1209 route("fallback", vec![MatchCondition::PathPrefix("/api".into())]),
1210 ],
1211 None,
1212 )
1213 .unwrap()
1214 }
1215
1216 #[test]
1225 fn a_query_parameter_change_is_not_served_from_cache() {
1226 let m = query_matcher();
1227
1228 let first = matched(
1229 &m,
1230 &RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v2")])),
1231 );
1232 let second = matched(
1233 &m,
1234 &RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v1")])),
1235 );
1236
1237 assert_eq!(first.as_deref(), Some("v2"));
1238 assert_eq!(
1239 second.as_deref(),
1240 Some("fallback"),
1241 "the second request was served the first request's route from cache"
1242 );
1243 }
1244
1245 #[test]
1248 fn the_reverse_order_is_also_correct() {
1249 let m = query_matcher();
1250
1251 let first = matched(
1252 &m,
1253 &RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v1")])),
1254 );
1255 let second = matched(
1256 &m,
1257 &RequestInfo::new("GET", "/api/x", "h").with_query_params(params(&[("version", "v2")])),
1258 );
1259
1260 assert_eq!(first.as_deref(), Some("fallback"));
1261 assert_eq!(second.as_deref(), Some("v2"));
1262 }
1263
1264 #[test]
1267 fn an_absent_query_parameter_is_distinct_from_a_present_one() {
1268 let m = query_matcher();
1269
1270 assert_eq!(
1271 matched(
1272 &m,
1273 &RequestInfo::new("GET", "/api/x", "h")
1274 .with_query_params(params(&[("version", "v2")]))
1275 )
1276 .as_deref(),
1277 Some("v2")
1278 );
1279 assert_eq!(
1280 matched(&m, &RequestInfo::new("GET", "/api/x", "h")).as_deref(),
1281 Some("fallback"),
1282 "a request without the parameter must not inherit the parameterised route"
1283 );
1284 }
1285
1286 #[test]
1289 fn parameter_order_does_not_affect_the_cache_key() {
1290 let m = query_matcher();
1291
1292 let a = RequestInfo::new("GET", "/api/x", "h")
1293 .with_query_params(params(&[("version", "v2"), ("page", "1")]));
1294 let b = RequestInfo::new("GET", "/api/x", "h")
1295 .with_query_params(params(&[("page", "1"), ("version", "v2")]));
1296
1297 assert_eq!(matched(&m, &a).as_deref(), Some("v2"));
1298 assert_eq!(matched(&m, &b).as_deref(), Some("v2"));
1299
1300 assert_eq!(
1304 m.cache_stats().entries,
1305 1,
1306 "parameter order changed the cache key"
1307 );
1308 }
1309
1310 #[test]
1313 fn a_header_and_a_query_parameter_do_not_collide() {
1314 let m = RouteMatcher::new(
1315 vec![
1316 route(
1317 "by-header",
1318 vec![
1319 MatchCondition::PathPrefix("/x".into()),
1320 MatchCondition::Header {
1321 name: "tenant".into(),
1322 value: Some("acme".into()),
1323 },
1324 ],
1325 ),
1326 route(
1327 "by-query",
1328 vec![
1329 MatchCondition::PathPrefix("/x".into()),
1330 MatchCondition::QueryParam {
1331 name: "tenant".into(),
1332 value: Some("acme".into()),
1333 },
1334 ],
1335 ),
1336 route("fallback", vec![MatchCondition::PathPrefix("/x".into())]),
1337 ],
1338 None,
1339 )
1340 .unwrap();
1341
1342 let with_header =
1343 RequestInfo::new("GET", "/x", "h").with_headers(params(&[("tenant", "acme")]));
1344 let with_query =
1345 RequestInfo::new("GET", "/x", "h").with_query_params(params(&[("tenant", "acme")]));
1346
1347 assert_eq!(matched(&m, &with_header).as_deref(), Some("by-header"));
1348 assert_eq!(
1349 matched(&m, &with_query).as_deref(),
1350 Some("by-query"),
1351 "a query parameter was served the header route's cache entry"
1352 );
1353 }
1354}
1355
1356#[cfg(test)]
1362mod route_matching_edges {
1363 use super::*;
1364 use std::collections::HashMap;
1365 use zentinel_common::types::Priority;
1366 use zentinel_config::{MatchCondition, RouteConfig};
1367
1368 fn route(id: &str, matches: Vec<MatchCondition>) -> RouteConfig {
1369 route_with_priority(id, matches, Priority::NORMAL)
1370 }
1371
1372 fn route_with_priority(
1373 id: &str,
1374 matches: Vec<MatchCondition>,
1375 priority: Priority,
1376 ) -> RouteConfig {
1377 RouteConfig {
1378 id: id.to_string(),
1379 priority,
1380 matches,
1381 upstream: Some("u".to_string()),
1382 service_type: zentinel_config::ServiceType::Web,
1383 policies: Default::default(),
1384 filters: vec![],
1385 builtin_handler: None,
1386 waf_enabled: false,
1387 retry_policy: None,
1388 static_files: None,
1389 api_schema: None,
1390 error_pages: None,
1391 websocket: false,
1392 websocket_inspection: false,
1393 inference: None,
1394 mcp: None,
1395 a2a: None,
1396 shadow: None,
1397 fallback: None,
1398 }
1399 }
1400
1401 fn matcher(routes: Vec<RouteConfig>) -> RouteMatcher {
1402 RouteMatcher::new(routes, None).expect("routes should compile")
1403 }
1404
1405 fn hit(m: &RouteMatcher, method: &str, path: &str, host: &str) -> Option<String> {
1406 m.match_request(&RequestInfo::new(method, path, host))
1407 .map(|r| r.route_id.to_string())
1408 }
1409
1410 fn pairs(kv: &[(&str, &str)]) -> HashMap<String, String> {
1411 kv.iter()
1412 .map(|(k, v)| (k.to_string(), v.to_string()))
1413 .collect()
1414 }
1415
1416 #[test]
1422 fn host_matching_ignores_case_on_both_sides() {
1423 let m = matcher(vec![route(
1424 "h",
1425 vec![MatchCondition::Host("example.com".into())],
1426 )]);
1427 assert_eq!(hit(&m, "GET", "/", "example.com").as_deref(), Some("h"));
1428 assert_eq!(hit(&m, "GET", "/", "EXAMPLE.COM").as_deref(), Some("h"));
1429 assert_eq!(hit(&m, "GET", "/", "ExAmPlE.cOm").as_deref(), Some("h"));
1430
1431 let m = matcher(vec![route(
1432 "h",
1433 vec![MatchCondition::Host("Example.COM".into())],
1434 )]);
1435 assert_eq!(
1436 hit(&m, "GET", "/", "example.com").as_deref(),
1437 Some("h"),
1438 "a mixed-case config host must still match"
1439 );
1440 }
1441
1442 #[test]
1446 fn a_trailing_dot_does_not_bypass_a_host_route() {
1447 let m = matcher(vec![
1450 route_with_priority(
1451 "restricted",
1452 vec![
1453 MatchCondition::Host("admin.example.com".into()),
1454 MatchCondition::PathPrefix("/".into()),
1455 ],
1456 Priority::HIGH,
1457 ),
1458 route_with_priority(
1459 "catchall",
1460 vec![MatchCondition::PathPrefix("/".into())],
1461 Priority::LOW,
1462 ),
1463 ]);
1464
1465 assert_eq!(
1466 hit(&m, "GET", "/", "admin.example.com").as_deref(),
1467 Some("restricted")
1468 );
1469 assert_eq!(
1470 hit(&m, "GET", "/", "admin.example.com.").as_deref(),
1471 Some("restricted"),
1472 "a trailing dot must not route around the host match"
1473 );
1474 assert_eq!(
1476 hit(&m, "GET", "/", "other.com").as_deref(),
1477 Some("catchall")
1478 );
1479 }
1480
1481 #[test]
1490 fn a_host_only_route_loses_to_a_path_catchall() {
1491 let m = matcher(vec![
1492 route(
1493 "host_only",
1494 vec![MatchCondition::Host("admin.example.com".into())],
1495 ),
1496 route("catchall", vec![MatchCondition::PathPrefix("/".into())]),
1497 ]);
1498 assert_eq!(
1499 hit(&m, "GET", "/", "admin.example.com").as_deref(),
1500 Some("catchall")
1501 );
1502
1503 let m = matcher(vec![
1505 route(
1506 "host_and_path",
1507 vec![
1508 MatchCondition::Host("admin.example.com".into()),
1509 MatchCondition::PathPrefix("/".into()),
1510 ],
1511 ),
1512 route("catchall", vec![MatchCondition::PathPrefix("/".into())]),
1513 ]);
1514 assert_eq!(
1515 hit(&m, "GET", "/", "admin.example.com").as_deref(),
1516 Some("host_and_path")
1517 );
1518 }
1519
1520 #[test]
1521 fn a_port_is_ignored_when_matching_a_host() {
1522 let m = matcher(vec![route(
1523 "h",
1524 vec![MatchCondition::Host("example.com".into())],
1525 )]);
1526 assert_eq!(
1527 hit(&m, "GET", "/", "example.com:8443").as_deref(),
1528 Some("h")
1529 );
1530 assert_eq!(hit(&m, "GET", "/", "example.com:80").as_deref(), Some("h"));
1531 }
1532
1533 #[test]
1536 fn an_ipv6_host_is_not_mangled_by_port_stripping() {
1537 assert_eq!(normalize_host("[::1]:8080"), "[::1]");
1538 assert_eq!(normalize_host("[2001:DB8::1]"), "[2001:db8::1]");
1539 assert_eq!(normalize_host("[::1]"), "[::1]");
1540 }
1541
1542 #[test]
1543 fn wildcard_hosts_also_ignore_case_port_and_trailing_dot() {
1544 let m = matcher(vec![route(
1545 "w",
1546 vec![MatchCondition::Host("*.example.com".into())],
1547 )]);
1548 for host in [
1549 "api.example.com",
1550 "API.EXAMPLE.COM",
1551 "api.example.com:8443",
1552 "api.example.com.",
1553 ] {
1554 assert_eq!(
1555 hit(&m, "GET", "/", host).as_deref(),
1556 Some("w"),
1557 "host {host}"
1558 );
1559 }
1560 }
1561
1562 #[test]
1565 fn a_wildcard_does_not_match_its_own_suffix() {
1566 let m = matcher(vec![route(
1567 "w",
1568 vec![MatchCondition::Host("*.example.com".into())],
1569 )]);
1570 assert_eq!(hit(&m, "GET", "/", "example.com").as_deref(), None);
1571 assert_eq!(hit(&m, "GET", "/", "notexample.com").as_deref(), None);
1572 }
1573
1574 #[test]
1578 fn hosts_are_alternatives_but_other_conditions_are_required() {
1579 let m = matcher(vec![route(
1580 "multi",
1581 vec![
1582 MatchCondition::Host("a.com".into()),
1583 MatchCondition::Host("b.com".into()),
1584 MatchCondition::PathPrefix("/x".into()),
1585 ],
1586 )]);
1587
1588 assert_eq!(hit(&m, "GET", "/x", "a.com").as_deref(), Some("multi"));
1589 assert_eq!(hit(&m, "GET", "/x", "b.com").as_deref(), Some("multi"));
1590 assert_eq!(
1591 hit(&m, "GET", "/x", "c.com").as_deref(),
1592 None,
1593 "an unlisted host must not match"
1594 );
1595 assert_eq!(
1596 hit(&m, "GET", "/y", "a.com").as_deref(),
1597 None,
1598 "the path is still required"
1599 );
1600 }
1601
1602 #[test]
1605 fn an_exact_path_does_not_match_children_or_a_trailing_slash() {
1606 let m = matcher(vec![route(
1607 "exact",
1608 vec![MatchCondition::Path("/api".into())],
1609 )]);
1610 assert_eq!(hit(&m, "GET", "/api", "h").as_deref(), Some("exact"));
1611 assert_eq!(hit(&m, "GET", "/api/", "h").as_deref(), None);
1612 assert_eq!(hit(&m, "GET", "/api/users", "h").as_deref(), None);
1613 assert_eq!(hit(&m, "GET", "/apiv2", "h").as_deref(), None);
1614 }
1615
1616 #[test]
1619 fn a_prefix_stops_at_a_segment_boundary() {
1620 let m = matcher(vec![route(
1621 "p",
1622 vec![MatchCondition::PathPrefix("/api".into())],
1623 )]);
1624 assert_eq!(hit(&m, "GET", "/api", "h").as_deref(), Some("p"));
1625 assert_eq!(hit(&m, "GET", "/api/", "h").as_deref(), Some("p"));
1626 assert_eq!(hit(&m, "GET", "/api/users", "h").as_deref(), Some("p"));
1627 assert_eq!(hit(&m, "GET", "/apikeys", "h").as_deref(), None);
1628 assert_eq!(hit(&m, "GET", "/apiv2/users", "h").as_deref(), None);
1629 }
1630
1631 #[test]
1634 fn an_invalid_path_regex_is_rejected_at_load() {
1635 for pattern in ["(unclosed", "a{2,1}", "[z-a]"] {
1636 assert!(
1637 RouteMatcher::new(
1638 vec![route("r", vec![MatchCondition::PathRegex(pattern.into())])],
1639 None,
1640 )
1641 .is_err(),
1642 "{pattern:?} should be rejected"
1643 );
1644 }
1645 }
1646
1647 #[test]
1648 fn a_path_regex_is_anchored_as_written() {
1649 let m = matcher(vec![route(
1650 "r",
1651 vec![MatchCondition::PathRegex("^/v[0-9]+/users$".into())],
1652 )]);
1653 assert_eq!(hit(&m, "GET", "/v1/users", "h").as_deref(), Some("r"));
1654 assert_eq!(hit(&m, "GET", "/v42/users", "h").as_deref(), Some("r"));
1655 assert_eq!(hit(&m, "GET", "/v1/users/1", "h").as_deref(), None);
1656 assert_eq!(hit(&m, "GET", "/x/v1/users", "h").as_deref(), None);
1657 }
1658
1659 #[test]
1662 fn a_method_condition_accepts_any_of_its_methods() {
1663 let m = matcher(vec![route(
1664 "rw",
1665 vec![MatchCondition::Method(vec!["POST".into(), "PUT".into()])],
1666 )]);
1667 assert_eq!(hit(&m, "POST", "/x", "h").as_deref(), Some("rw"));
1668 assert_eq!(hit(&m, "PUT", "/x", "h").as_deref(), Some("rw"));
1669 assert_eq!(hit(&m, "GET", "/x", "h").as_deref(), None);
1670 assert_eq!(hit(&m, "DELETE", "/x", "h").as_deref(), None);
1671 }
1672
1673 #[test]
1677 fn a_header_condition_distinguishes_presence_from_value() {
1678 let present = matcher(vec![route(
1679 "any",
1680 vec![MatchCondition::Header {
1681 name: "x-key".into(),
1682 value: None,
1683 }],
1684 )]);
1685 let exact = matcher(vec![route(
1686 "exact",
1687 vec![MatchCondition::Header {
1688 name: "x-key".into(),
1689 value: Some("secret".into()),
1690 }],
1691 )]);
1692
1693 let with_other =
1694 RequestInfo::new("GET", "/x", "h").with_headers(pairs(&[("x-key", "other")]));
1695 let with_secret =
1696 RequestInfo::new("GET", "/x", "h").with_headers(pairs(&[("x-key", "secret")]));
1697 let without = RequestInfo::new("GET", "/x", "h").with_headers(pairs(&[("y", "1")]));
1698
1699 assert!(present.match_request(&with_other).is_some());
1700 assert!(present.match_request(&without).is_none());
1701 assert!(exact.match_request(&with_secret).is_some());
1702 assert!(exact.match_request(&with_other).is_none());
1703 }
1704
1705 #[test]
1706 fn a_query_condition_distinguishes_presence_from_value() {
1707 let present = matcher(vec![route(
1708 "any",
1709 vec![MatchCondition::QueryParam {
1710 name: "debug".into(),
1711 value: None,
1712 }],
1713 )]);
1714 let exact = matcher(vec![route(
1715 "exact",
1716 vec![MatchCondition::QueryParam {
1717 name: "v".into(),
1718 value: Some("2".into()),
1719 }],
1720 )]);
1721
1722 assert!(present
1723 .match_request(
1724 &RequestInfo::new("GET", "/x", "h").with_query_params(pairs(&[("debug", "0")]))
1725 )
1726 .is_some());
1727 assert!(present
1728 .match_request(&RequestInfo::new("GET", "/x", "h"))
1729 .is_none());
1730 assert!(exact
1731 .match_request(
1732 &RequestInfo::new("GET", "/x", "h").with_query_params(pairs(&[("v", "2")]))
1733 )
1734 .is_some());
1735 assert!(exact
1736 .match_request(
1737 &RequestInfo::new("GET", "/x", "h").with_query_params(pairs(&[("v", "1")]))
1738 )
1739 .is_none());
1740 }
1741
1742 #[test]
1745 fn higher_priority_wins_regardless_of_declaration_order() {
1746 let m = matcher(vec![
1747 route_with_priority(
1748 "low",
1749 vec![MatchCondition::PathPrefix("/a".into())],
1750 Priority::LOW,
1751 ),
1752 route_with_priority(
1753 "high",
1754 vec![MatchCondition::PathPrefix("/a".into())],
1755 Priority::HIGH,
1756 ),
1757 ]);
1758 assert_eq!(hit(&m, "GET", "/a", "h").as_deref(), Some("high"));
1759 }
1760
1761 #[test]
1765 fn equal_priorities_resolve_deterministically() {
1766 for _ in 0..10 {
1767 let m = matcher(vec![
1768 route("first", vec![MatchCondition::PathPrefix("/a".into())]),
1769 route("second", vec![MatchCondition::PathPrefix("/a".into())]),
1770 ]);
1771 assert_eq!(hit(&m, "GET", "/a", "h").as_deref(), Some("first"));
1772 }
1773 }
1774
1775 #[test]
1778 fn a_route_with_no_conditions_matches_anything() {
1779 let m = matcher(vec![route("catchall", vec![])]);
1780 assert_eq!(
1781 hit(&m, "GET", "/anything", "h").as_deref(),
1782 Some("catchall")
1783 );
1784 assert_eq!(
1785 hit(&m, "POST", "/", "other.host").as_deref(),
1786 Some("catchall")
1787 );
1788 }
1789
1790 #[test]
1791 fn no_match_returns_none_rather_than_an_arbitrary_route() {
1792 let m = matcher(vec![route(
1793 "api",
1794 vec![MatchCondition::PathPrefix("/api".into())],
1795 )]);
1796 assert_eq!(hit(&m, "GET", "/other", "h").as_deref(), None);
1797 }
1798}