use crate::uri::{PathMatchOptions, PathPattern, PathRef};
fn p(s: &str) -> PathRef<'_> {
PathRef::from_raw_str(s)
}
fn caps(pattern: &str, path: &str) -> Option<Vec<(String, String)>> {
let pat = PathPattern::new(pattern);
pat.captures(p(path)).map(|c| {
let mut v: Vec<(String, String)> = c
.iter()
.map(|(n, val)| (n.to_owned(), val.to_owned()))
.collect();
v.sort();
v
})
}
fn glob_of(pattern: &str, path: &str) -> Option<String> {
PathPattern::new(pattern)
.captures(p(path))
.and_then(|c| c.glob().map(str::to_owned))
}
#[test]
fn literal_exact() {
let pat = PathPattern::new("/backend-api/codex/responses");
assert!(pat.is_match(p("/backend-api/codex/responses")));
assert!(!pat.is_match(p("/backend-api/codex/responses/x")));
assert!(!pat.is_match(p("/backend-api/codex")));
assert!(
pat.captures(p("/backend-api/codex/responses"))
.unwrap()
.is_empty()
);
assert!(pat.captures(p("/backend-api/codex")).is_none());
}
#[test]
fn old_metachars_are_now_literals() {
let colon = PathPattern::new("/books:archive");
assert!(colon.is_match(p("/books:archive")));
assert!(!colon.is_match(p("/books/archive")));
assert!(colon.captures(p("/books:archive")).unwrap().is_empty());
let star = PathPattern::new("/a*b");
assert!(star.is_match(p("/a*b")));
assert!(!star.is_match(p("/axb")));
assert!(!star.is_match(p("/ab")));
let dstar = PathPattern::new("/x/**");
assert!(dstar.is_match(p("/x/**")));
assert!(!dstar.is_match(p("/x/a/b")));
}
#[test]
fn whole_segment_capture_trailing_required() {
assert_eq!(
caps("/simple/{name}/", "/simple/requests/"),
Some(vec![("name".to_owned(), "requests".to_owned())])
);
assert!(caps("/simple/{name}/", "/simple/requests").is_none());
}
#[test]
fn optional_trailing_slash() {
let want = Some(vec![("name".to_owned(), "requests".to_owned())]);
assert_eq!(caps("/simple/{name}/?", "/simple/requests"), want);
assert_eq!(caps("/simple/{name}/?", "/simple/requests/"), want);
}
#[test]
fn capture_with_suffix() {
assert_eq!(
caps("/p2/{vendor}/{pkg}.json", "/p2/acme/widget.json"),
Some(vec![
("pkg".to_owned(), "widget".to_owned()),
("vendor".to_owned(), "acme".to_owned()),
])
);
assert_eq!(
caps("/p2/{vendor}/{pkg}.json", "/p2/acme/widget~dev.json"),
Some(vec![
("pkg".to_owned(), "widget~dev".to_owned()),
("vendor".to_owned(), "acme".to_owned()),
])
);
assert!(caps("/p2/{vendor}/{pkg}.json", "/p2/acme/widget.txt").is_none());
}
#[test]
fn anonymous_wildcard_suffix() {
let pat = PathPattern::new("/files/{}.txt");
assert!(pat.is_match(p("/files/readme.txt")));
assert!(!pat.is_match(p("/files/readme.md")));
assert!(pat.captures(p("/files/readme.txt")).unwrap().is_empty());
}
#[test]
fn catch_all_tail() {
assert_eq!(glob_of("/assets/{*}", "/assets/a"), Some("a".to_owned()));
assert_eq!(
glob_of("/assets/{*}", "/assets/a/b/c"),
Some("a/b/c".to_owned())
);
assert!(
PathPattern::new("/assets/{*}")
.captures(p("/assets"))
.is_none()
);
assert!(!PathPattern::new("/assets/{*}").is_match(p("/assets")));
}
#[test]
fn catch_all_middle() {
let pat = PathPattern::new("/p2/{*}/{}.txt");
assert!(pat.is_match(p("/p2/a/b/c.txt")));
assert!(!pat.is_match(p("/p2/x.txt")));
assert_eq!(
glob_of("/p2/{*}/{}.txt", "/p2/a/b/c.txt"),
Some("a/b".to_owned())
);
}
#[test]
fn decode_aware_capture() {
assert_eq!(
caps("/p2/{vendor}/{pkg}.json", "/p2/ac%6de/widget.json"),
Some(vec![
("pkg".to_owned(), "widget".to_owned()),
("vendor".to_owned(), "acme".to_owned()),
])
);
}
#[test]
fn case_sensitivity() {
let sensitive = PathPattern::new("/api/v2");
assert!(sensitive.is_match(p("/api/v2")));
assert!(!sensitive.is_match(p("/API/v2")));
let insensitive = PathPattern::new_with_opts(
"/api/v2",
PathMatchOptions {
ignore_ascii_case: true,
..Default::default()
},
);
assert!(insensitive.is_match(p("/API/v2")));
assert!(insensitive.is_match(p("/api/v2")));
}
#[test]
fn pattern_identity_honors_match_options_and_capture_names() {
use crate::test_hash::hash;
let opts = PathMatchOptions {
ignore_ascii_case: true,
..Default::default()
};
let upper = PathPattern::new_with_opts("/API/{id}.JSON", opts);
let lower = PathPattern::new_with_opts("/api/{id}.json", opts);
assert_eq!(upper, lower);
assert_eq!(hash(&upper), hash(&lower));
let different_capture_name = PathPattern::new_with_opts("/api/{ID}.json", opts);
assert_ne!(lower, different_capture_name);
let sensitive = PathPattern::new("/API/{id}.JSON");
assert_ne!(sensitive, lower);
}
#[test]
fn char_optional() {
let pat = PathPattern::new("/ab?c");
assert!(pat.is_match(p("/abc")));
assert!(pat.is_match(p("/ac")));
assert!(!pat.is_match(p("/abdc")));
}
#[test]
fn root_and_empty_edges() {
assert!(PathPattern::new("/").is_match(p("/")));
assert!(!PathPattern::new("/a").is_match(p("/")));
assert!(PathPattern::new("/a").is_match(p("/a")));
assert!(PathPattern::new("").is_match(p("")));
assert!(!PathPattern::new("/{}").is_match(p("/")));
assert!(PathPattern::new("/{}?").is_match(p("/")));
assert!(!PathPattern::new("{weird}?[]").is_match(p("/x")));
assert!(PathPattern::new("/{}").is_match(p("/anything")));
assert_eq!(glob_of("/{*}", "/a/b"), Some("a/b".to_owned()));
}
#[test]
fn is_match_captures_parity() {
let cases: &[(&str, &str)] = &[
(
"/backend-api/codex/responses",
"/backend-api/codex/responses",
),
("/files/{}.txt", "/files/a.txt"),
("/p2/{vendor}/{pkg}.json", "/p2/acme/widget.json"),
("/assets/{*}", "/assets/a/b"),
("/simple/{name}/?", "/simple/x/"),
("/foo/{name}?/bar", "/foo/bar"),
("/api/v2", "/api/v3"),
("/files/{}.txt", "/files/a.md"),
("/", "/"),
("/", "/a"),
];
for (pattern, path) in cases {
let pat = PathPattern::new(*pattern);
assert_eq!(
pat.is_match(p(path)),
pat.captures(p(path)).is_some(),
"is_match/captures disagree for pattern {pattern:?} path {path:?}"
);
}
let pat = PathPattern::new("/a/b/c");
assert!(pat.captures(p("/a/b/c")).unwrap().is_empty());
assert!(pat.captures(p("/a/b")).is_none());
}
#[test]
fn capture_accessors() {
let pat = PathPattern::new("/p2/{vendor}/{pkg}.json");
let caps = pat.captures(p("/p2/acme/widget.json")).unwrap();
assert_eq!(caps.get("vendor"), Some("acme"));
assert_eq!(caps.get_non_empty("vendor"), Some("acme"));
assert_eq!(caps.get("pkg"), Some("widget"));
assert_eq!(caps.get("absent"), None);
assert_eq!(caps.get_non_empty("absent"), None);
assert!(!caps.is_empty());
let empty_capture_pat = PathPattern::new("/{value}?");
let empty_capture = empty_capture_pat.captures(p("/")).unwrap();
assert_eq!(empty_capture.get("value"), Some(""));
assert_eq!(empty_capture.get_non_empty("value"), None);
let empty_pat = PathPattern::new("/x");
let empty = empty_pat.captures(p("/x")).unwrap();
assert!(empty.is_empty());
assert_eq!(empty.get("anything"), None);
assert_eq!(empty.get_non_empty("anything"), None);
}
#[test]
fn capture_then_literal_without_star() {
let pat = PathPattern::new("/p2/{vendor}/{pkg}.json");
let caps = pat.captures(p("/p2/acme/widget.json")).unwrap();
assert_eq!(caps.get("pkg"), Some("widget"));
assert_eq!(caps.get("vendor"), Some("acme"));
assert!(pat.captures(p("/p2/acme/widget.txt")).is_none());
}
#[test]
fn capture_names_with_underscore_and_dash() {
let pat = PathPattern::new("/{my_name}/{other-id}");
let caps = pat.captures(p("/foo/bar")).unwrap();
assert_eq!(caps.get("my_name"), Some("foo"));
assert_eq!(caps.get("other-id"), Some("bar"));
}
#[test]
fn capture_and_wildcard_runs_are_non_empty_by_default() {
let pat = PathPattern::new("/foo/{name}/bar");
assert_eq!(
caps("/foo/{name}/bar", "/foo/john/bar"),
Some(vec![("name".to_owned(), "john".to_owned())])
);
assert!(pat.captures(p("/foo//bar")).is_none());
assert!(pat.captures(p("/foo/bar")).is_none());
let suffixed = PathPattern::new("/files/{name}.txt");
assert_eq!(
suffixed
.captures(p("/files/readme.txt"))
.unwrap()
.get("name"),
Some("readme")
);
assert!(suffixed.captures(p("/files/.txt")).is_none());
let anonymous = PathPattern::new("/files/{}.txt");
assert!(anonymous.is_match(p("/files/readme.txt")));
assert!(!anonymous.is_match(p("/files/.txt")));
let plus_is_literal = PathPattern::new("/a+b/{name}");
assert!(plus_is_literal.is_match(p("/a+b/c")));
assert!(!plus_is_literal.is_match(p("/ab/c")));
let plus_inside_braces_is_not_a_token = PathPattern::new("/x/{name+}");
assert!(plus_inside_braces_is_not_a_token.is_match(p("/x/{name+}")));
assert!(!plus_inside_braces_is_not_a_token.is_match(p("/x/john")));
}
#[test]
fn optional_capture_accepts_empty_and_missing_segment() {
let pat = PathPattern::new("/foo/{name}?/bar");
let empty = pat.captures(p("/foo//bar")).unwrap();
assert_eq!(empty.get("name"), Some(""));
assert_eq!(empty.get_non_empty("name"), None);
let named = pat.captures(p("/foo/john/bar")).unwrap();
assert_eq!(named.get_non_empty("name"), Some("john"));
let missing = pat.captures(p("/foo/bar")).unwrap();
assert_eq!(missing.get("name"), Some(""));
assert_eq!(missing.get_non_empty("name"), None);
let anonymous = PathPattern::new("/foo/{}?/bar");
assert!(anonymous.is_match(p("/foo/x/bar")));
assert!(anonymous.is_match(p("/foo//bar")));
assert!(anonymous.is_match(p("/foo/bar")));
}
#[test]
fn capture_free_trailing_slash_fast_path() {
let required = PathPattern::new("/a/b/");
assert!(required.is_match(p("/a/b/")));
assert!(!required.is_match(p("/a/b")));
let forbidden = PathPattern::new("/a/b");
assert!(forbidden.is_match(p("/a/b")));
assert!(!forbidden.is_match(p("/a/b/")));
let optional = PathPattern::new("/a/b/?");
assert!(optional.is_match(p("/a/b")));
assert!(optional.is_match(p("/a/b/")));
}
#[test]
fn backtracking_discards_stale_bindings() {
assert_eq!(
caps("/{*}/{x}", "/a/b/c"),
Some(vec![("x".to_owned(), "c".to_owned())])
);
assert_eq!(glob_of("/{*}/{x}", "/a/b/c"), Some("a/b".to_owned()));
}
#[test]
fn anonymous_and_glob_excluded_from_named() {
let anon_pat = PathPattern::new("/p2/{}/{real}");
let caps = anon_pat.captures(p("/p2/foo/bar")).unwrap();
assert_eq!(caps.get("real"), Some("bar"));
assert_eq!(caps.iter().collect::<Vec<_>>(), vec![("real", "bar")]);
let glob_pat = PathPattern::new("/files/{*}/{name}");
let g = glob_pat.captures(p("/files/a/b/x")).unwrap();
assert_eq!(g.get("name"), Some("x"));
assert_eq!(g.glob(), Some("a/b"));
assert_eq!(g.iter().collect::<Vec<_>>(), vec![("name", "x")]);
}
#[test]
fn named_catch_all() {
assert_eq!(
caps("/assets/{*path}", "/assets/css/app.css"),
Some(vec![("path".to_owned(), "css/app.css".to_owned())])
);
assert_eq!(
caps("/assets/{*path}", "/assets/a"),
Some(vec![("path".to_owned(), "a".to_owned())])
);
let pat = PathPattern::new("/assets/{*path}");
let c = pat.captures(p("/assets/a/b")).unwrap();
assert_eq!(c.get("path"), Some("a/b"));
assert_eq!(c.glob(), None);
assert!(
PathPattern::new("/assets/{*path}")
.captures(p("/assets"))
.is_none()
);
assert_eq!(
caps("/a/{*mid}/z", "/a/b/c/z"),
Some(vec![("mid".to_owned(), "b/c".to_owned())])
);
assert_eq!(
caps("/d/{*rest}", "/d/a%20b/c"),
Some(vec![("rest".to_owned(), "a b/c".to_owned())])
);
assert!(
PathPattern::new("/assets/{path}")
.captures(p("/assets/a/b"))
.is_none()
);
assert_eq!(
caps("/assets/{path}", "/assets/a"),
Some(vec![("path".to_owned(), "a".to_owned())])
);
}
#[test]
fn utf8_literal_and_capture() {
assert!(PathPattern::new("/café/menu").is_match(p("/café/menu")));
assert!(!PathPattern::new("/café/menu").is_match(p("/cafe/menu")));
assert_eq!(
caps("/u/{name}", "/u/naïve"),
Some(vec![("name".to_owned(), "naïve".to_owned())])
);
assert_eq!(
caps("/u/{name}", "/u/caf%C3%A9"),
Some(vec![("name".to_owned(), "café".to_owned())])
);
assert!(PathPattern::new("/café").is_match(p("/caf%C3%A9")));
assert_eq!(
caps("/d/{name}.md", "/d/naïve.md"),
Some(vec![("name".to_owned(), "naïve".to_owned())])
);
}
#[test]
fn pct_encoded_inside_segment_is_not_a_separator() {
assert_eq!(
caps("/files/{name}", "/files/a%2Fb"),
Some(vec![("name".to_owned(), "a/b".to_owned())])
);
assert_eq!(
caps("/person/{name}/age", "/person/glen%20dc/age"),
Some(vec![("name".to_owned(), "glen dc".to_owned())])
);
assert_eq!(
caps("/x/{v}", "/x/%7Babc"),
Some(vec![("v".to_owned(), "{abc".to_owned())])
);
}
#[test]
fn invalid_utf8_decodes_lossy_without_panic() {
let pat = PathPattern::new("/x/{v}");
let caps = pat.captures(p("/x/%ff")).unwrap();
assert_eq!(caps.get("v"), Some("\u{FFFD}"));
assert_eq!(glob_of("/g/{*}", "/g/a/%ff"), Some("a/\u{FFFD}".to_owned()));
}
#[test]
fn misused_meta_chars_never_panic_and_stay_consistent() {
let patterns = [
"{",
"}",
"{}",
"{{}}",
"{}}",
"{{}",
"{name",
"name}",
"{bad name}",
"{na/me}",
"{*}",
"{**}",
"{*}}",
"{*bad name}",
"{*na/me}",
"a{*}",
"{*}b",
"{}?",
"{name}?",
"{?}",
"?{}",
"??",
"a?",
"ab?c",
"{weird}?[]",
":",
"*",
"**",
"***",
"/",
"//",
"///",
"",
"/a/{*}/{*}/b",
"{a}{b}{c}",
"{*rest}",
"x{*name}",
"{*name}}",
"/a/{*mid}/z",
"{}.{}",
"v{ver}-rc",
];
let paths = [
"", "/", "//", "/abc", "/a/b", "/a/b/c", "/a//b", "/x/", "/*", "/:", "/a?b", "/%2F",
"/%ff", "/café", "/a%2Fb/c",
];
for pattern in patterns {
let pat = PathPattern::new(pattern);
for path in paths {
let pr = p(path);
assert_eq!(
pat.is_match(pr),
pat.captures(pr).is_some(),
"is_match/captures disagree: pattern={pattern:?} path={path:?}"
);
if let Some(c) = pat.captures(pr) {
for (name, _value) in c.iter() {
assert!(c.get(name).is_some());
}
core::hint::black_box((c.glob(), c.is_empty()));
}
}
}
}
#[test]
fn pathological_multi_run_segment_is_polynomial_and_correct() {
use std::time::{Duration, Instant};
let pat = PathPattern::new("/{a}b{b}b");
let hay = "/".to_owned() + &"a".repeat(40) + "bbb";
let start = Instant::now();
let caps = pat.captures(p(&hay)).expect("must match");
assert_eq!(caps.get("a"), Some("a".repeat(40).as_str()));
assert_eq!(caps.get("b"), Some("b"));
let fail_pat = PathPattern::new("{x}{y}{z}{w}{v}END");
let fail_hay = "/".to_owned() + &"q".repeat(60);
assert!(!fail_pat.is_match(p(&fail_hay)));
assert!(
start.elapsed() < Duration::from_secs(2),
"matching must stay polynomial"
);
}
#[test]
fn pathological_many_optional_literals_is_polynomial() {
use std::time::{Duration, Instant};
let mut raw = String::from("/");
for _ in 0..30 {
raw.push_str("a?");
}
raw.push('Z');
let pat = PathPattern::new(raw.as_str());
let hay = "/".to_owned() + &"a".repeat(30);
let start = Instant::now();
assert!(!pat.is_match(p(&hay)));
assert!(PathPattern::new(raw.as_str()).is_match(p("/Z")));
assert!(
start.elapsed() < Duration::from_secs(2),
"matching must stay polynomial"
);
}
#[test]
fn pathological_multi_catchall_is_polynomial_and_correct() {
use std::time::{Duration, Instant};
let pat = PathPattern::new("/{*}/{*}/{*}/{*}/{*}/{*}/{*}/{*}/end");
let hay = "/".to_owned() + &"x/".repeat(30) + "y";
let start = Instant::now();
assert!(!pat.is_match(p(&hay)));
assert!(
start.elapsed() < Duration::from_secs(2),
"matching must stay polynomial"
);
let ok = PathPattern::new("/{*}/{*}/end");
let g = ok.captures(p("/a/b/c/end")).expect("must match");
assert_eq!(g.glob(), Some("a"));
}
#[test]
fn brace_misuse_falls_back_to_literal() {
let pat = PathPattern::new("/a{*}b");
assert!(pat.is_match(p("/a{*}b")));
assert!(!pat.is_match(p("/axb")));
let pat = PathPattern::new("/{}}");
assert!(pat.is_match(p("/anything}")));
assert!(!pat.is_match(p("/anything")));
let pat = PathPattern::new("/{*}}");
assert!(pat.is_match(p("/{*}}")));
assert!(!pat.is_match(p("/a/b")));
}
#[test]
fn segment_kinds_classify_each_segment() {
use crate::uri::PathPatternSegmentKind as K;
let kinds = |pat: &str| PathPattern::new(pat).segment_kinds().collect::<Vec<_>>();
assert_eq!(kinds("/a/b"), [K::Literal, K::Literal]);
assert_eq!(kinds("/users/{id}"), [K::Literal, K::Dynamic]);
assert_eq!(kinds("/files/{}.json"), [K::Literal, K::Dynamic]);
assert_eq!(kinds("/p/{pkg}.json"), [K::Literal, K::Dynamic]);
assert_eq!(kinds("/assets/{*}"), [K::Literal, K::CatchAll]);
assert_eq!(kinds("/assets/{*rest}"), [K::Literal, K::CatchAll]);
assert_eq!(kinds("/maybe/a?"), [K::Literal, K::Dynamic]);
assert_eq!(kinds("/api/{*bad name}"), [K::Literal, K::Literal]);
assert_eq!(kinds("/x/{na.me}"), [K::Literal, K::Literal]);
assert_eq!(PathPattern::new("/").segment_kinds().len(), 0);
assert_eq!(PathPattern::new("").segment_kinds().len(), 0);
}
#[test]
fn segment_specificity_reports_dynamic_tie_breakers() {
use crate::uri::PathPatternSegmentKind as K;
let specs: Vec<_> = PathPattern::new("/files/{name}.json")
.segment_specificity()
.collect();
assert_eq!(specs[0].kind, K::Literal);
assert_eq!(specs[0].literal_bytes, 5);
assert_eq!(specs[0].dynamic_parts, 0);
assert_eq!(specs[0].optional_parts, 0);
assert_eq!(specs[1].kind, K::Dynamic);
assert_eq!(specs[1].literal_bytes, 5);
assert_eq!(specs[1].dynamic_parts, 1);
assert_eq!(specs[1].optional_parts, 0);
let specs: Vec<_> = PathPattern::new("/maybe/a?")
.segment_specificity()
.collect();
assert_eq!(specs[1].kind, K::Dynamic);
assert_eq!(specs[1].literal_bytes, 1);
assert_eq!(specs[1].dynamic_parts, 0);
assert_eq!(specs[1].optional_parts, 1);
}
#[test]
fn prefix_matching() {
let api = PathPattern::new_prefix("/api");
assert!(api.is_match(p("/api"))); assert!(api.is_match(p("/api/"))); assert!(api.is_match(p("/api/users")));
assert!(api.is_match(p("/api/users/42")));
assert!(!api.is_match(p("/apixyz"))); assert!(!api.is_match(p("/"))); assert!(!api.is_match(p("")));
let v2 = PathPattern::new_prefix("/api/v2");
assert!(v2.is_match(p("/api/v2")));
assert!(v2.is_match(p("/api/v2/users")));
assert!(!v2.is_match(p("/api"))); assert!(!v2.is_match(p("/api/v2x")));
let any = PathPattern::new_prefix("");
assert!(any.is_match(p("/anything/at/all")));
assert!(any.is_match(p("")));
let cap = PathPattern::new_prefix("/users/{id}");
let caps = cap.captures(p("/users/42/orders/7")).unwrap();
assert_eq!(caps.get("id"), Some("42"));
}
#[test]
fn path_router_matches_longest_typed_prefix() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/api", "api");
router.insert_prefix("/api/admin", "admin");
let matched = router.match_prefix(p("/api/admin/users")).unwrap();
assert_eq!(*matched.value(), "admin");
assert_eq!(matched.matched_segment_count(), 2);
let matched = router.match_prefix(p("/api/users")).unwrap();
assert_eq!(*matched.value(), "api");
assert_eq!(matched.matched_segment_count(), 1);
assert!(router.match_prefix(p("/apix/users")).is_none());
}
#[test]
fn path_router_matches_exact_without_prefix_bleed() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/api", "api");
router.insert_prefix("/api/users", "users");
router.insert_prefix("/dir/", "dir");
let matched = router.match_exact(p("/api")).unwrap();
assert_eq!(*matched.value(), "api");
assert_eq!(matched.matched_segment_count(), 1);
let matched = router.match_exact(p("/api/users")).unwrap();
assert_eq!(*matched.value(), "users");
assert_eq!(matched.matched_segment_count(), 2);
assert!(router.match_exact(p("/api/users/42")).is_none());
assert!(router.match_exact(p("/api/")).is_none());
assert!(router.match_exact(p("/dir")).is_none());
assert_eq!(*router.match_exact(p("/dir/")).unwrap().value(), "dir");
}
#[test]
fn path_router_exact_middle_catch_all_uses_exact_captures() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/files/{*rest}/raw", "raw");
let matched = router.match_exact(p("/files/a/raw/raw")).unwrap();
assert_eq!(*matched.value(), "raw");
assert_eq!(matched.matched_segment_count(), 4);
assert_eq!(matched.captures().get("rest"), Some("a/raw"));
assert!(router.match_exact(p("/files/a/raw/raw/tail")).is_none());
}
#[test]
fn path_router_matches_dynamic_prefix_and_captures() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/users/{id}", "user");
let matched = router.match_prefix(p("/users/42/orders")).unwrap();
assert_eq!(*matched.value(), "user");
assert_eq!(matched.matched_segment_count(), 2);
assert_eq!(matched.captures().get("id"), Some("42"));
assert!(router.match_prefix(p("/users")).is_none());
}
#[test]
fn path_router_matches_optional_dynamic_segment() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/foo/{name}?/bar", "named");
router.insert_prefix("/anon/{}?/bar", "anonymous");
let matched = router.match_exact(p("/foo/john/bar")).unwrap();
assert_eq!(*matched.value(), "named");
assert_eq!(matched.matched_segment_count(), 3);
assert_eq!(matched.captures().get_non_empty("name"), Some("john"));
let matched = router.match_exact(p("/foo//bar")).unwrap();
assert_eq!(*matched.value(), "named");
assert_eq!(matched.matched_segment_count(), 3);
assert_eq!(matched.captures().get("name"), Some(""));
assert_eq!(matched.captures().get_non_empty("name"), None);
let matched = router.match_exact(p("/foo/bar")).unwrap();
assert_eq!(*matched.value(), "named");
assert_eq!(matched.matched_segment_count(), 2);
assert_eq!(matched.captures().get("name"), Some(""));
assert_eq!(matched.captures().get_non_empty("name"), None);
assert_eq!(
*router.match_exact(p("/anon/bar")).unwrap().value(),
"anonymous"
);
assert_eq!(
*router.match_exact(p("/anon//bar")).unwrap().value(),
"anonymous"
);
}
#[test]
fn path_router_prefers_literal_over_omitted_optional_segment() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/foo/{name}?/bar", "optional");
router.insert_prefix("/foo/bar", "literal");
router.insert_prefix("/root/{name}?", "optional-root");
router.insert_prefix("/root", "literal-root");
let matched = router.match_exact(p("/foo/bar")).unwrap();
assert_eq!(*matched.value(), "literal");
assert!(matched.captures().is_empty());
let matched = router.match_exact(p("/foo/john/bar")).unwrap();
assert_eq!(*matched.value(), "optional");
assert_eq!(matched.captures().get("name"), Some("john"));
let matched = router.match_exact(p("/root")).unwrap();
assert_eq!(*matched.value(), "literal-root");
assert!(matched.captures().is_empty());
let matched = router.match_exact(p("/root/john")).unwrap();
assert_eq!(*matched.value(), "optional-root");
assert_eq!(matched.captures().get("name"), Some("john"));
}
#[test]
fn path_router_drops_trailing_catch_all_from_prefix() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/api/{*rest}", "api");
let matched = router.match_prefix(p("/api")).unwrap();
assert_eq!(*matched.value(), "api");
assert_eq!(matched.matched_segment_count(), 1);
assert!(matched.captures().get("rest").is_none());
let matched = router.match_prefix(p("/api/users/42")).unwrap();
assert_eq!(*matched.value(), "api");
assert_eq!(matched.matched_segment_count(), 1);
}
#[test]
fn path_router_treats_invalid_catch_all_as_literal() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/api/{*bad name}", "literal");
assert!(router.match_prefix(p("/api/users")).is_none());
let matched = router.match_prefix(p("/api/{*bad%20name}/users")).unwrap();
assert_eq!(*matched.value(), "literal");
assert_eq!(matched.matched_segment_count(), 2);
}
#[test]
fn path_router_honors_case_insensitive_options() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix_with_opts(
"/Api/{id}",
PathMatchOptions {
ignore_ascii_case: true,
..Default::default()
},
"api",
);
let matched = router.match_prefix(p("/api/ABC/rest")).unwrap();
assert_eq!(*matched.value(), "api");
assert_eq!(matched.matched_segment_count(), 2);
assert_eq!(matched.captures().get("id"), Some("ABC"));
}
#[test]
fn path_router_empty_prefix_matches_without_consuming_segments() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("{*rest}", "root");
let matched = router.match_prefix(p("/api/users")).unwrap();
assert_eq!(*matched.value(), "root");
assert_eq!(matched.matched_segment_count(), 0);
assert!(matched.captures().get("rest").is_none());
let matched = router.match_prefix(p("/")).unwrap();
assert_eq!(*matched.value(), "root");
assert_eq!(matched.matched_segment_count(), 0);
}
#[test]
fn path_router_replaces_equivalent_prefix() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
let opts = PathMatchOptions {
ignore_ascii_case: true,
..Default::default()
};
assert_eq!(router.insert_prefix_with_opts("/Api", opts, "old"), None);
assert_eq!(
router.insert_prefix_with_opts("/api", opts, "new"),
Some("old")
);
assert_eq!(router.len(), 1);
let matched = router.match_prefix(p("/API/users")).unwrap();
assert_eq!(*matched.value(), "new");
assert_eq!(matched.matched_segment_count(), 1);
}
#[test]
fn path_router_uses_trie_precedence_without_registration_order_bias() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/{tenant}/settings", "dynamic-settings");
router.insert_prefix("/acme", "literal");
router.insert_prefix("/acme/settings/security", "literal-security");
router.insert_prefix("/acme/{section}", "literal-section");
let matched = router
.match_prefix(p("/acme/settings/security/mfa"))
.unwrap();
assert_eq!(*matched.value(), "literal-security");
assert_eq!(matched.matched_segment_count(), 3);
let matched = router.match_prefix(p("/acme/settings/profile")).unwrap();
assert_eq!(*matched.value(), "literal-section");
assert_eq!(matched.matched_segment_count(), 2);
assert_eq!(matched.captures().get("section"), Some("settings"));
let matched = router.match_prefix(p("/globex/settings/profile")).unwrap();
assert_eq!(*matched.value(), "dynamic-settings");
assert_eq!(matched.matched_segment_count(), 2);
assert_eq!(matched.captures().get("tenant"), Some("globex"));
let matched = router.match_prefix(p("/acme/billing/cards")).unwrap();
assert_eq!(*matched.value(), "literal-section");
assert_eq!(matched.matched_segment_count(), 2);
assert_eq!(matched.captures().get("section"), Some("billing"));
}
#[test]
fn path_router_middle_catch_all_reports_consumed_path_segments() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/files/{*rest}/raw", "raw");
let matched = router.match_prefix(p("/files/a/b/c/raw/tail")).unwrap();
assert_eq!(*matched.value(), "raw");
assert_eq!(matched.matched_segment_count(), 5);
assert_eq!(matched.captures().get("rest"), Some("a/b/c"));
}
#[test]
fn path_router_capture_free_dynamic_prefix_returns_empty_captures() {
use crate::uri::PathRouter;
let mut router = PathRouter::new();
router.insert_prefix("/assets/{}.css", "asset");
let matched = router.match_prefix(p("/assets/app.css/v1")).unwrap();
assert_eq!(*matched.value(), "asset");
assert_eq!(matched.matched_segment_count(), 2);
assert!(matched.captures().is_empty());
}
#[tokio::test]
async fn path_router_service_inserts_owned_captures() {
use crate::{
PathInputExt,
uri::{PathRouteCaptures, PathRouter, PathRouterError, Uri},
};
use rama_core::{
Service,
extensions::{Extensions, ExtensionsRef},
service::service_fn,
};
struct Input {
uri: Uri,
extensions: Extensions,
}
impl Input {
fn new(path: &str) -> Self {
Self {
uri: path.parse().unwrap(),
extensions: Extensions::new(),
}
}
}
impl ExtensionsRef for Input {
fn extensions(&self) -> &Extensions {
&self.extensions
}
}
impl PathInputExt for Input {
fn path_ref(&self) -> crate::uri::PathRef<'_> {
self.uri.path_ref_or_root()
}
}
let mut router = PathRouter::new();
router.insert_prefix(
"/users/{id}/files/{*rest}",
service_fn(async |input: Input| {
let captures = input
.extensions()
.get_ref::<PathRouteCaptures>()
.expect("path captures");
assert_eq!(captures.get_non_empty("id"), Some("42"));
assert_eq!(captures.get_non_empty("missing"), None);
Ok::<_, core::convert::Infallible>((
captures.get("id").map(str::to_owned),
captures.glob().map(str::to_owned),
))
}),
);
let output = router
.serve(Input::new("/users/42/files/a/b/c"))
.await
.unwrap();
assert_eq!(output, (Some("42".to_owned()), None));
let err = router.serve(Input::new("/teams/42")).await.unwrap_err();
assert!(matches!(err, PathRouterError::NotFound));
}