From f31dcd3c1efd7d471c001360f8babf30cbce741a Mon Sep 17 00:00:00 2001 From: Dmitry Marakasov <474217+AMDmi3@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:47:51 +0300 Subject: [PATCH] Bump matchit to 0.9.2 (#3702) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new version now supports prefix and suffix captures such as `/{file}.png`, `/avatar.{extension}`, and `/user-{id}.png`. Co-authored-by: David Mládek --- Cargo.lock | 4 +- axum/CHANGELOG.md | 2 + axum/Cargo.toml | 2 +- axum/src/docs/routing/route.md | 25 +++++- axum/src/extract/matched_path.rs | 41 +++++++++ axum/src/extract/path/mod.rs | 21 +++++ axum/src/routing/strip_prefix.rs | 69 +++++++++++++-- axum/src/routing/tests/mod.rs | 144 +++++++++++++++++++++++++++++++ axum/src/routing/tests/nest.rs | 56 ++++++++++++ examples/Cargo.lock | 4 +- 10 files changed, 355 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b5a2677f..6eae7e78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -976,9 +976,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "matchit" -version = "0.8.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "memchr" diff --git a/axum/CHANGELOG.md b/axum/CHANGELOG.md index c1c53eaf..1ed16587 100644 --- a/axum/CHANGELOG.md +++ b/axum/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **changed:** `serve` has an additional generic argument and can now work with any response body type, not just `axum::body::Body` ([#3205]) - **changed:** `Redirect` constructors now accept any `impl Into` ([#3635]) +- **changed:** Updated `matchit` allowing for routes with captures and static prefixes and suffixes ([#3702]) [#3158]: https://github.com/tokio-rs/axum/pull/3158 [#3261]: https://github.com/tokio-rs/axum/pull/3261 @@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#3601]: https://github.com/tokio-rs/axum/pull/3601 [#3489]: https://github.com/tokio-rs/axum/pull/3489 [#3586]: https://github.com/tokio-rs/axum/pull/3586 +[#3702]: https://github.com/tokio-rs/axum/pull/3702 # 0.8.9 diff --git a/axum/Cargo.toml b/axum/Cargo.toml index 059fe302..ed430d2e 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -111,7 +111,7 @@ http = "1.0.0" http-body = "1.0.0" http-body-util = "0.1.0" itoa = "1.0.5" -matchit = "=0.8.4" +matchit = "=0.9.2" memchr = "2.4.1" mime = "0.3.16" percent-encoding = "2.1" diff --git a/axum/src/docs/routing/route.md b/axum/src/docs/routing/route.md index 528f8e1a..bb4ac530 100644 --- a/axum/src/docs/routing/route.md +++ b/axum/src/docs/routing/route.md @@ -1,7 +1,7 @@ Add another route to the router. `path` is a string of path segments separated by `/`. Each segment -can be either static, a capture, or a wildcard. +can either be static, contain a capture, or be a wildcard. `method_router` is the [`MethodRouter`] that should receive the request if the path matches `path`. Usually, `method_router` will be a handler wrapped in a method @@ -24,11 +24,15 @@ Paths can contain segments like `/{key}` which matches any single segment and will store the value captured at `key`. The value captured can be zero-length except for in the invalid path `//`. +Each segment may have only one capture, but it may have static prefixes and suffixes. + Examples: - `/{key}` - `/users/{id}` - `/users/{id}/tweets` +- `/avatars/{id}.jpg` +- `/avatars/{id}.png` Captures can be extracted using [`Path`](crate::extract::Path). See its documentation for more details. @@ -38,6 +42,25 @@ regular expression. You must handle that manually in your handlers. [`MatchedPath`] can be used to extract the matched path rather than the actual path. +Captures must not be empty. For example `/a/` will not match `/a/{capture}` and +`/.png` will not match `/{image}.png`. + +You may have either capture(s) with static prefixes, capture(s) with suffixes, or a single +capture with both prefix and suffix, but these kinds of captures may not be mixed. You may mix +these with static routes and a standalone capture though. If multiple patterns match, static +segment takes precedence, then the capture with longest static prefix or suffix. + +Example valid mixed route sets: +- `/logo.png`, `/author.jpg`, `/{id}.png`, `/{id}.jpg`, `/{other_file}` (but you may not add `/old-{id}.png` or `/post-{id}`). +- `/logo.png`, `/avatar-{id}.jpg`, `/{other_file}` (but you may not add `/{id}.jpg`, `/avatar-{id}.png`). + +This is done on each level of the path and if the path matches even if due to a wildcard, that path +will be chosen. For example if one makes a request to `/foobar/baz` the first route will be used by +axum because it has better match on the leftmost differing path segment and the whole path matches. + +- `/foobar/{*wildcard}` +- `/foo{wildcard}/baz` + # Wildcards Paths can end in `/{*key}` which matches all segments and will store the segments diff --git a/axum/src/extract/matched_path.rs b/axum/src/extract/matched_path.rs index eb6ef499..e762014b 100644 --- a/axum/src/extract/matched_path.rs +++ b/axum/src/extract/matched_path.rs @@ -294,6 +294,47 @@ mod tests { assert_eq!(res.status(), StatusCode::OK); } + #[crate::test] + async fn can_extract_nested_matched_path_with_prefix_in_middleware_on_nested_router() { + async fn extract_matched_path(matched_path: MatchedPath, req: Request) -> Request { + assert_eq!(matched_path.as_str(), "/foo{one}/bar{two}"); + req + } + + let app = Router::new().nest( + "/foo{one}", + Router::new() + .route("/bar{two}", get(|| async move {})) + .layer(map_request(extract_matched_path)), + ); + + let client = TestClient::new(app); + + let res = client.get("/foo1/bar2").await; + assert_eq!(res.status(), StatusCode::OK); + } + + #[crate::test] + async fn can_extract_nested_matched_path_with_prefix_and_suffix_in_middleware_on_nested_router() + { + async fn extract_matched_path(matched_path: MatchedPath, req: Request) -> Request { + assert_eq!(matched_path.as_str(), "/foo{one}foo/bar{two}bar"); + req + } + + let app = Router::new().nest( + "/foo{one}foo", + Router::new() + .route("/bar{two}bar", get(|| async move {})) + .layer(map_request(extract_matched_path)), + ); + + let client = TestClient::new(app); + + let res = client.get("/foo1foo/bar2bar").await; + assert_eq!(res.status(), StatusCode::OK); + } + #[crate::test] async fn can_extract_nested_matched_path_in_middleware_on_nested_router_via_extension() { async fn extract_matched_path(req: Request) -> Request { diff --git a/axum/src/extract/path/mod.rs b/axum/src/extract/path/mod.rs index f6779a3f..ffc345be 100644 --- a/axum/src/extract/path/mod.rs +++ b/axum/src/extract/path/mod.rs @@ -857,6 +857,27 @@ mod tests { assert_eq!(res.status(), StatusCode::OK); } + #[crate::test] + async fn deserialize_into_vec_of_tuples_with_prefixes_and_suffixes() { + let app = Router::new().route( + "/f{o}o/b{a}r", + get(|Path(params): Path>| async move { + assert_eq!( + params, + vec![ + ("o".to_owned(), "0".to_owned()), + ("a".to_owned(), "4".to_owned()) + ] + ); + }), + ); + + let client = TestClient::new(app); + + let res = client.get("/f0o/b4r").await; + assert_eq!(res.status(), StatusCode::OK); + } + #[crate::test] async fn type_that_uses_deserialize_any() { use time::Date; diff --git a/axum/src/routing/strip_prefix.rs b/axum/src/routing/strip_prefix.rs index 3209da3b..52438e92 100644 --- a/axum/src/routing/strip_prefix.rs +++ b/axum/src/routing/strip_prefix.rs @@ -66,7 +66,7 @@ fn strip_prefix(uri: &Uri, prefix: &str) -> Option { match item { Item::Both(path_segment, prefix_segment) => { - if is_capture(prefix_segment) || path_segment == prefix_segment { + if prefix_matches(prefix_segment, path_segment) { // the prefix segment is either a param, which matches anything, or // it actually matches the path segment *matching_prefix_length.as_mut().unwrap() += path_segment.len(); @@ -148,12 +148,67 @@ where }) } -fn is_capture(segment: &str) -> bool { - segment.starts_with('{') - && segment.ends_with('}') - && !segment.starts_with("{{") - && !segment.ends_with("}}") - && !segment.starts_with("{*") +fn prefix_matches(prefix_segment: &str, path_segment: &str) -> bool { + if let Some((prefix, suffix)) = capture_prefix_suffix(prefix_segment) { + path_segment.starts_with(prefix) && path_segment.ends_with(suffix) + } else { + prefix_segment == path_segment + } +} + +/// Takes a segment and returns prefix and suffix of the path, omitting the capture. Currently, +/// matchit supports only one capture so this can be a pair. If there is no capture, `None` is +/// returned. +fn capture_prefix_suffix(segment: &str) -> Option<(&str, &str)> { + fn find_first_not_double(needle: u8, haystack: &[u8]) -> Option { + let mut possible_capture = 0; + while let Some(index) = haystack + .get(possible_capture..) + .and_then(|haystack| haystack.iter().position(|byte| byte == &needle)) + { + let index = index + possible_capture; + + if haystack.get(index + 1) == Some(&needle) { + possible_capture = index + 2; + continue; + } + + return Some(index); + } + + None + } + + let capture_start = find_first_not_double(b'{', segment.as_bytes())?; + + let Some(capture_end) = find_first_not_double(b'}', segment.as_bytes()) else { + if cfg!(debug_assertions) { + panic!( + "Segment `{segment}` is malformed. It seems to contain a capture start but no \ + capture end. This should have been rejected at application start, please file a \ + bug in axum repository." + ); + } else { + // This is very bad but let's not panic in production. This will most likely not match. + return None; + } + }; + + if capture_start > capture_end { + if cfg!(debug_assertions) { + panic!( + "Segment `{segment}` is malformed. It seems to contain a capture start after \ + capture end. This should have been rejected at application start, please file a \ + bug in axum repository." + ); + } else { + // This is very bad but let's not panic in production. This will most likely not match. + return None; + } + } + + // Slicing may panic but we found the indexes inside the string so this should be fine. + Some((&segment[..capture_start], &segment[capture_end + 1..])) } #[derive(Debug)] diff --git a/axum/src/routing/tests/mod.rs b/axum/src/routing/tests/mod.rs index aae2cb61..20077a0b 100644 --- a/axum/src/routing/tests/mod.rs +++ b/axum/src/routing/tests/mod.rs @@ -434,6 +434,114 @@ async fn what_matches_wildcard() { assert_eq!(get("/x/a/b/").await, "x"); } +#[crate::test] +async fn prefix_match() { + let app = Router::new() + .route("/{picture}.png", get(|| async { "picture" })) + .route("/{picture}.txt", get(|| async { "text" })) + .route("/logo.svg", get(|| async { "logo" })) + .fallback(|| async { "fallback" }); + + let client = TestClient::new(app); + + let get = |path| { + let f = client.get(path); + async move { f.await.text().await } + }; + + assert_eq!(get("/").await, "fallback"); + assert_eq!(get("/a/b.png").await, "fallback"); + assert_eq!(get("/a.png/").await, "fallback"); + assert_eq!(get("//a.png").await, "fallback"); + + // Empty capture is not allowed + assert_eq!(get("/.png").await, "fallback"); + assert_eq!(get("/..png").await, "picture"); + assert_eq!(get("/a.png").await, "picture"); + assert_eq!(get("/b.png").await, "picture"); + + assert_eq!(get("/.txt").await, "fallback"); + assert_eq!(get("/..txt").await, "text"); + assert_eq!(get("/a.txt").await, "text"); + assert_eq!(get("/b.txt").await, "text"); + + assert_eq!(get("/logo.svg").await, "logo"); +} + +#[crate::test] +async fn suffix_match() { + let app = Router::new() + .route("/new-{id}", get(|| async { "new" })) + .route("/old-{id}", get(|| async { "old" })) + .route("/any", get(|| async { "any" })) + .fallback(|| async { "fallback" }); + + let client = TestClient::new(app); + + let get = |path| { + let f = client.get(path); + async move { f.await.text().await } + }; + + assert_eq!(get("/").await, "fallback"); + assert_eq!(get("/a/new-1").await, "fallback"); + assert_eq!(get("/new-1/").await, "fallback"); + assert_eq!(get("//new-1/").await, "fallback"); + + // Empty capture is not allowed + assert_eq!(get("/new-").await, "fallback"); + assert_eq!(get("/new-1").await, "new"); + + assert_eq!(get("/old-").await, "fallback"); + assert_eq!(get("/old-1").await, "old"); + + assert_eq!(get("/any").await, "any"); +} + +#[crate::test] +async fn prefix_suffix_match() { + let app = Router::new() + .route("/start-{regex}-end", get(|| async { "regex" })) + .fallback(|| async { "fallback" }); + + let client = TestClient::new(app); + + let get = |path| { + let f = client.get(path); + async move { f.await.text().await } + }; + + assert_eq!(get("/").await, "fallback"); + + // Empty capture is not allowed + assert_eq!(get("/start--end").await, "fallback"); + + assert_eq!(get("/start-regex-end").await, "regex"); + assert_eq!(get("/foo/start-regex-end").await, "fallback"); +} + +#[crate::test] +async fn prefix_suffix_nested_match() { + let app = Router::new() + .route("/{a}/a", get(|| async { "a" })) + .route("/{b}/b", get(|| async { "b" })) + .route("/a{c}c/a", get(|| async { "c" })) + .route("/a{d}c/{*anything}", get(|| async { "d" })) + .fallback(|| async { "fallback" }); + + let client = TestClient::new(app); + + let get = |path| { + let f = client.get(path); + async move { f.await.text().await } + }; + + assert_eq!(get("/ac/a").await, "a"); + assert_eq!(get("/ac/b").await, "b"); + assert_eq!(get("/abc/a").await, "c"); + assert_eq!(get("/abc/b").await, "d"); +} + #[should_panic( expected = "Invalid route \"/{*wild}\": Insertion failed due to conflict with previously registered route: /{*__private__axum_fallback}" )] @@ -444,6 +552,42 @@ fn colliding_fallback_with_wildcard() { .route("/{*wild}", get(|| async { "wildcard" })); } +#[should_panic( + expected = "Invalid route \"/{wild}-bar\": Insertion failed due to conflict with previously registered route: /foo-{wild}" +)] +#[test] +fn colliding_prefix_suffix() { + _ = Router::<()>::new() + .route("/foo-{wild}", get(|| async { "wildcard" })) + .route("/foo", get(|| async { "wildcard" })) + .route("/{wild}", get(|| async { "wildcard" })) + .route("/{wild}-bar", get(|| async { "wildcard" })); +} + +#[should_panic( + expected = "Invalid route \"/foo-{wild}\": Insertion failed due to conflict with previously registered route: /foo-{wild}-bar" +)] +#[test] +fn colliding_prefixsuffix_prefix() { + _ = Router::<()>::new() + .route("/foo-{wild}-bar", get(|| async { "wildcard" })) + .route("/foo", get(|| async { "wildcard" })) + .route("/{wild}", get(|| async { "wildcard" })) + .route("/foo-{wild}", get(|| async { "wildcard" })); +} + +#[should_panic( + expected = "Invalid route \"/{wild}-bar\": Insertion failed due to conflict with previously registered route: /foo-{wild}-bar" +)] +#[test] +fn colliding_prefixsuffix_suffix() { + _ = Router::<()>::new() + .route("/foo-{wild}-bar", get(|| async { "wildcard" })) + .route("/foo", get(|| async { "wildcard" })) + .route("/{wild}", get(|| async { "wildcard" })) + .route("/{wild}-bar", get(|| async { "wildcard" })); +} + // We might want to reject this too #[crate::test] async fn colliding_wildcard_with_fallback() { diff --git a/axum/src/routing/tests/nest.rs b/axum/src/routing/tests/nest.rs index 8724c419..4cd16223 100644 --- a/axum/src/routing/tests/nest.rs +++ b/axum/src/routing/tests/nest.rs @@ -302,6 +302,62 @@ async fn nest_at_capture() { assert_eq!(res.text().await, "a=foo b=bar"); } +// Not `crate::test` because `nest_service` would fail. +#[tokio::test] +async fn nest_at_prefix_capture() { + let empty_routes = Router::new(); + let api_routes = Router::new().route( + "/{b}", + get(|Path((a, b)): Path<(String, String)>| async move { format!("a={a} b={b}") }), + ); + + let app = Router::new() + .nest("/x{a}x", api_routes) + .nest("/xax", empty_routes); + + let client = TestClient::new(app); + + let res = client.get("/xax/bar").await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().await, "a=a b=bar"); +} + +#[tokio::test] +async fn nest_service_at_prefix_capture() { + let empty_routes = Router::new(); + let api_routes = Router::new().route( + "/{b}", + get(|Path((a, b)): Path<(String, String)>| async move { format!("a={a} b={b}") }), + ); + + let app = Router::new() + .nest_service("/x{a}", api_routes) + .nest_service("/xa", empty_routes); + + let client = TestClient::new(app); + + let res = client.get("/xa/bar").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn nest_service_at_prefix_suffix_capture() { + let empty_routes = Router::new(); + let api_routes = Router::new().route( + "/{b}", + get(|Path((a, b)): Path<(String, String)>| async move { format!("a={a} b={b}") }), + ); + + let app = Router::new() + .nest_service("/x{a}x", api_routes) + .nest_service("/xax", empty_routes); + + let client = TestClient::new(app); + + let res = client.get("/xax/bar").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + #[crate::test] async fn nest_with_and_without_trailing() { let app = Router::new().nest_service("/foo", get(|| async {})); diff --git a/examples/Cargo.lock b/examples/Cargo.lock index b7292b74..a22095d9 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -2631,9 +2631,9 @@ dependencies = [ [[package]] name = "matchit" -version = "0.8.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" [[package]] name = "md-5"