Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c6666c2
feat(router): add wildcard path matching support
dharshan-0 Jun 2, 2026
22d88ac
test(router): add tests for wildcard path matching
dharshan-0 Jun 2, 2026
30250f1
fix(router): fixed formatting
dharshan-0 Jun 2, 2026
d4d8c39
docs(introduction.md): document wildcard path matching
dharshan-0 Jun 3, 2026
8d0ab73
fix(router): fixed clippy errors and warnings
dharshan-0 Jun 3, 2026
7378518
fix(router): fixed formatting
dharshan-0 Jun 3, 2026
f247cc7
Merge branch 'master' into wildcard-feature
dharshan-0 Jun 3, 2026
0dc4687
test(router): add test for wildcard param
dharshan-0 Jun 3, 2026
4788c9d
fix(router): change wildcard param syntax
dharshan-0 Jun 9, 2026
22fbeed
test(router): change test for new path syntax
dharshan-0 Jun 9, 2026
62bb573
Merge branch 'master' into wildcard-feature
dharshan-0 Jun 10, 2026
669fac5
docs(introduction.md): update wildcard params to new syntax
dharshan-0 Jun 10, 2026
26cba98
Merge remote-tracking branch 'refs/remotes/origin/wildcard-feature' i…
dharshan-0 Jun 10, 2026
4559b71
docs(introduction.md): fix wildcard routing code example
dharshan-0 Jun 11, 2026
0f45536
docs(introduction.md): fix missed name fn in wildcard routing code ex…
dharshan-0 Jun 11, 2026
83a3ca0
fix(router): improve error message for invalid wildcard parameter names
dharshan-0 Jun 17, 2026
2179b38
Merge branch 'master' into wildcard-feature
ElijahAhianyo Jun 18, 2026
89fac85
Merge branch 'master' into wildcard-feature
ElijahAhianyo Jun 22, 2026
ccd7901
test(router): add test for illegal wildcard
dharshan-0 Jun 28, 2026
77ea6ca
feat(router): add precedence based routing
dharshan-0 Jun 28, 2026
66adf0c
test(router.rs): add test for precedence based routing
dharshan-0 Jun 28, 2026
e854310
docs(introduction.md): add documentation for route precedence
dharshan-0 Jun 28, 2026
8389365
Merge branch 'master' into wildcard-feature
dharshan-0 Jun 28, 2026
0e6c9ef
fix(router): made clippy happy
dharshan-0 Jun 30, 2026
44d1185
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 6, 2026
dd863b8
fix(router.rs): fixed typo in the test
dharshan-0 Jul 7, 2026
997251d
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 7, 2026
1ef194b
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 12, 2026
1ee0097
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 14, 2026
fae85a9
Merge branch 'master' into wildcard-feature
dharshan-0 Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 137 additions & 2 deletions cot/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ use tracing::debug;
use crate::error::NotFound;
use crate::request::{PathParams, Request, RequestExt, RequestHead};
use crate::response::Response;
use crate::router::path::{CaptureResult, PathMatcher, ReverseParamMap};
use crate::router::path::{CaptureResult, PathMatcher, ReverseParamMap, compare_weights};
use crate::{Error, ProjectContext, Result};

pub mod method;
Expand Down Expand Up @@ -102,7 +102,9 @@ impl Router {
/// ```
#[must_use]
pub fn with_urls<T: Into<Vec<Route>>>(urls: T) -> Self {
let urls = urls.into();
let mut urls = urls.into();
urls.sort_by(|a, b| compare_weights(&a.url.token_weights(), &b.url.token_weights()));

let mut names = HashMap::new();

for url in &urls {
Expand Down Expand Up @@ -1068,6 +1070,14 @@ mod tests {
}
}

struct SelectedHandler;

impl RequestHandler for SelectedHandler {
async fn handle(&self, _request: Request) -> Result<Response> {
Html::new("selected").into_response()
}
}

#[cfg(feature = "openapi")]
impl crate::openapi::AsApiRoute for MockHandler {
fn as_api_route(
Expand Down Expand Up @@ -1306,4 +1316,129 @@ mod tests {
fn test_request() -> Request {
TestRequestBuilder::get("/test").build()
}

#[cot::test]
async fn prefer_literal_over_wildcard_placeholder() {
let router = Router::with_urls(vec![
Route::with_handler("/foo/{*path}", MockHandler),
Route::with_handler("/foo/{param}", MockHandler),
Route::with_handler("/foo/bar", SelectedHandler),
]);
let response = router
.route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar")
.await
.unwrap();
let body = response.into_body().into_bytes().await.unwrap();
assert_eq!(body, "selected".as_bytes());
}

#[cot::test]
async fn prefer_literal_over_placeholder_wildcard() {
let router = Router::with_urls(vec![
Route::with_handler("/foo/{param}", MockHandler),
Route::with_handler("/foo/bar", SelectedHandler),
Route::with_handler("/foo/{*path}", MockHandler),
]);
let response = router
.route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar")
.await
.unwrap();
let body = response.into_body().into_bytes().await.unwrap();
assert_eq!(body, "selected".as_bytes());
}

#[cot::test]
async fn prefer_wildcard_as_last() {
let router = Router::with_urls(vec![
Route::with_handler("/foo/bar/baz", MockHandler),
Route::with_handler("/foo/{param}/baz", MockHandler),
Route::with_handler("/foo/{*path}", SelectedHandler),
]);
let response = router
.route(
TestRequestBuilder::get("/foo/bar/boo").build(),
"/foo/bar/boo",
)
.await
.unwrap();
let body = response.into_body().into_bytes().await.unwrap();
assert_eq!(body, "selected".as_bytes());
}

#[cot::test]
async fn prefer_param_over_wildcard() {
let router = Router::with_urls(vec![
Route::with_handler("/foo/{*path}", MockHandler),
Route::with_handler("/foo/{param}", SelectedHandler),
]);
let response = router
.route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar")
.await
.unwrap();
let body = response.into_body().into_bytes().await.unwrap();
assert_eq!(body, "selected".as_bytes());
}

#[cot::test]
async fn prefer_literal_over_param() {
let router = Router::with_urls(vec![
Route::with_handler("/foo/{param}", MockHandler),
Route::with_handler("/foo/bar", SelectedHandler),
]);
let response = router
.route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar")
.await
.unwrap();
let body = response.into_body().into_bytes().await.unwrap();
assert_eq!(body, "selected".as_bytes());
}

#[cot::test]
async fn prefer_more_literal_wildcard_over_less_literal_wildcard() {
let router = Router::with_urls(vec![
Route::with_handler("/foo/{*path}", MockHandler),
Route::with_handler("/foo/bar/{*path}", SelectedHandler),
]);
let response = router
.route(
TestRequestBuilder::get("/foo/bar/baz").build(),
"/foo/bar/baz",
)
.await
.unwrap();
let body = response.into_body().into_bytes().await.unwrap();
assert_eq!(body, "selected".as_bytes());
}

#[cot::test]
async fn prefer_sub_router_literal_over_wildcard() {
let sub_router = Router::with_urls(vec![
Route::with_handler("/{*path}", MockHandler),
Route::with_handler("/bar", SelectedHandler),
]);
let router = Router::with_urls(vec![Route::with_router("/foo", sub_router)]);
let response = router
.route(TestRequestBuilder::get("/foo/bar").build(), "/foo/bar")
.await
.unwrap();
let body = response.into_body().into_bytes().await.unwrap();
assert_eq!(body, "selected".as_bytes());
}

#[cot::test]
async fn prefer_more_leading_literal_route_on_tie() {
let router = Router::with_urls(vec![
Route::with_handler("/foo/{param}/baz", MockHandler),
Route::with_handler("/foo/bar/{param}", SelectedHandler),
]);
let response = router
.route(
TestRequestBuilder::get("/foo/bar/baz").build(),
"/foo/bar/baz",
)
.await
.unwrap();
let body = response.into_body().into_bytes().await.unwrap();
assert_eq!(body, "selected".as_bytes());
}
}
159 changes: 149 additions & 10 deletions cot/src/router/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,32 @@ impl PathMatcher {
}
(Some('}'), State::Param { start }) => {
let param_name = &path_pattern[start..index].trim();
assert!(
Self::is_param_name_valid(param_name),
"Invalid parameter name: `{param_name}`"
);

parts.push(PathPart::Param {
name: (*param_name).to_string(),
});

if let Some(wildcard_name) = param_name.strip_prefix('*') {
assert!(
Self::is_param_name_valid(wildcard_name),
"Invalid wildcard parameter name: `{wildcard_name}`"
);

let next_char = char_iter.peek().map(|(_, ch)| *ch).unwrap_or_default();
assert!(
Comment thread
ElijahAhianyo marked this conversation as resolved.
next_char.is_none(),
"Wildcard must be the last part of the path: `{path_pattern}`"
);

parts.push(PathPart::Wildcard {
name: wildcard_name.to_string(),
});
} else {
assert!(
Self::is_param_name_valid(param_name),
"Invalid parameter name: `{param_name}`"
);

parts.push(PathPart::Param {
name: param_name.to_string(),
});
}
state = State::Literal { start: index + 1 };
}
(Some('/') | None, State::Param { start }) => {
Expand Down Expand Up @@ -129,6 +147,13 @@ impl PathMatcher {
}
current_path = &current_path[s.len()..];
}
PathPart::Wildcard { name } => {
if current_path.is_empty() {
return None;
}
params.push(PathParam::new(name, current_path));
current_path = "";
}
PathPart::Param { name } => {
let next_slash = current_path.find('/');
let value = if let Some(next_slash) = next_slash {
Expand All @@ -154,7 +179,7 @@ impl PathMatcher {
for part in &self.parts {
match part {
PathPart::Literal(s) => result.push_str(s),
PathPart::Param { name } => {
PathPart::Param { name } | PathPart::Wildcard { name } => {
let value = params
.get(name)
.ok_or_else(|| ReverseError::MissingParam(name.clone()))?;
Expand All @@ -174,9 +199,70 @@ impl PathMatcher {
pub(super) fn param_names(&self) -> impl Iterator<Item = &str> {
self.parts.iter().filter_map(|part| match part {
PathPart::Literal(..) => None,
PathPart::Param { name } => Some(name.as_str()),
PathPart::Param { name } | PathPart::Wildcard { name } => Some(name.as_str()),
})
}

#[expect(clippy::same_item_push)]
pub(crate) fn token_weights(&self) -> Vec<u8> {
let mut weights = Vec::with_capacity(self.parts.len());
for part in &self.parts {
match part {
PathPart::Literal(s) => {
for _ in 0..s.split('/').filter(|s| !s.is_empty()).count() {
weights.push(0);
}
}
PathPart::Param { .. } => weights.push(1),
PathPart::Wildcard { .. } => weights.push(2),
}
}
weights
}
}

pub(crate) fn compare_weights(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
let max_len = std::cmp::max(a.len(), b.len());
let mut score: isize = 0;
let mut lexical_order_a = String::with_capacity(a.len());
let mut lexical_order_b = String::with_capacity(b.len());

for i in 0..max_len {
let (wa, wb) = match (a.get(i), b.get(i)) {
(None, _) => return std::cmp::Ordering::Less,
(_, None) => return std::cmp::Ordering::Greater,
(Some(&w), Some(&v)) => (w, v),
};
lexical_order_a.push(char::from_digit(u32::from(wa), 10).unwrap());
lexical_order_b.push(char::from_digit(u32::from(wb), 10).unwrap());

if wa == 2 && wb == 2 {
return lexical_order_a.cmp(&lexical_order_b);
}
if wa == 2 {
return std::cmp::Ordering::Greater;
}
if wb == 2 {
return std::cmp::Ordering::Less;
}
if wa != wb {
if wa < wb {
score += 1;
} else {
score -= 1;
}
}
}

if score != 0 {
return if score > 0 {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}

lexical_order_a.cmp(&lexical_order_b)
Comment on lines +224 to +265

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this whole logic is here? Why can't you just compare the weights vectors?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A direct comparison of the weight vectors is purely lexicographic it only considers the first segment where the routes differ and ignores everything after that.

For example:

/1 / 2 / {*rest}
/1 /{id}/ 3 / 4 / 5

The first difference is 2 (static) vs {id} (parameter), so a lexicographic comparison immediately ranks the first route ahead of the second. However, the second route is actually more specific overall.

Let me know, if Iam wrong @seqre .

}

impl Display for PathMatcher {
Expand Down Expand Up @@ -302,6 +388,7 @@ impl<'matcher, 'path> CaptureResult<'matcher, 'path> {
enum PathPart {
Literal(String),
Param { name: String },
Wildcard { name: String },
}

impl Display for PathPart {
Expand All @@ -312,6 +399,7 @@ impl Display for PathPart {
write!(f, "{s}")
}
PathPart::Param { name } => write!(f, "{{{name}}}"),
PathPart::Wildcard { name } => write!(f, "{{*{name}}}"),
}
}
}
Expand Down Expand Up @@ -543,4 +631,55 @@ mod tests {
let params = ReverseParamMap::new();
assert_eq!(path_parser.reverse(&params).unwrap(), "/café/test");
}

#[test]
fn path_parser_wildcard_root() {
let path_parser = PathMatcher::new("/{*path}");
assert_eq!(
path_parser.capture("/foo/bar"),
Some(CaptureResult::new(
vec![PathParam::new("path", "foo/bar")],
""
))
);
}

#[test]
fn path_parser_wildcard_single_segment() {
let path_parser = PathMatcher::new("/users/rand/{*path}");
assert_eq!(
path_parser.capture("/users/rand/foo"),
Some(CaptureResult::new(vec![PathParam::new("path", "foo")], ""))
);
}

#[test]
fn path_parser_wildcard_multi_segment() {
let path_parser = PathMatcher::new("/users/rand/{*path}");
assert_eq!(
path_parser.capture("/users/rand/foo/bar"),
Some(CaptureResult::new(
vec![PathParam::new("path", "foo/bar")],
""
))
);
}

#[test]
fn path_parser_wildcard_no_match() {
let path_parser = PathMatcher::new("/prefix/{*path}");
assert_eq!(path_parser.capture("/other/foo"), None);
}

#[test]
fn path_parser_wildcard_empty_not_allowed() {
let path_parser = PathMatcher::new("/users/rand/{*path}");
assert_eq!(path_parser.capture("/users/rand/"), None);
}

#[test]
#[should_panic(expected = "Wildcard must be the last part of the path: `/users/{*rest}/`")]
fn path_parser_no_path_allowed_after_wildcard() {
let _ = PathMatcher::new("/users/{*rest}/");
}
}
Loading