Skip to content

Commit 50a4be9

Browse files
davidpdrsnjplatte
andauthored
Update matchit and fix nesting inconsistencies (#1086)
* Break `Router::nest` into `nest` and `nest_service` methods * fix doc tests * update docs * fix * Only accept `Router` in `Resource::{nest, nest_collection}` * update changelog * fix docs * fix `MatchedPath` with `Router`s nested with `nest_service` * Apply suggestions from code review Co-authored-by: Jonas Platte <jplatte+git@posteo.de> * adjust docs for fallbacks * Always nest services as opaque * fix old docs reference * more tests for `MatchedPath` with nested handlers * minor clean up * use identifier captures in format strings * Apply suggestions from code review Co-authored-by: Jonas Platte <jplatte+git@posteo.de> * fix test Co-authored-by: Jonas Platte <jplatte+git@posteo.de>
1 parent 0090d00 commit 50a4be9

12 files changed

Lines changed: 233 additions & 392 deletions

File tree

axum-extra/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ and this project adheres to [Semantic Versioning].
99

1010
- **added:** Add `RouterExt::route_with_tsr` for adding routes with an
1111
additional "trailing slash redirect" route ([#1119])
12+
- **breaking:** `Resource::nest` and `Resource::nest_collection` has been
13+
removed. You can instead convert the `Resource` into a `Router` and
14+
add additional routes as necessary ([#1086])
1215
- **changed:** For methods that accept some `S: Service`, the bounds have been
1316
relaxed so the response type must implement `IntoResponse` rather than being a
1417
literal `Response`
1518
- **added:** Support chaining handlers with `HandlerCallWithExtractors::or` ([#1170])
1619
- **change:** axum-extra's MSRV is now 1.60 ([#1239])
1720

21+
[#1086]: https://github.com/tokio-rs/axum/pull/1086
1822
[#1119]: https://github.com/tokio-rs/axum/pull/1119
1923
[#1170]: https://github.com/tokio-rs/axum/pull/1170
2024
[#1239]: https://github.com/tokio-rs/axum/pull/1239

axum-extra/src/routing/resource.rs

Lines changed: 2 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,7 @@ use tower_service::Service;
3131
/// // `PUT or PATCH /users/:users_id`
3232
/// .update(|Path(user_id): Path<u64>| async {})
3333
/// // `DELETE /users/:users_id`
34-
/// .destroy(|Path(user_id): Path<u64>| async {})
35-
/// // Nest another router at the "member level"
36-
/// // This defines a route for `GET /users/:users_id/tweets`
37-
/// .nest(Router::new().route(
38-
/// "/tweets",
39-
/// get(|Path(user_id): Path<u64>| async {}),
40-
/// ))
41-
/// // Nest another router at the "collection level"
42-
/// // This defines a route for `GET /users/featured`
43-
/// .nest_collection(
44-
/// Router::new().route("/featured", get(|| async {})),
45-
/// );
34+
/// .destroy(|Path(user_id): Path<u64>| async {});
4635
///
4736
/// let app = Router::new().merge(users);
4837
/// # let _: Router<axum::body::Body> = app;
@@ -136,34 +125,6 @@ where
136125
self.route(&path, delete(handler))
137126
}
138127

139-
/// Nest another route at the "member level".
140-
///
141-
/// The routes will be nested at `/{resource_name}/:{resource_name}_id`.
142-
pub fn nest<T>(mut self, svc: T) -> Self
143-
where
144-
T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
145-
T::Response: IntoResponse,
146-
T::Future: Send + 'static,
147-
{
148-
let path = self.show_update_destroy_path();
149-
self.router = self.router.nest(&path, svc);
150-
self
151-
}
152-
153-
/// Nest another route at the "collection level".
154-
///
155-
/// The routes will be nested at `/{resource_name}`.
156-
pub fn nest_collection<T>(mut self, svc: T) -> Self
157-
where
158-
T: Service<Request<B>, Error = Infallible> + Clone + Send + 'static,
159-
T::Response: IntoResponse,
160-
T::Future: Send + 'static,
161-
{
162-
let path = self.index_create_path();
163-
self.router = self.router.nest(&path, svc);
164-
self
165-
}
166-
167128
fn index_create_path(&self) -> String {
168129
format!("/{}", self.name)
169130
}
@@ -214,14 +175,7 @@ mod tests {
214175
.show(|Path(id): Path<u64>| async move { format!("users#show id={}", id) })
215176
.edit(|Path(id): Path<u64>| async move { format!("users#edit id={}", id) })
216177
.update(|Path(id): Path<u64>| async move { format!("users#update id={}", id) })
217-
.destroy(|Path(id): Path<u64>| async move { format!("users#destroy id={}", id) })
218-
.nest(Router::new().route(
219-
"/tweets",
220-
get(|Path(id): Path<u64>| async move { format!("users#tweets id={}", id) }),
221-
))
222-
.nest_collection(
223-
Router::new().route("/featured", get(|| async move { "users#featured" })),
224-
);
178+
.destroy(|Path(id): Path<u64>| async move { format!("users#destroy id={}", id) });
225179

226180
let mut app = Router::new().merge(users);
227181

@@ -264,16 +218,6 @@ mod tests {
264218
call_route(&mut app, Method::DELETE, "/users/1").await,
265219
"users#destroy id=1"
266220
);
267-
268-
assert_eq!(
269-
call_route(&mut app, Method::GET, "/users/1/tweets").await,
270-
"users#tweets id=1"
271-
);
272-
273-
assert_eq!(
274-
call_route(&mut app, Method::GET, "/users/featured").await,
275-
"users#featured"
276-
);
277221
}
278222

279223
async fn call_route(app: &mut Router, method: Method, uri: &str) -> String {

axum/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
Use `axum::middleware::from_extractor` instead ([#1077])
1313
- **breaking:** Allow `Error: Into<Infallible>` for `Route::{layer, route_layer}` ([#924])
1414
- **breaking:** `MethodRouter` now panics on overlapping routes ([#1102])
15+
- **breaking:** Nested `Router`s will no longer delegate to the outer `Router`'s
16+
fallback. Instead you must explicitly set a fallback on the inner `Router` ([#1086])
17+
- **breaking:** The request `/foo/` no longer matches `/foo/*rest`. If you want
18+
to match `/foo/` you have to add a route specifically for that ([#1086])
19+
- **breaking:** Path params for wildcard routes no longer include the prefix
20+
`/`. e.g. `/foo.js` will match `/*filepath` with a value of `foo.js`, _not_
21+
`/foo.js` ([#1086])
22+
- **fixed:** Routes like `/foo` and `/*rest` are no longer considered
23+
overlapping. `/foo` will take priority ([#1086])
1524
- **breaking:** Remove trailing slash redirects. Previously if you added a route
1625
for `/foo`, axum would redirect calls to `/foo/` to `/foo` (or vice versa for
1726
`/foo/`). That is no longer supported and such requests will now be sent to
@@ -36,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3645

3746
[#1171]: https://github.com/tokio-rs/axum/pull/1171
3847
[#1077]: https://github.com/tokio-rs/axum/pull/1077
48+
[#1086]: https://github.com/tokio-rs/axum/pull/1086
3949
[#1088]: https://github.com/tokio-rs/axum/pull/1088
4050
[#1102]: https://github.com/tokio-rs/axum/pull/1102
4151
[#1119]: https://github.com/tokio-rs/axum/pull/1119

axum/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ http = "0.2.5"
3838
http-body = "0.4.4"
3939
hyper = { version = "0.14.14", features = ["server", "tcp", "stream"] }
4040
itoa = "1.0.1"
41-
matchit = "0.5.0"
41+
matchit = "0.6"
4242
memchr = "2.4.1"
4343
mime = "0.3.16"
4444
percent-encoding = "2.1"

axum/src/docs/routing/nest.md

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
Nest a group of routes (or a [`Service`]) at some path.
1+
Nest a [`Service`] at some path.
22

33
This allows you to break your application into smaller pieces and compose
44
them together.
@@ -64,36 +64,6 @@ let app = Router::new().nest("/:version/api", users_api);
6464
# };
6565
```
6666

67-
# Nesting services
68-
69-
`nest` also accepts any [`Service`]. This can for example be used with
70-
[`tower_http::services::ServeDir`] to serve static files from a directory:
71-
72-
```rust
73-
use axum::{
74-
Router,
75-
routing::get_service,
76-
http::StatusCode,
77-
error_handling::HandleErrorLayer,
78-
};
79-
use std::{io, convert::Infallible};
80-
use tower_http::services::ServeDir;
81-
82-
// Serves files inside the `public` directory at `GET /public/*`
83-
let serve_dir_service = get_service(ServeDir::new("public"))
84-
.handle_error(|error: io::Error| async move {
85-
(
86-
StatusCode::INTERNAL_SERVER_ERROR,
87-
format!("Unhandled internal error: {}", error),
88-
)
89-
});
90-
91-
let app = Router::new().nest("/public", serve_dir_service);
92-
# async {
93-
# axum::Server::bind(&"".parse().unwrap()).serve(app.into_make_service()).await.unwrap();
94-
# };
95-
```
96-
9767
# Differences to wildcard routes
9868

9969
Nested routes are similar to wildcard routes. The difference is that
@@ -103,18 +73,73 @@ the prefix stripped:
10373
```rust
10474
use axum::{routing::get, http::Uri, Router};
10575

76+
let nested_router = Router::new()
77+
.route("/", get(|uri: Uri| async {
78+
// `uri` will _not_ contain `/bar`
79+
}));
80+
10681
let app = Router::new()
10782
.route("/foo/*rest", get(|uri: Uri| async {
10883
// `uri` will contain `/foo`
10984
}))
110-
.nest("/bar", get(|uri: Uri| async {
111-
// `uri` will _not_ contain `/bar`
112-
}));
85+
.nest("/bar", nested_router);
11386
# async {
11487
# axum::Server::bind(&"".parse().unwrap()).serve(app.into_make_service()).await.unwrap();
11588
# };
11689
```
11790

91+
# Fallbacks
92+
93+
When nesting a router, if a request matches the prefix but the nested router doesn't have a matching
94+
route, the outer fallback will _not_ be called:
95+
96+
```rust
97+
use axum::{routing::get, http::StatusCode, handler::Handler, Router};
98+
99+
async fn fallback() -> (StatusCode, &'static str) {
100+
(StatusCode::NOT_FOUND, "Not Found")
101+
}
102+
103+
let api_routes = Router::new().nest("/users", get(|| async {}));
104+
105+
let app = Router::new()
106+
.nest("/api", api_routes)
107+
.fallback(fallback.into_service());
108+
# let _: Router = app;
109+
```
110+
111+
Here requests like `GET /api/not-found` will go into `api_routes` and then to
112+
the fallback of `api_routes` which will return an empty `404 Not Found`
113+
response. The outer fallback declared on `app` will _not_ be called.
114+
115+
Think of nested services as swallowing requests that matches the prefix and
116+
not falling back to outer router even if they don't have a matching route.
117+
118+
You can still add separate fallbacks to nested routers:
119+
120+
```rust
121+
use axum::{routing::get, http::StatusCode, handler::Handler, Json, Router};
122+
use serde_json::{json, Value};
123+
124+
async fn fallback() -> (StatusCode, &'static str) {
125+
(StatusCode::NOT_FOUND, "Not Found")
126+
}
127+
128+
async fn api_fallback() -> (StatusCode, Json<Value>) {
129+
(StatusCode::NOT_FOUND, Json(json!({ "error": "Not Found" })))
130+
}
131+
132+
let api_routes = Router::new()
133+
.nest("/users", get(|| async {}))
134+
// add dedicated fallback for requests starting with `/api`
135+
.fallback(api_fallback.into_service());
136+
137+
let app = Router::new()
138+
.nest("/api", api_routes)
139+
.fallback(fallback.into_service());
140+
# let _: Router = app;
141+
```
142+
118143
# Panics
119144

120145
- If the route overlaps with another route. See [`Router::route`]
@@ -125,3 +150,4 @@ for more details.
125150
`Router` only allows a single fallback.
126151

127152
[`OriginalUri`]: crate::extract::OriginalUri
153+
[fallbacks]: Router::fallback

axum/src/docs/routing/route.md

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ Examples:
5151
- `/:id/:repo/*tree`
5252

5353
Wildcard captures can also be extracted using [`Path`](crate::extract::Path).
54+
Note that the leading slash is not included, i.e. for the route `/foo/*rest` and
55+
the path `/foo/bar/baz` the value of `rest` will be `bar/baz`.
5456

5557
# Accepting multiple methods
5658

@@ -184,22 +186,6 @@ let app = Router::new()
184186
The static route `/foo` and the dynamic route `/:key` are not considered to
185187
overlap and `/foo` will take precedence.
186188

187-
Take care when using [`Router::nest`] as it behaves like a wildcard route.
188-
Therefore this setup panics:
189-
190-
```rust,should_panic
191-
use axum::{routing::get, Router};
192-
193-
let app = Router::new()
194-
// this is similar to `/api/*`
195-
.nest("/api", get(|| async {}))
196-
// which overlaps with this route
197-
.route("/api/users", get(|| async {}));
198-
# async {
199-
# axum::Server::bind(&"".parse().unwrap()).serve(app.into_make_service()).await.unwrap();
200-
# };
201-
```
202-
203189
Also panics if `path` is empty.
204190

205191
## Nesting

axum/src/extract/matched_path.rs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,9 @@ where
8585
mod tests {
8686
use super::*;
8787
use crate::{extract::Extension, handler::Handler, routing::get, test_helpers::*, Router};
88-
use http::Request;
88+
use http::{Request, StatusCode};
8989
use std::task::{Context, Poll};
90+
use tower::layer::layer_fn;
9091
use tower_service::Service;
9192

9293
#[derive(Clone)]
@@ -147,25 +148,37 @@ mod tests {
147148
.nest("/api", api)
148149
.nest(
149150
"/public",
150-
Router::new().route("/assets/*path", get(handler)),
151+
Router::new()
152+
.route("/assets/*path", get(handler))
153+
// have to set the middleware here since otherwise the
154+
// matched path is just `/public/*` since we're nesting
155+
// this router
156+
.layer(layer_fn(SetMatchedPathExtension)),
151157
)
152158
.nest("/foo", handler.into_service())
153-
.layer(tower::layer::layer_fn(SetMatchedPathExtension));
159+
.layer(layer_fn(SetMatchedPathExtension));
154160

155161
let client = TestClient::new(app);
156162

157-
let res = client.get("/foo").send().await;
158-
assert_eq!(res.text().await, "/:key");
159-
160163
let res = client.get("/api/users/123").send().await;
161164
assert_eq!(res.text().await, "/api/users/:id");
162165

166+
// the router nested at `/public` doesn't handle `/`
167+
let res = client.get("/public").send().await;
168+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
169+
163170
let res = client.get("/public/assets/css/style.css").send().await;
164171
assert_eq!(
165172
res.text().await,
166173
"extractor = /public/assets/*path, middleware = /public/assets/*path"
167174
);
168175

176+
let res = client.get("/foo").send().await;
177+
assert_eq!(res.text().await, "extractor = /foo, middleware = /foo");
178+
179+
let res = client.get("/foo/").send().await;
180+
assert_eq!(res.text().await, "extractor = /foo/, middleware = /foo/");
181+
169182
let res = client.get("/foo/bar/baz").send().await;
170183
assert_eq!(
171184
res.text().await,
@@ -176,4 +189,20 @@ mod tests {
176189
),
177190
);
178191
}
192+
193+
#[tokio::test]
194+
async fn nested_opaque_routers_append_to_matched_path() {
195+
let app = Router::new().nest(
196+
"/:a",
197+
Router::new().route(
198+
"/:b",
199+
get(|path: MatchedPath| async move { path.as_str().to_owned() }),
200+
),
201+
);
202+
203+
let client = TestClient::new(app);
204+
205+
let res = client.get("/foo/bar").send().await;
206+
assert_eq!(res.text().await, "/:a/:b");
207+
}
179208
}

axum/src/extract/path/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,10 +499,10 @@ mod tests {
499499
let client = TestClient::new(app);
500500

501501
let res = client.get("/foo/bar/baz").send().await;
502-
assert_eq!(res.text().await, "/bar/baz");
502+
assert_eq!(res.text().await, "bar/baz");
503503

504504
let res = client.get("/bar/baz/qux").send().await;
505-
assert_eq!(res.text().await, "/baz/qux");
505+
assert_eq!(res.text().await, "baz/qux");
506506
}
507507

508508
#[tokio::test]

0 commit comments

Comments
 (0)