diff --git a/cot/src/router.rs b/cot/src/router.rs index 2d49c26a..16caa603 100644 --- a/cot/src/router.rs +++ b/cot/src/router.rs @@ -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; @@ -102,7 +102,9 @@ impl Router { /// ``` #[must_use] pub fn with_urls>>(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 { @@ -1068,6 +1070,14 @@ mod tests { } } + struct SelectedHandler; + + impl RequestHandler for SelectedHandler { + async fn handle(&self, _request: Request) -> Result { + Html::new("selected").into_response() + } + } + #[cfg(feature = "openapi")] impl crate::openapi::AsApiRoute for MockHandler { fn as_api_route( @@ -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()); + } } diff --git a/cot/src/router/path.rs b/cot/src/router/path.rs index cd250799..3d2af285 100644 --- a/cot/src/router/path.rs +++ b/cot/src/router/path.rs @@ -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!( + 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 }) => { @@ -129,6 +147,13 @@ impl PathMatcher { } current_path = ¤t_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 { @@ -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()))?; @@ -174,9 +199,70 @@ impl PathMatcher { pub(super) fn param_names(&self) -> impl Iterator { 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 { + 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) } impl Display for PathMatcher { @@ -302,6 +388,7 @@ impl<'matcher, 'path> CaptureResult<'matcher, 'path> { enum PathPart { Literal(String), Param { name: String }, + Wildcard { name: String }, } impl Display for PathPart { @@ -312,6 +399,7 @@ impl Display for PathPart { write!(f, "{s}") } PathPart::Param { name } => write!(f, "{{{name}}}"), + PathPart::Wildcard { name } => write!(f, "{{*{name}}}"), } } } @@ -543,4 +631,55 @@ mod tests { let params = ReverseParamMap::new(); assert_eq!(path_parser.reverse(¶ms).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}/"); + } } diff --git a/docs/introduction.md b/docs/introduction.md index c28bd48c..70a6bde2 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -181,6 +181,61 @@ fn router(&self) -> Router { Now, when you visit [`localhost:8000/hello/John/Smith/`](http://localhost:8000/hello/John), you should see `Hello, John Smith!` displayed on the page! +Cot also supports wildcard parameters to match all sub-paths within a route segment. + +```rust +# struct MyApp; +async fn wildcard_path(Path(path): Path) -> cot::Result { + Ok(Html::new(format!("Passed path: {path}"))) +} + +// inside `impl App`: +# impl App for MyApp { +fn router(&self) -> Router { + Router::with_urls([ + // ... + Route::with_handler_and_name("/wildcard/{*path}", wildcard_path, "wildcard_path"), + ]) +} +# fn name(&self) -> &str { todo!() } +# } +``` + +Prefixing the parameter name with an asterisk (`*`) designates it as a wildcard. + +In this case, it matches `/wildcard/foo`, `/wildcard/foo/bar` and so on, but it won't match `/wildcard/`. + +Also note that, wildcard param can only be used at the end of path pattern. + +### Routes precedence + +When a request path matches multiple routes, cot automatically uses the most specific route, so you do not need to worry about the order in which you define your routes. + +It looks something like this: + +**Literal segments** > **parameter segments** > **wildcard segments**. + +#### Example 1: + +If you have: + +- `/{*wildcard}` +- `/foo/{param}` +- `/foo/bar` + +For a request to `/foo/bar`, all three routes technically match. However, the 3rd route (`/foo/bar`) wins because explicit literal segments take absolute priority over dynamic parameters and wildcards. + +#### Example 2: + +Consider a scenario where two routes have an identical structural mix of literals and parameters: + +- Route A: `/foo/{param}/baz` *(Literal, Param, Literal)* +- Route B: `/foo/bar/{param}` *(Weights: Literal, Literal, Param)* + +For request `/foo/bar/baz`, it is tie, because two routes have same number of *literals* and *placeholders*, so to break the tie cot evaluates the patterns from left to right. + +At the second segment, **Route B** provides a concrete literal (`/bar`), whereas **Route A** opens up to a dynamic parameter (`{param}`). Because literals are more specific than placeholders, **Route B** wins. + ## Project structure ### App