Skip to content

Commit b22f6c1

Browse files
committed
axum: Update matchit to 0.8.6 and support capture prefixes and suffixes
1 parent e0b55d7 commit b22f6c1

File tree

9 files changed

+236
-11
lines changed

9 files changed

+236
-11
lines changed

Cargo.lock

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

axum/CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
# Unreleased
99

10+
- **changed:** Updated `matchit` allowing for routes with captures and static prefixes and suffixes.
11+
1012
# 0.8.0
1113

1214
## since rc.1

axum/Cargo.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ http = "1.0.0"
5757
http-body = "1.0.0"
5858
http-body-util = "0.1.0"
5959
itoa = "1.0.5"
60-
matchit = "=0.8.4"
60+
matchit = "=0.8.6"
6161
memchr = "2.4.1"
6262
mime = "0.3.16"
6363
percent-encoding = "2.1"

axum/src/docs/routing/route.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Add another route to the router.
22

33
`path` is a string of path segments separated by `/`. Each segment
4-
can be either static, a capture, or a wildcard.
4+
can either be static, contain a capture, or be a wildcard.
55

66
`method_router` is the [`MethodRouter`] that should receive the request if the
77
path matches `path`. `method_router` will commonly be a handler wrapped in a method
@@ -24,11 +24,15 @@ Paths can contain segments like `/{key}` which matches any single segment and
2424
will store the value captured at `key`. The value captured can be zero-length
2525
except for in the invalid path `//`.
2626

27+
Each segment may have only one capture, but it may have static prefixes and suffixes.
28+
2729
Examples:
2830

2931
- `/{key}`
3032
- `/users/{id}`
3133
- `/users/{id}/tweets`
34+
- `/avatars/large_{id}.png`
35+
- `/avatars/small_{id}.jpg`
3236

3337
Captures can be extracted using [`Path`](crate::extract::Path). See its
3438
documentation for more details.
@@ -39,6 +43,9 @@ regular expression. You must handle that manually in your handlers.
3943
[`MatchedPath`](crate::extract::MatchedPath) can be used to extract the matched
4044
path rather than the actual path.
4145

46+
Captures must not be empty. For example `/a/` will not match `/a/{capture}` and
47+
`/.png` will not match `/{image}.png`.
48+
4249
# Wildcards
4350

4451
Paths can end in `/{*key}` which matches all segments and will store the segments

axum/src/extract/matched_path.rs

+21
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,27 @@ mod tests {
295295
assert_eq!(res.status(), StatusCode::OK);
296296
}
297297

298+
#[crate::test]
299+
async fn can_extract_nested_matched_path_with_prefix_and_suffix_in_middleware_on_nested_router()
300+
{
301+
async fn extract_matched_path<B>(matched_path: MatchedPath, req: Request<B>) -> Request<B> {
302+
assert_eq!(matched_path.as_str(), "/f{o}o/b{a}r");
303+
req
304+
}
305+
306+
let app = Router::new().nest(
307+
"/f{o}o",
308+
Router::new()
309+
.route("/b{a}r", get(|| async move {}))
310+
.layer(map_request(extract_matched_path)),
311+
);
312+
313+
let client = TestClient::new(app);
314+
315+
let res = client.get("/foo/bar").await;
316+
assert_eq!(res.status(), StatusCode::OK);
317+
}
318+
298319
#[crate::test]
299320
async fn can_extract_nested_matched_path_in_middleware_on_nested_router_via_extension() {
300321
async fn extract_matched_path<B>(req: Request<B>) -> Request<B> {

axum/src/extract/path/mod.rs

+21
Original file line numberDiff line numberDiff line change
@@ -848,6 +848,27 @@ mod tests {
848848
assert_eq!(res.status(), StatusCode::OK);
849849
}
850850

851+
#[crate::test]
852+
async fn deserialize_into_vec_of_tuples_with_prefixes_and_suffixes() {
853+
let app = Router::new().route(
854+
"/f{o}o/b{a}r",
855+
get(|Path(params): Path<Vec<(String, String)>>| async move {
856+
assert_eq!(
857+
params,
858+
vec![
859+
("o".to_owned(), "0".to_owned()),
860+
("a".to_owned(), "4".to_owned())
861+
]
862+
);
863+
}),
864+
);
865+
866+
let client = TestClient::new(app);
867+
868+
let res = client.get("/f0o/b4r").await;
869+
assert_eq!(res.status(), StatusCode::OK);
870+
}
871+
851872
#[crate::test]
852873
async fn type_that_uses_deserialize_any() {
853874
use time::Date;

axum/src/routing/strip_prefix.rs

+83-7
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ fn strip_prefix(uri: &Uri, prefix: &str) -> Option<Uri> {
6666

6767
match item {
6868
Item::Both(path_segment, prefix_segment) => {
69-
if is_capture(prefix_segment) || path_segment == prefix_segment {
69+
if prefix_matches(prefix_segment, path_segment) {
7070
// the prefix segment is either a param, which matches anything, or
7171
// it actually matches the path segment
7272
*matching_prefix_length.as_mut().unwrap() += path_segment.len();
@@ -148,12 +148,67 @@ where
148148
})
149149
}
150150

151-
fn is_capture(segment: &str) -> bool {
152-
segment.starts_with('{')
153-
&& segment.ends_with('}')
154-
&& !segment.starts_with("{{")
155-
&& !segment.ends_with("}}")
156-
&& !segment.starts_with("{*")
151+
fn prefix_matches(prefix_segment: &str, path_segment: &str) -> bool {
152+
if let Some((prefix, suffix)) = capture_prefix_suffix(prefix_segment) {
153+
path_segment.starts_with(prefix) && path_segment.ends_with(suffix)
154+
} else {
155+
prefix_segment == path_segment
156+
}
157+
}
158+
159+
/// Takes a segment and returns prefix and suffix of the path, omitting the capture. Currently,
160+
/// matchit supports only one capture so this can be a pair. If there is no capture, `None` is
161+
/// returned.
162+
fn capture_prefix_suffix(segment: &str) -> Option<(&str, &str)> {
163+
fn find_first_not_double(needle: u8, haystack: &[u8]) -> Option<usize> {
164+
let mut possible_capture = 0;
165+
while let Some(index) = haystack
166+
.get(possible_capture..)
167+
.and_then(|haystack| haystack.iter().position(|byte| byte == &needle))
168+
{
169+
let index = index + possible_capture;
170+
171+
if haystack.get(index + 1) == Some(&needle) {
172+
possible_capture = index + 2;
173+
continue;
174+
}
175+
176+
return Some(index);
177+
}
178+
179+
None
180+
}
181+
182+
let capture_start = find_first_not_double(b'{', segment.as_bytes())?;
183+
184+
let Some(capture_end) = find_first_not_double(b'}', segment.as_bytes()) else {
185+
if cfg!(debug_assertions) {
186+
panic!(
187+
"Segment `{segment}` is malformed. It seems to contain a capture start but no \
188+
capture end. This should have been rejected at application start, please file a \
189+
bug in axum repository."
190+
);
191+
} else {
192+
// This is very bad but let's not panic in production. This will most likely not match.
193+
return None;
194+
}
195+
};
196+
197+
if capture_start > capture_end {
198+
if cfg!(debug_assertions) {
199+
panic!(
200+
"Segment `{segment}` is malformed. It seems to contain a capture start after \
201+
capture end. This should have been rejected at application start, please file a \
202+
bug in axum repository."
203+
);
204+
} else {
205+
// This is very bad but let's not panic in production. This will most likely not match.
206+
return None;
207+
}
208+
}
209+
210+
// Slicing may panic but we found the indexes inside the string so this should be fine.
211+
Some((&segment[..capture_start], &segment[capture_end + 1..]))
157212
}
158213

159214
#[derive(Debug)]
@@ -380,6 +435,27 @@ mod tests {
380435
expected = Some("/a"),
381436
);
382437

438+
test!(
439+
param_14,
440+
uri = "/abc",
441+
prefix = "/a{b}c",
442+
expected = Some("/"),
443+
);
444+
445+
test!(
446+
param_15,
447+
uri = "/z/abc/d",
448+
prefix = "/z/a{b}c",
449+
expected = Some("/d"),
450+
);
451+
452+
test!(
453+
param_16,
454+
uri = "/abc/d/e",
455+
prefix = "/a{b}c/d/",
456+
expected = Some("/e"),
457+
);
458+
383459
#[quickcheck]
384460
fn does_not_panic(uri_and_prefix: UriAndPrefix) -> bool {
385461
let UriAndPrefix { uri, prefix } = uri_and_prefix;

axum/src/routing/tests/mod.rs

+60
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,66 @@ async fn what_matches_wildcard() {
407407
assert_eq!(get("/x/a/b/").await, "x");
408408
}
409409

410+
#[crate::test]
411+
async fn prefix_suffix_match() {
412+
let app = Router::new()
413+
.route("/{picture}.png", get(|| async { "picture" }))
414+
.route("/hello-{name}", get(|| async { "greeting" }))
415+
.route("/start-{regex}-end", get(|| async { "regex" }))
416+
.route("/logo.svg", get(|| async { "logo" }))
417+
.fallback(|| async { "fallback" });
418+
419+
let client = TestClient::new(app);
420+
421+
let get = |path| {
422+
let f = client.get(path);
423+
async move { f.await.text().await }
424+
};
425+
426+
assert_eq!(get("/").await, "fallback");
427+
assert_eq!(get("/a/b.png").await, "fallback");
428+
assert_eq!(get("/a.png/").await, "fallback");
429+
assert_eq!(get("//a.png").await, "fallback");
430+
431+
// Empty capture is not allowed
432+
assert_eq!(get("/.png").await, "fallback");
433+
assert_eq!(get("/..png").await, "picture");
434+
assert_eq!(get("/a.png").await, "picture");
435+
assert_eq!(get("/b.png").await, "picture");
436+
437+
assert_eq!(get("/hello-").await, "fallback");
438+
assert_eq!(get("/hello-world").await, "greeting");
439+
440+
assert_eq!(get("/start--end").await, "fallback");
441+
assert_eq!(get("/start-regex-end").await, "regex");
442+
443+
assert_eq!(get("/logo.svg").await, "logo");
444+
445+
assert_eq!(get("/hello-.png").await, "greeting");
446+
}
447+
448+
#[crate::test]
449+
async fn prefix_suffix_nested_match() {
450+
let app = Router::new()
451+
.route("/{a}/a", get(|| async { "a" }))
452+
.route("/{b}/b", get(|| async { "b" }))
453+
.route("/a{c}c/a", get(|| async { "c" }))
454+
.route("/a{d}c/{*anything}", get(|| async { "d" }))
455+
.fallback(|| async { "fallback" });
456+
457+
let client = TestClient::new(app);
458+
459+
let get = |path| {
460+
let f = client.get(path);
461+
async move { f.await.text().await }
462+
};
463+
464+
assert_eq!(get("/ac/a").await, "a");
465+
assert_eq!(get("/ac/b").await, "b");
466+
assert_eq!(get("/abc/a").await, "c");
467+
assert_eq!(get("/abc/b").await, "d");
468+
}
469+
410470
#[crate::test]
411471
async fn static_and_dynamic_paths() {
412472
let app = Router::new()

axum/src/routing/tests/nest.rs

+38
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,44 @@ async fn nest_at_capture() {
290290
assert_eq!(res.text().await, "a=foo b=bar");
291291
}
292292

293+
// Not `crate::test` because `nest_service` would fail.
294+
#[tokio::test]
295+
async fn nest_at_prefix_capture() {
296+
let empty_routes = Router::new();
297+
let api_routes = Router::new().route(
298+
"/{b}",
299+
get(|Path((a, b)): Path<(String, String)>| async move { format!("a={a} b={b}") }),
300+
);
301+
302+
let app = Router::new()
303+
.nest("/x{a}x", api_routes)
304+
.nest("/xax", empty_routes);
305+
306+
let client = TestClient::new(app);
307+
308+
let res = client.get("/xax/bar").await;
309+
assert_eq!(res.status(), StatusCode::OK);
310+
assert_eq!(res.text().await, "a=a b=bar");
311+
}
312+
313+
#[tokio::test]
314+
async fn nest_service_at_prefix_capture() {
315+
let empty_routes = Router::new();
316+
let api_routes = Router::new().route(
317+
"/{b}",
318+
get(|Path((a, b)): Path<(String, String)>| async move { format!("a={a} b={b}") }),
319+
);
320+
321+
let app = Router::new()
322+
.nest_service("/x{a}x", api_routes)
323+
.nest_service("/xax", empty_routes);
324+
325+
let client = TestClient::new(app);
326+
327+
let res = client.get("/xax/bar").await;
328+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
329+
}
330+
293331
#[crate::test]
294332
async fn nest_with_and_without_trailing() {
295333
let app = Router::new().nest_service("/foo", get(|| async {}));

0 commit comments

Comments
 (0)