Skip to content

Commit e7e4cd0

Browse files
committed
docs, tests, and nesting for matchit
1 parent 54df92d commit e7e4cd0

File tree

6 files changed

+186
-20
lines changed

6 files changed

+186
-20
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
Turn off checks for compatibility with route matching syntax from 0.7.
2+
3+
This allows usage of paths starting with a colon `:` or an asterisk `*` which are otherwise prohibited.
4+
5+
# Example
6+
7+
```rust
8+
use axum::{
9+
routing::get,
10+
Router,
11+
};
12+
13+
let app = Router::<()>::new()
14+
.without_v07_checks()
15+
.route("/:colon", get(|| async {}))
16+
.route("/*asterisk", get(|| async {}));
17+
18+
// Our app now accepts
19+
// - GET /:colon
20+
// - GET /*asterisk
21+
# let _: Router = app;
22+
```
23+
24+
Adding such routes without calling this method first will panic.
25+
26+
```rust,should_panic
27+
use axum::{
28+
routing::get,
29+
Router,
30+
};
31+
32+
// This panics...
33+
let app = Router::<()>::new()
34+
.route("/:colon", get(|| async {}));
35+
```
36+
37+
# Merging
38+
39+
When two routers are merged, v0.7 checks are disabled if both of the two routers had them also disabled.
40+
41+
# Nesting
42+
43+
Each router needs to have the checks explicitly disabled. Nesting a router with the checks either enabled or disabled has no effect on the outer router.

axum/src/extract/matched_path.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,4 +349,44 @@ mod tests {
349349
let res = client.get("/foo/bar").await;
350350
assert_eq!(res.status(), StatusCode::OK);
351351
}
352+
353+
#[crate::test]
354+
async fn matching_colon() {
355+
let app = Router::new().without_v07_checks().route(
356+
"/:foo",
357+
get(|path: MatchedPath| async move { path.as_str().to_owned() }),
358+
);
359+
360+
let client = TestClient::new(app);
361+
362+
let res = client.get("/:foo").await;
363+
assert_eq!(res.status(), StatusCode::OK);
364+
assert_eq!(res.text().await, "/:foo");
365+
366+
let res = client.get("/:bar").await;
367+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
368+
369+
let res = client.get("/foo").await;
370+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
371+
}
372+
373+
#[crate::test]
374+
async fn matching_asterisk() {
375+
let app = Router::new().without_v07_checks().route(
376+
"/*foo",
377+
get(|path: MatchedPath| async move { path.as_str().to_owned() }),
378+
);
379+
380+
let client = TestClient::new(app);
381+
382+
let res = client.get("/*foo").await;
383+
assert_eq!(res.status(), StatusCode::OK);
384+
assert_eq!(res.text().await, "/*foo");
385+
386+
let res = client.get("/*bar").await;
387+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
388+
389+
let res = client.get("/foo").await;
390+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
391+
}
352392
}

axum/src/routing/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ where
154154
}
155155
}
156156

157+
#[doc = include_str!("../docs/routing/without_v07_checks.md")]
158+
pub fn without_v07_checks(self) -> Self {
159+
self.tap_inner_mut(|this| {
160+
this.path_router.without_v07_checks();
161+
})
162+
}
163+
157164
#[doc = include_str!("../docs/routing/route.md")]
158165
#[track_caller]
159166
pub fn route(self, path: &str, method_router: MethodRouter<S>) -> Self {

axum/src/routing/path_router.rs

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub(super) struct PathRouter<S, const IS_FALLBACK: bool> {
1414
routes: HashMap<RouteId, Endpoint<S>>,
1515
node: Arc<Node>,
1616
prev_route_id: RouteId,
17+
v7_checks: bool,
1718
}
1819

1920
impl<S> PathRouter<S, true>
@@ -32,26 +33,56 @@ where
3233
}
3334
}
3435

36+
fn validate_path(v7_checks: bool, path: &str) -> Result<(), &'static str> {
37+
if path.is_empty() {
38+
return Err("Paths must start with a `/`. Use \"/\" for root routes");
39+
} else if !path.starts_with('/') {
40+
return Err("Paths must start with a `/`");
41+
}
42+
43+
if v7_checks {
44+
validate_v07_paths(path)?;
45+
}
46+
47+
Ok(())
48+
}
49+
50+
fn validate_v07_paths(path: &str) -> Result<(), &'static str> {
51+
path.split('/')
52+
.find_map(|segment| {
53+
if segment.starts_with(':') {
54+
Some(Err(
55+
"Path segments must not start with `:`. For capture groups, use \
56+
`{capture}`. If you meant to literally match a segment starting with \
57+
a colon, call `without_v07_checks` on the router.",
58+
))
59+
} else if segment.starts_with('*') {
60+
Some(Err(
61+
"Path segments must not start with `*`. For wildcard capture, use \
62+
`{*wildcard}`. If you meant to literally match a segment starting with \
63+
an asterisk, call `without_v07_checks` on the router.",
64+
))
65+
} else {
66+
None
67+
}
68+
})
69+
.unwrap_or(Ok(()))
70+
}
71+
3572
impl<S, const IS_FALLBACK: bool> PathRouter<S, IS_FALLBACK>
3673
where
3774
S: Clone + Send + Sync + 'static,
3875
{
76+
pub(super) fn without_v07_checks(&mut self) {
77+
self.v7_checks = false;
78+
}
79+
3980
pub(super) fn route(
4081
&mut self,
4182
path: &str,
4283
method_router: MethodRouter<S>,
4384
) -> Result<(), Cow<'static, str>> {
44-
fn validate_path(path: &str) -> Result<(), &'static str> {
45-
if path.is_empty() {
46-
return Err("Paths must start with a `/`. Use \"/\" for root routes");
47-
} else if !path.starts_with('/') {
48-
return Err("Paths must start with a `/`");
49-
}
50-
51-
Ok(())
52-
}
53-
54-
validate_path(path)?;
85+
validate_path(self.v7_checks, path)?;
5586

5687
let endpoint = if let Some((route_id, Endpoint::MethodRouter(prev_method_router))) = self
5788
.node
@@ -97,11 +128,7 @@ where
97128
path: &str,
98129
endpoint: Endpoint<S>,
99130
) -> Result<(), Cow<'static, str>> {
100-
if path.is_empty() {
101-
return Err("Paths must start with a `/`. Use \"/\" for root routes".into());
102-
} else if !path.starts_with('/') {
103-
return Err("Paths must start with a `/`".into());
104-
}
131+
validate_path(self.v7_checks, path)?;
105132

106133
let id = self.next_route_id();
107134
self.set_node(path, id)?;
@@ -125,8 +152,12 @@ where
125152
routes,
126153
node,
127154
prev_route_id: _,
155+
v7_checks,
128156
} = other;
129157

158+
// If either of the two did not allow paths starting with `:` or `*`, do not allow them for the merged router either.
159+
self.v7_checks |= v7_checks;
160+
130161
for (id, route) in routes {
131162
let path = node
132163
.route_id_to_path
@@ -162,12 +193,14 @@ where
162193
path_to_nest_at: &str,
163194
router: PathRouter<S, IS_FALLBACK>,
164195
) -> Result<(), Cow<'static, str>> {
165-
let prefix = validate_nest_path(path_to_nest_at);
196+
let prefix = validate_nest_path(self.v7_checks, path_to_nest_at);
166197

167198
let PathRouter {
168199
routes,
169200
node,
170201
prev_route_id: _,
202+
// Ignore the configuration of the nested router
203+
v7_checks: _,
171204
} = router;
172205

173206
for (id, endpoint) in routes {
@@ -205,7 +238,7 @@ where
205238
T::Response: IntoResponse,
206239
T::Future: Send + 'static,
207240
{
208-
let path = validate_nest_path(path_to_nest_at);
241+
let path = validate_nest_path(self.v7_checks, path_to_nest_at);
209242
let prefix = path;
210243

211244
let path = if path.ends_with('/') {
@@ -255,6 +288,7 @@ where
255288
routes,
256289
node: self.node,
257290
prev_route_id: self.prev_route_id,
291+
v7_checks: self.v7_checks,
258292
}
259293
}
260294

@@ -287,6 +321,7 @@ where
287321
routes,
288322
node: self.node,
289323
prev_route_id: self.prev_route_id,
324+
v7_checks: self.v7_checks,
290325
}
291326
}
292327

@@ -313,6 +348,7 @@ where
313348
routes,
314349
node: self.node,
315350
prev_route_id: self.prev_route_id,
351+
v7_checks: self.v7_checks,
316352
}
317353
}
318354

@@ -395,6 +431,7 @@ impl<S, const IS_FALLBACK: bool> Default for PathRouter<S, IS_FALLBACK> {
395431
routes: Default::default(),
396432
node: Default::default(),
397433
prev_route_id: RouteId(0),
434+
v7_checks: true,
398435
}
399436
}
400437
}
@@ -414,6 +451,7 @@ impl<S, const IS_FALLBACK: bool> Clone for PathRouter<S, IS_FALLBACK> {
414451
routes: self.routes.clone(),
415452
node: self.node.clone(),
416453
prev_route_id: self.prev_route_id,
454+
v7_checks: self.v7_checks,
417455
}
418456
}
419457
}
@@ -460,16 +498,22 @@ impl fmt::Debug for Node {
460498
}
461499

462500
#[track_caller]
463-
fn validate_nest_path(path: &str) -> &str {
501+
fn validate_nest_path(v7_checks: bool, path: &str) -> &str {
464502
if path.is_empty() {
465503
// nesting at `""` and `"/"` should mean the same thing
466504
return "/";
467505
}
468506

469-
if path.contains('*') {
507+
if path.split('/').any(|segment| {
508+
segment.starts_with("{*") && segment.ends_with('}') && !segment.ends_with("}}")
509+
}) {
470510
panic!("Invalid route: nested routes cannot contain wildcards (*)");
471511
}
472512

513+
if v7_checks {
514+
validate_v07_paths(path).unwrap();
515+
}
516+
473517
path
474518
}
475519

axum/src/routing/tests/mod.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1057,3 +1057,19 @@ async fn impl_handler_for_into_response() {
10571057
assert_eq!(res.status(), StatusCode::CREATED);
10581058
assert_eq!(res.text().await, "thing created");
10591059
}
1060+
1061+
#[crate::test]
1062+
#[should_panic(
1063+
expected = "Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router."
1064+
)]
1065+
async fn colon_in_route() {
1066+
_ = Router::<()>::new().route("/:foo", get(|| async move {}));
1067+
}
1068+
1069+
#[crate::test]
1070+
#[should_panic(
1071+
expected = "Path segments must not start with `*`. For wildcard capture, use `{*wildcard}`. If you meant to literally match a segment starting with an asterisk, call `without_v07_checks` on the router."
1072+
)]
1073+
async fn asterisk_in_route() {
1074+
_ = Router::<()>::new().route("/*foo", get(|| async move {}));
1075+
}

axum/src/routing/tests/nest.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,3 +417,19 @@ nested_route_test!(nest_9, nest = "/a", route = "/a/", expected = "/a/a/");
417417
nested_route_test!(nest_11, nest = "/a/", route = "/", expected = "/a/");
418418
nested_route_test!(nest_12, nest = "/a/", route = "/a", expected = "/a/a");
419419
nested_route_test!(nest_13, nest = "/a/", route = "/a/", expected = "/a/a/");
420+
421+
#[crate::test]
422+
#[should_panic(
423+
expected = "Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router."
424+
)]
425+
async fn colon_in_route() {
426+
_ = Router::<()>::new().nest("/:foo", Router::new());
427+
}
428+
429+
#[crate::test]
430+
#[should_panic(
431+
expected = "Path segments must not start with `*`. For wildcard capture, use `{*wildcard}`. If you meant to literally match a segment starting with an asterisk, call `without_v07_checks` on the router."
432+
)]
433+
async fn asterisk_in_route() {
434+
_ = Router::<()>::new().nest("/*foo", Router::new());
435+
}

0 commit comments

Comments
 (0)