Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

axum: Update matchit to 0.8.6 and support capture prefixes and suffixes #3143

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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
31 changes: 30 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,31 @@ 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`.

You may mix captures that have different static prefixes or suffixes, though it is discouraged as it
might lead to surprising behavior. If multiple routes would match, the one with the longest static
prefix is used, if there are multiple with the same match, the longest matched static suffix is
chosen. For example, if a request is done to `/abcdef` here are examples of routes that would all
match. If multiple of these were defined in a single router, the topmost one would be used.

- `/abcdef`
- `/abc{x}ef`
- `/abc{x}f`
- `/abc{x}`
- `/a{x}def`
- `/a{x}`
- `/{x}def`
- `/{x}`

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 `/foo/bar/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.

- `/foo/{*wildcard}`
- `/fo{x}/bar/baz`

# 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