Skip to content

Commit

Permalink
axum: Update matchit to 0.8.6 and support capture prefixes and suffixes
Browse files Browse the repository at this point in the history
  • Loading branch information
mladedav committed Jan 3, 2025
1 parent e0b55d7 commit 30ee2c7
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 11 deletions.
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions axum/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

# Unreleased

- **changed:** Updated `matchit` allowing for routes with captures and static prefixes and suffixes ([#3143])

[#3143]: https://github.com/tokio-rs/axum/pull/3143

# 0.8.0

## since rc.1
Expand Down
2 changes: 1 addition & 1 deletion axum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,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.8.6"
memchr = "2.4.1"
mime = "0.3.16"
percent-encoding = "2.1"
Expand Down
9 changes: 8 additions & 1 deletion axum/src/docs/routing/route.md
Original file line number Diff line number Diff line change
@@ -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`. `method_router` will commonly be a handler wrapped in a method
Expand All @@ -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/large_{id}.png`
- `/avatars/small_{id}.jpg`

Captures can be extracted using [`Path`](crate::extract::Path). See its
documentation for more details.
Expand All @@ -39,6 +43,9 @@ regular expression. You must handle that manually in your handlers.
[`MatchedPath`](crate::extract::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`.

# Wildcards

Paths can end in `/{*key}` which matches all segments and will store the segments
Expand Down
21 changes: 21 additions & 0 deletions axum/src/extract/matched_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,27 @@ mod tests {
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<B>(matched_path: MatchedPath, req: Request<B>) -> Request<B> {
assert_eq!(matched_path.as_str(), "/f{o}o/b{a}r");
req
}

let app = Router::new().nest(
"/f{o}o",
Router::new()
.route("/b{a}r", get(|| async move {}))
.layer(map_request(extract_matched_path)),
);

let client = TestClient::new(app);

let res = client.get("/foo/bar").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<B>(req: Request<B>) -> Request<B> {
Expand Down
21 changes: 21 additions & 0 deletions axum/src/extract/path/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,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<Vec<(String, String)>>| 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;
Expand Down
90 changes: 83 additions & 7 deletions axum/src/routing/strip_prefix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ fn strip_prefix(uri: &Uri, prefix: &str) -> Option<Uri> {

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();
Expand Down Expand Up @@ -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<usize> {
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)]
Expand Down Expand Up @@ -380,6 +435,27 @@ mod tests {
expected = Some("/a"),
);

test!(
param_14,
uri = "/abc",
prefix = "/a{b}c",
expected = Some("/"),
);

test!(
param_15,
uri = "/z/abc/d",
prefix = "/z/a{b}c",
expected = Some("/d"),
);

test!(
param_16,
uri = "/abc/d/e",
prefix = "/a{b}c/d/",
expected = Some("/e"),
);

#[quickcheck]
fn does_not_panic(uri_and_prefix: UriAndPrefix) -> bool {
let UriAndPrefix { uri, prefix } = uri_and_prefix;
Expand Down
60 changes: 60 additions & 0 deletions axum/src/routing/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,66 @@ async fn what_matches_wildcard() {
assert_eq!(get("/x/a/b/").await, "x");
}

#[crate::test]
async fn prefix_suffix_match() {
let app = Router::new()
.route("/{picture}.png", get(|| async { "picture" }))
.route("/hello-{name}", get(|| async { "greeting" }))
.route("/start-{regex}-end", get(|| async { "regex" }))
.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("/hello-").await, "fallback");
assert_eq!(get("/hello-world").await, "greeting");

assert_eq!(get("/start--end").await, "fallback");
assert_eq!(get("/start-regex-end").await, "regex");

assert_eq!(get("/logo.svg").await, "logo");

assert_eq!(get("/hello-.png").await, "greeting");
}

#[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");
}

#[crate::test]
async fn static_and_dynamic_paths() {
let app = Router::new()
Expand Down
38 changes: 38 additions & 0 deletions axum/src/routing/tests/nest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,44 @@ 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}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 {}));
Expand Down

0 comments on commit 30ee2c7

Please sign in to comment.