Skip to content
Open
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: 4 additions & 0 deletions .typos.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[default]
extend-ignore-re = [
"`([^`]+)`",
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is to suppress the false positive of type checker

Copy link
Copy Markdown
Contributor

@jplatte jplatte Mar 5, 2026

Choose a reason for hiding this comment

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

I think this is much too broad of an exclusion. Just update the docs sample to something else?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Content wrapped in backticks usually consists of proper nouns and inline code, so I think it’s reasonable to exclude this pattern.

]
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.

1 change: 1 addition & 0 deletions axum/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
type, not just `axum::body::Body` ([#3205])
- **changed:** Update minimum rust version to 1.80 ([#3620])
- **changed:** `Redirect` constructors now accept any `impl Into<String>` ([#3635])
- **changed:** Updated `matchit` allowing for routes with captures and static prefixes and suffixes ()

[#3158]: https://github.com/tokio-rs/axum/pull/3158
[#3261]: https://github.com/tokio-rs/axum/pull/3261
Expand Down
2 changes: 1 addition & 1 deletion axum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.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`. Usually, `method_router` will 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 @@ -38,6 +42,31 @@ 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 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}`
Comment on lines +48 to +61
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is the reason we didn't merge the 0.8.6 update. The logic is simply too surprising.

If you want to push this forward, can you please update matchit to 0.9 and change the documentation (and possibly the new tests) to align with that version? That version disallows these semi-conflicting routes in a single router.


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 @@ -294,6 +294,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 @@ -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<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
69 changes: 62 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
60 changes: 60 additions & 0 deletions axum/src/routing/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,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");
}

#[should_panic(
expected = "Invalid route \"/{*wild}\": Insertion failed due to conflict with previously registered route: /{*__private__axum_fallback}"
)]
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 @@ -302,6 +302,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
Loading
Loading