diff --git a/axum-extra/src/extract/optional_path.rs b/axum-extra/src/extract/optional_path.rs index 9bbd9db4ba..236b9836a4 100644 --- a/axum-extra/src/extract/optional_path.rs +++ b/axum-extra/src/extract/optional_path.rs @@ -29,7 +29,7 @@ use serde::de::DeserializeOwned; /// /// let app = Router::new() /// .route("/blog", get(render_blog)) -/// .route("/blog/:page", get(render_blog)); +/// .route("/blog/{page}", get(render_blog)); /// # let app: Router = app; /// ``` #[derive(Debug)] @@ -77,7 +77,7 @@ mod tests { let app = Router::new() .route("/", get(handle)) - .route("/:num", get(handle)); + .route("/{num}", get(handle)); let client = TestClient::new(app); diff --git a/axum-extra/src/handler/mod.rs b/axum-extra/src/handler/mod.rs index 4017e149a6..2438889930 100644 --- a/axum-extra/src/handler/mod.rs +++ b/axum-extra/src/handler/mod.rs @@ -95,7 +95,7 @@ pub trait HandlerCallWithExtractors: Sized { /// } /// /// let app = Router::new().route( - /// "/users/:id", + /// "/users/{id}", /// get( /// // first try `admin`, if that rejects run `user`, finally falling back /// // to `guest` diff --git a/axum-extra/src/handler/or.rs b/axum-extra/src/handler/or.rs index 0a468db70c..e4a1dc67e7 100644 --- a/axum-extra/src/handler/or.rs +++ b/axum-extra/src/handler/or.rs @@ -134,7 +134,7 @@ mod tests { "fallback" } - let app = Router::new().route("/:id", get(one.or(two).or(three))); + let app = Router::new().route("/{id}", get(one.or(two).or(three))); let client = TestClient::new(app); diff --git a/axum-extra/src/protobuf.rs b/axum-extra/src/protobuf.rs index ac0f09524d..30e048e273 100644 --- a/axum-extra/src/protobuf.rs +++ b/axum-extra/src/protobuf.rs @@ -82,7 +82,7 @@ use prost::Message; /// # unimplemented!() /// } /// -/// let app = Router::new().route("/users/:id", get(get_user)); +/// let app = Router::new().route("/users/{id}", get(get_user)); /// # let _: Router = app; /// ``` #[derive(Debug, Clone, Copy, Default)] diff --git a/axum-extra/src/routing/mod.rs b/axum-extra/src/routing/mod.rs index a294c54721..9f9d18cb6e 100644 --- a/axum-extra/src/routing/mod.rs +++ b/axum-extra/src/routing/mod.rs @@ -371,11 +371,11 @@ mod tests { async fn tsr_with_params() { let app = Router::new() .route_with_tsr( - "/a/:a", + "/a/{a}", get(|Path(param): Path| async move { param }), ) .route_with_tsr( - "/b/:b/", + "/b/{b}/", get(|Path(param): Path| async move { param }), ); diff --git a/axum-extra/src/routing/resource.rs b/axum-extra/src/routing/resource.rs index 3c54b9c226..96c15c5533 100644 --- a/axum-extra/src/routing/resource.rs +++ b/axum-extra/src/routing/resource.rs @@ -19,13 +19,13 @@ use axum::{ /// .create(|| async {}) /// // `GET /users/new` /// .new(|| async {}) -/// // `GET /users/:users_id` +/// // `GET /users/{users_id}` /// .show(|Path(user_id): Path| async {}) -/// // `GET /users/:users_id/edit` +/// // `GET /users/{users_id}/edit` /// .edit(|Path(user_id): Path| async {}) -/// // `PUT or PATCH /users/:users_id` +/// // `PUT or PATCH /users/{users_id}` /// .update(|Path(user_id): Path| async {}) -/// // `DELETE /users/:users_id` +/// // `DELETE /users/{users_id}` /// .destroy(|Path(user_id): Path| async {}); /// /// let app = Router::new().merge(users); @@ -82,7 +82,9 @@ where self.route(&path, get(handler)) } - /// Add a handler at `GET /{resource_name}/:{resource_name}_id`. + /// Add a handler at `GET //{_id}`. + /// + /// For example when the resources are posts: `GET /post/{post_id}`. pub fn show(self, handler: H) -> Self where H: Handler, @@ -92,17 +94,21 @@ where self.route(&path, get(handler)) } - /// Add a handler at `GET /{resource_name}/:{resource_name}_id/edit`. + /// Add a handler at `GET //{_id}/edit`. + /// + /// For example when the resources are posts: `GET /post/{post_id}/edit`. pub fn edit(self, handler: H) -> Self where H: Handler, T: 'static, { - let path = format!("/{0}/:{0}_id/edit", self.name); + let path = format!("/{0}/{{{0}_id}}/edit", self.name); self.route(&path, get(handler)) } - /// Add a handler at `PUT or PATCH /resource_name/:{resource_name}_id`. + /// Add a handler at `PUT or PATCH //{_id}`. + /// + /// For example when the resources are posts: `PUT /post/{post_id}`. pub fn update(self, handler: H) -> Self where H: Handler, @@ -115,7 +121,9 @@ where ) } - /// Add a handler at `DELETE /{resource_name}/:{resource_name}_id`. + /// Add a handler at `DELETE //{_id}`. + /// + /// For example when the resources are posts: `DELETE /post/{post_id}`. pub fn destroy(self, handler: H) -> Self where H: Handler, @@ -130,7 +138,7 @@ where } fn show_update_destroy_path(&self) -> String { - format!("/{0}/:{0}_id", self.name) + format!("/{0}/{{{0}_id}}", self.name) } fn route(mut self, path: &str, method_router: MethodRouter) -> Self { diff --git a/axum-extra/src/routing/typed.rs b/axum-extra/src/routing/typed.rs index f754282369..ff2d6eed7b 100644 --- a/axum-extra/src/routing/typed.rs +++ b/axum-extra/src/routing/typed.rs @@ -19,15 +19,15 @@ use serde::Serialize; /// RouterExt, // for `Router::typed_*` /// }; /// -/// // A type safe route with `/users/:id` as its associated path. +/// // A type safe route with `/users/{id}` as its associated path. /// #[derive(TypedPath, Deserialize)] -/// #[typed_path("/users/:id")] +/// #[typed_path("/users/{id}")] /// struct UsersMember { /// id: u32, /// } /// /// // A regular handler function that takes `UsersMember` as the first argument -/// // and thus creates a typed connection between this handler and the `/users/:id` path. +/// // and thus creates a typed connection between this handler and the `/users/{id}` path. /// // /// // The `TypedPath` must be the first argument to the function. /// async fn users_show( @@ -39,7 +39,7 @@ use serde::Serialize; /// let app = Router::new() /// // Add our typed route to the router. /// // -/// // The path will be inferred to `/users/:id` since `users_show`'s +/// // The path will be inferred to `/users/{id}` since `users_show`'s /// // first argument is `UsersMember` which implements `TypedPath` /// .typed_get(users_show) /// .typed_post(users_create) @@ -75,7 +75,7 @@ use serde::Serialize; /// use axum_extra::routing::TypedPath; /// /// #[derive(TypedPath, Deserialize)] -/// #[typed_path("/users/:id")] +/// #[typed_path("/users/{id}")] /// struct UsersMember { /// id: u32, /// } @@ -100,7 +100,7 @@ use serde::Serialize; /// use axum_extra::routing::TypedPath; /// /// #[derive(TypedPath, Deserialize)] -/// #[typed_path("/users/:id/teams/:team_id")] +/// #[typed_path("/users/{id}/teams/{team_id}")] /// struct UsersMember { /// id: u32, /// } @@ -117,7 +117,7 @@ use serde::Serialize; /// struct UsersCollection; /// /// #[derive(TypedPath, Deserialize)] -/// #[typed_path("/users/:id")] +/// #[typed_path("/users/{id}")] /// struct UsersMember(u32); /// ``` /// @@ -130,7 +130,7 @@ use serde::Serialize; /// use axum_extra::routing::TypedPath; /// /// #[derive(TypedPath, Deserialize)] -/// #[typed_path("/users/:id")] +/// #[typed_path("/users/{id}")] /// struct UsersMember { /// id: String, /// } @@ -158,7 +158,7 @@ use serde::Serialize; /// }; /// /// #[derive(TypedPath, Deserialize)] -/// #[typed_path("/users/:id", rejection(UsersMemberRejection))] +/// #[typed_path("/users/{id}", rejection(UsersMemberRejection))] /// struct UsersMember { /// id: String, /// } @@ -215,7 +215,7 @@ use serde::Serialize; /// [`Deserialize`]: serde::Deserialize /// [`PathRejection`]: axum::extract::rejection::PathRejection pub trait TypedPath: std::fmt::Display { - /// The path with optional captures such as `/users/:id`. + /// The path with optional captures such as `/users/{id}`. const PATH: &'static str; /// Convert the path into a `Uri`. @@ -398,7 +398,7 @@ mod tests { use serde::Deserialize; #[derive(TypedPath, Deserialize)] - #[typed_path("/users/:id")] + #[typed_path("/users/{id}")] struct UsersShow { id: i32, } diff --git a/axum-extra/src/typed_header.rs b/axum-extra/src/typed_header.rs index ca10cf90e6..da40f2c031 100644 --- a/axum-extra/src/typed_header.rs +++ b/axum-extra/src/typed_header.rs @@ -30,7 +30,7 @@ use std::convert::Infallible; /// // ... /// } /// -/// let app = Router::new().route("/users/:user_id/team/:team_id", get(users_teams_show)); +/// let app = Router::new().route("/users/{user_id}/team/{team_id}", get(users_teams_show)); /// # let _: Router = app; /// ``` /// diff --git a/axum-macros/src/typed_path.rs b/axum-macros/src/typed_path.rs index 61db3eb9ae..baaf7f9fa2 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -386,8 +386,12 @@ fn parse_path(path: &LitStr) -> syn::Result> { .split('/') .map(|segment| { if let Some(capture) = segment - .strip_prefix(':') - .or_else(|| segment.strip_prefix('*')) + .strip_prefix('{') + .and_then(|segment| segment.strip_suffix('}')) + .and_then(|segment| { + (!segment.starts_with('{') && !segment.ends_with('}')).then_some(segment) + }) + .map(|capture| capture.strip_prefix('*').unwrap_or(capture)) { Ok(Segment::Capture(capture.to_owned(), path.span())) } else { diff --git a/axum-macros/tests/typed_path/fail/missing_field.rs b/axum-macros/tests/typed_path/fail/missing_field.rs index 2e211769ab..6991ed1643 100644 --- a/axum-macros/tests/typed_path/fail/missing_field.rs +++ b/axum-macros/tests/typed_path/fail/missing_field.rs @@ -2,7 +2,7 @@ use axum_macros::TypedPath; use serde::Deserialize; #[derive(TypedPath, Deserialize)] -#[typed_path("/users/:id")] +#[typed_path("/users/{id}")] struct MyPath {} fn main() { diff --git a/axum-macros/tests/typed_path/fail/missing_field.stderr b/axum-macros/tests/typed_path/fail/missing_field.stderr index faf2d4b681..2a85e74938 100644 --- a/axum-macros/tests/typed_path/fail/missing_field.stderr +++ b/axum-macros/tests/typed_path/fail/missing_field.stderr @@ -1,5 +1,5 @@ error[E0026]: struct `MyPath` does not have a field named `id` --> tests/typed_path/fail/missing_field.rs:5:14 | -5 | #[typed_path("/users/:id")] - | ^^^^^^^^^^^^ struct `MyPath` does not have this field +5 | #[typed_path("/users/{id}")] + | ^^^^^^^^^^^^^ struct `MyPath` does not have this field diff --git a/axum-macros/tests/typed_path/fail/not_deserialize.rs b/axum-macros/tests/typed_path/fail/not_deserialize.rs index b569186651..1d99e8f2aa 100644 --- a/axum-macros/tests/typed_path/fail/not_deserialize.rs +++ b/axum-macros/tests/typed_path/fail/not_deserialize.rs @@ -1,7 +1,7 @@ use axum_macros::TypedPath; #[derive(TypedPath)] -#[typed_path("/users/:id")] +#[typed_path("/users/{id}")] struct MyPath { id: u32, } diff --git a/axum-macros/tests/typed_path/fail/route_not_starting_with_slash_non_empty.rs b/axum-macros/tests/typed_path/fail/route_not_starting_with_slash_non_empty.rs index 33ae38d699..9d45b99964 100644 --- a/axum-macros/tests/typed_path/fail/route_not_starting_with_slash_non_empty.rs +++ b/axum-macros/tests/typed_path/fail/route_not_starting_with_slash_non_empty.rs @@ -1,7 +1,7 @@ use axum_extra::routing::TypedPath; #[derive(TypedPath)] -#[typed_path(":foo")] +#[typed_path("{foo}")] struct MyPath; fn main() {} diff --git a/axum-macros/tests/typed_path/fail/route_not_starting_with_slash_non_empty.stderr b/axum-macros/tests/typed_path/fail/route_not_starting_with_slash_non_empty.stderr index db8e40f024..f1b7b2caf3 100644 --- a/axum-macros/tests/typed_path/fail/route_not_starting_with_slash_non_empty.stderr +++ b/axum-macros/tests/typed_path/fail/route_not_starting_with_slash_non_empty.stderr @@ -1,5 +1,5 @@ error: paths must start with a `/` --> tests/typed_path/fail/route_not_starting_with_slash_non_empty.rs:4:14 | -4 | #[typed_path(":foo")] - | ^^^^^^ +4 | #[typed_path("{foo}")] + | ^^^^^^^ diff --git a/axum-macros/tests/typed_path/fail/unit_with_capture.rs b/axum-macros/tests/typed_path/fail/unit_with_capture.rs index 49979cf725..ddd544f658 100644 --- a/axum-macros/tests/typed_path/fail/unit_with_capture.rs +++ b/axum-macros/tests/typed_path/fail/unit_with_capture.rs @@ -2,7 +2,7 @@ use axum_macros::TypedPath; use serde::Deserialize; #[derive(TypedPath, Deserialize)] -#[typed_path("/users/:id")] +#[typed_path("/users/{id}")] struct MyPath; fn main() {} diff --git a/axum-macros/tests/typed_path/fail/unit_with_capture.stderr b/axum-macros/tests/typed_path/fail/unit_with_capture.stderr index d290308c8e..058ca6f974 100644 --- a/axum-macros/tests/typed_path/fail/unit_with_capture.stderr +++ b/axum-macros/tests/typed_path/fail/unit_with_capture.stderr @@ -1,5 +1,5 @@ error: Typed paths for unit structs cannot contain captures --> tests/typed_path/fail/unit_with_capture.rs:5:14 | -5 | #[typed_path("/users/:id")] - | ^^^^^^^^^^^^ +5 | #[typed_path("/users/{id}")] + | ^^^^^^^^^^^^^ diff --git a/axum-macros/tests/typed_path/pass/customize_rejection.rs b/axum-macros/tests/typed_path/pass/customize_rejection.rs index 01f11fc94c..080bc3f2d3 100644 --- a/axum-macros/tests/typed_path/pass/customize_rejection.rs +++ b/axum-macros/tests/typed_path/pass/customize_rejection.rs @@ -6,7 +6,7 @@ use axum_extra::routing::{RouterExt, TypedPath}; use serde::Deserialize; #[derive(TypedPath, Deserialize)] -#[typed_path("/:foo", rejection(MyRejection))] +#[typed_path("/{foo}", rejection(MyRejection))] struct MyPathNamed { foo: String, } @@ -16,7 +16,7 @@ struct MyPathNamed { struct MyPathUnit; #[derive(TypedPath, Deserialize)] -#[typed_path("/:foo", rejection(MyRejection))] +#[typed_path("/{foo}", rejection(MyRejection))] struct MyPathUnnamed(String); struct MyRejection; diff --git a/axum-macros/tests/typed_path/pass/into_uri.rs b/axum-macros/tests/typed_path/pass/into_uri.rs index 5276627c2f..2269b53133 100644 --- a/axum-macros/tests/typed_path/pass/into_uri.rs +++ b/axum-macros/tests/typed_path/pass/into_uri.rs @@ -3,13 +3,13 @@ use axum::http::Uri; use serde::Deserialize; #[derive(TypedPath, Deserialize)] -#[typed_path("/:id")] +#[typed_path("/{id}")] struct Named { id: u32, } #[derive(TypedPath, Deserialize)] -#[typed_path("/:id")] +#[typed_path("/{id}")] struct Unnamed(u32); #[derive(TypedPath, Deserialize)] diff --git a/axum-macros/tests/typed_path/pass/named_fields_struct.rs b/axum-macros/tests/typed_path/pass/named_fields_struct.rs index 042936fe02..5decd89c89 100644 --- a/axum-macros/tests/typed_path/pass/named_fields_struct.rs +++ b/axum-macros/tests/typed_path/pass/named_fields_struct.rs @@ -2,7 +2,7 @@ use axum_extra::routing::TypedPath; use serde::Deserialize; #[derive(TypedPath, Deserialize)] -#[typed_path("/users/:user_id/teams/:team_id")] +#[typed_path("/users/{user_id}/teams/{team_id}")] struct MyPath { user_id: u32, team_id: u32, @@ -11,7 +11,7 @@ struct MyPath { fn main() { _ = axum::Router::<()>::new().route("/", axum::routing::get(|_: MyPath| async {})); - assert_eq!(MyPath::PATH, "/users/:user_id/teams/:team_id"); + assert_eq!(MyPath::PATH, "/users/{user_id}/teams/{team_id}"); assert_eq!( format!( "{}", diff --git a/axum-macros/tests/typed_path/pass/option_result.rs b/axum-macros/tests/typed_path/pass/option_result.rs index 1bd2359010..36ea33707e 100644 --- a/axum-macros/tests/typed_path/pass/option_result.rs +++ b/axum-macros/tests/typed_path/pass/option_result.rs @@ -3,7 +3,7 @@ use axum::{extract::rejection::PathRejection, http::StatusCode}; use serde::Deserialize; #[derive(TypedPath, Deserialize)] -#[typed_path("/users/:id")] +#[typed_path("/users/{id}")] struct UsersShow { id: String, } diff --git a/axum-macros/tests/typed_path/pass/tuple_struct.rs b/axum-macros/tests/typed_path/pass/tuple_struct.rs index 3ee8370402..0c85bae5ec 100644 --- a/axum-macros/tests/typed_path/pass/tuple_struct.rs +++ b/axum-macros/tests/typed_path/pass/tuple_struct.rs @@ -4,12 +4,12 @@ use serde::Deserialize; pub type Result = std::result::Result; #[derive(TypedPath, Deserialize)] -#[typed_path("/users/:user_id/teams/:team_id")] +#[typed_path("/users/{user_id}/teams/{team_id}")] struct MyPath(u32, u32); fn main() { _ = axum::Router::<()>::new().route("/", axum::routing::get(|_: MyPath| async {})); - assert_eq!(MyPath::PATH, "/users/:user_id/teams/:team_id"); + assert_eq!(MyPath::PATH, "/users/{user_id}/teams/{team_id}"); assert_eq!(format!("{}", MyPath(1, 2)), "/users/1/teams/2"); } diff --git a/axum-macros/tests/typed_path/pass/url_encoding.rs b/axum-macros/tests/typed_path/pass/url_encoding.rs index db1c3700ab..5ac412e447 100644 --- a/axum-macros/tests/typed_path/pass/url_encoding.rs +++ b/axum-macros/tests/typed_path/pass/url_encoding.rs @@ -2,13 +2,13 @@ use axum_extra::routing::TypedPath; use serde::Deserialize; #[derive(TypedPath, Deserialize)] -#[typed_path("/:param")] +#[typed_path("/{param}")] struct Named { param: String, } #[derive(TypedPath, Deserialize)] -#[typed_path("/:param")] +#[typed_path("/{param}")] struct Unnamed(String); fn main() { diff --git a/axum-macros/tests/typed_path/pass/wildcards.rs b/axum-macros/tests/typed_path/pass/wildcards.rs index 98aa5f5153..51f0c3f540 100644 --- a/axum-macros/tests/typed_path/pass/wildcards.rs +++ b/axum-macros/tests/typed_path/pass/wildcards.rs @@ -2,7 +2,7 @@ use axum_extra::routing::{RouterExt, TypedPath}; use serde::Deserialize; #[derive(TypedPath, Deserialize)] -#[typed_path("/*rest")] +#[typed_path("/{*rest}")] struct MyPath { rest: String, } diff --git a/axum/Cargo.toml b/axum/Cargo.toml index dd2a59da48..c81d9aacc7 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -49,7 +49,7 @@ http = "1.0.0" http-body = "1.0.0" http-body-util = "0.1.0" itoa = "1.0.5" -matchit = "0.7" +matchit = "=0.8.0" memchr = "2.4.1" mime = "0.3.16" percent-encoding = "2.1" diff --git a/axum/src/docs/extract.md b/axum/src/docs/extract.md index 4965428959..807d7895e7 100644 --- a/axum/src/docs/extract.md +++ b/axum/src/docs/extract.md @@ -93,7 +93,7 @@ async fn extension(Extension(state): Extension) {} struct State { /* ... */ } let app = Router::new() - .route("/path/:user_id", post(path)) + .route("/path/{user_id}", post(path)) .route("/query", post(query)) .route("/string", post(string)) .route("/bytes", post(bytes)) @@ -116,7 +116,7 @@ use axum::{ use uuid::Uuid; use serde::Deserialize; -let app = Router::new().route("/users/:id/things", get(get_user_things)); +let app = Router::new().route("/users/{id}/things", get(get_user_things)); #[derive(Deserialize)] struct Pagination { diff --git a/axum/src/docs/routing/merge.md b/axum/src/docs/routing/merge.md index e8f668712e..ddea660879 100644 --- a/axum/src/docs/routing/merge.md +++ b/axum/src/docs/routing/merge.md @@ -16,7 +16,7 @@ use axum::{ // define some routes separately let user_routes = Router::new() .route("/users", get(users_list)) - .route("/users/:id", get(users_show)); + .route("/users/{id}", get(users_show)); let team_routes = Router::new() .route("/teams", get(teams_list)); @@ -30,7 +30,7 @@ let app = Router::new() // Our app now accepts // - GET /users -// - GET /users/:id +// - GET /users/{id} // - GET /teams # let _: Router = app; ``` diff --git a/axum/src/docs/routing/nest.md b/axum/src/docs/routing/nest.md index c3f7308fdb..8e315f5474 100644 --- a/axum/src/docs/routing/nest.md +++ b/axum/src/docs/routing/nest.md @@ -11,7 +11,7 @@ use axum::{ Router, }; -let user_routes = Router::new().route("/:id", get(|| async {})); +let user_routes = Router::new().route("/{id}", get(|| async {})); let team_routes = Router::new().route("/", post(|| async {})); @@ -22,7 +22,7 @@ let api_routes = Router::new() let app = Router::new().nest("/api", api_routes); // Our app now accepts -// - GET /api/users/:id +// - GET /api/users/{id} // - POST /api/teams # let _: Router = app; ``` @@ -54,9 +54,9 @@ async fn users_get(Path(params): Path>) { let id = params.get("id"); } -let users_api = Router::new().route("/users/:id", get(users_get)); +let users_api = Router::new().route("/users/{id}", get(users_get)); -let app = Router::new().nest("/:version/api", users_api); +let app = Router::new().nest("/{version}/api", users_api); # let _: Router = app; ``` @@ -75,7 +75,7 @@ let nested_router = Router::new() })); let app = Router::new() - .route("/foo/*rest", get(|uri: Uri| async { + .route("/foo/{*rest}", get(|uri: Uri| async { // `uri` will contain `/foo` })) .nest("/bar", nested_router); diff --git a/axum/src/docs/routing/route.md b/axum/src/docs/routing/route.md index fa55f4faa7..0d9853341f 100644 --- a/axum/src/docs/routing/route.md +++ b/axum/src/docs/routing/route.md @@ -21,15 +21,15 @@ be called. # Captures -Paths can contain segments like `/:key` which matches any single segment and +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 `//`. Examples: -- `/:key` -- `/users/:id` -- `/users/:id/tweets` +- `/{key}` +- `/users/{id}` +- `/users/{id}/tweets` Captures can be extracted using [`Path`](crate::extract::Path). See its documentation for more details. @@ -42,19 +42,19 @@ path rather than the actual path. # Wildcards -Paths can end in `/*key` which matches all segments and will store the segments +Paths can end in `/{*key}` which matches all segments and will store the segments captured at `key`. Examples: -- `/*key` -- `/assets/*path` -- `/:id/:repo/*tree` +- `/{*key}` +- `/assets/{*path}` +- `/{id}/{repo}/{*tree}` -Note that `/*key` doesn't match empty segments. Thus: +Note that `/{*key}` doesn't match empty segments. Thus: -- `/*key` doesn't match `/` but does match `/a`, `/a/`, etc. -- `/x/*key` doesn't match `/x` or `/x/` but does match `/x/a`, `/x/a/`, etc. +- `/{*key}` doesn't match `/` but does match `/a`, `/a/`, etc. +- `/x/{*key}` doesn't match `/x` or `/x/` but does match `/x/a`, `/x/a/`, etc. Wildcard captures can also be extracted using [`Path`](crate::extract::Path): @@ -65,14 +65,14 @@ use axum::{ extract::Path, }; -let app: Router = Router::new().route("/*key", get(handler)); +let app: Router = Router::new().route("/{*key}", get(handler)); async fn handler(Path(path): Path) -> String { path } ``` -Note that the leading slash is not included, i.e. for the route `/foo/*rest` and +Note that the leading slash is not included, i.e. for the route `/foo/{*rest}` and the path `/foo/bar/baz` the value of `rest` will be `bar/baz`. # Accepting multiple methods @@ -121,9 +121,9 @@ use axum::{Router, routing::{get, delete}, extract::Path}; let app = Router::new() .route("/", get(root)) .route("/users", get(list_users).post(create_user)) - .route("/users/:id", get(show_user)) - .route("/api/:version/users/:id/action", delete(do_users_action)) - .route("/assets/*path", get(serve_asset)); + .route("/users/{id}", get(show_user)) + .route("/api/{version}/users/{id}/action", delete(do_users_action)) + .route("/assets/{*path}", get(serve_asset)); async fn root() {} @@ -152,7 +152,7 @@ let app = Router::new() # let _: Router = app; ``` -The static route `/foo` and the dynamic route `/:key` are not considered to +The static route `/foo` and the dynamic route `/{key}` are not considered to overlap and `/foo` will take precedence. Also panics if `path` is empty. diff --git a/axum/src/docs/routing/without_v07_checks.md b/axum/src/docs/routing/without_v07_checks.md new file mode 100644 index 0000000000..1eb377e62b --- /dev/null +++ b/axum/src/docs/routing/without_v07_checks.md @@ -0,0 +1,43 @@ +Turn off checks for compatibility with route matching syntax from 0.7. + +This allows usage of paths starting with a colon `:` or an asterisk `*` which are otherwise prohibited. + +# Example + +```rust +use axum::{ + routing::get, + Router, +}; + +let app = Router::<()>::new() + .without_v07_checks() + .route("/:colon", get(|| async {})) + .route("/*asterisk", get(|| async {})); + +// Our app now accepts +// - GET /:colon +// - GET /*asterisk +# let _: Router = app; +``` + +Adding such routes without calling this method first will panic. + +```rust,should_panic +use axum::{ + routing::get, + Router, +}; + +// This panics... +let app = Router::<()>::new() + .route("/:colon", get(|| async {})); +``` + +# Merging + +When two routers are merged, v0.7 checks are disabled if both of the two routers had them also disabled. + +# Nesting + +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. diff --git a/axum/src/extract/matched_path.rs b/axum/src/extract/matched_path.rs index 6ac0397c05..b7e0791cb6 100644 --- a/axum/src/extract/matched_path.rs +++ b/axum/src/extract/matched_path.rs @@ -14,10 +14,10 @@ use std::{collections::HashMap, sync::Arc}; /// }; /// /// let app = Router::new().route( -/// "/users/:id", +/// "/users/{id}", /// get(|path: MatchedPath| async move { /// let path = path.as_str(); -/// // `path` will be "/users/:id" +/// // `path` will be "/users/{id}" /// }) /// ); /// # let _: Router = app; @@ -39,7 +39,7 @@ use std::{collections::HashMap, sync::Arc}; /// use tower_http::trace::TraceLayer; /// /// let app = Router::new() -/// .route("/users/:id", get(|| async { /* ... */ })) +/// .route("/users/{id}", get(|| async { /* ... */ })) /// .layer( /// TraceLayer::new_for_http().make_span_with(|req: &Request<_>| { /// let path = if let Some(path) = req.extensions().get::() { @@ -143,22 +143,22 @@ mod tests { #[crate::test] async fn extracting_on_handler() { let app = Router::new().route( - "/:a", + "/{a}", get(|path: MatchedPath| async move { path.as_str().to_owned() }), ); let client = TestClient::new(app); let res = client.get("/foo").await; - assert_eq!(res.text().await, "/:a"); + assert_eq!(res.text().await, "/{a}"); } #[crate::test] async fn extracting_on_handler_in_nested_router() { let app = Router::new().nest( - "/:a", + "/{a}", Router::new().route( - "/:b", + "/{b}", get(|path: MatchedPath| async move { path.as_str().to_owned() }), ), ); @@ -166,17 +166,17 @@ mod tests { let client = TestClient::new(app); let res = client.get("/foo/bar").await; - assert_eq!(res.text().await, "/:a/:b"); + assert_eq!(res.text().await, "/{a}/{b}"); } #[crate::test] async fn extracting_on_handler_in_deeply_nested_router() { let app = Router::new().nest( - "/:a", + "/{a}", Router::new().nest( - "/:b", + "/{b}", Router::new().route( - "/:c", + "/{c}", get(|path: MatchedPath| async move { path.as_str().to_owned() }), ), ), @@ -185,7 +185,7 @@ mod tests { let client = TestClient::new(app); let res = client.get("/foo/bar/baz").await; - assert_eq!(res.text().await, "/:a/:b/:c"); + assert_eq!(res.text().await, "/{a}/{b}/{c}"); } #[crate::test] @@ -199,7 +199,7 @@ mod tests { } let app = Router::new() - .nest_service("/:a", Router::new().route("/:b", get(|| async move {}))) + .nest_service("/{a}", Router::new().route("/{b}", get(|| async move {}))) .layer(map_request(extract_matched_path)); let client = TestClient::new(app); @@ -214,12 +214,12 @@ mod tests { matched_path: Option, req: Request, ) -> Request { - assert_eq!(matched_path.unwrap().as_str(), "/:a/:b"); + assert_eq!(matched_path.unwrap().as_str(), "/{a}/{b}"); req } let app = Router::new() - .nest("/:a", Router::new().route("/:b", get(|| async move {}))) + .nest("/{a}", Router::new().route("/{b}", get(|| async move {}))) .layer(map_request(extract_matched_path)); let client = TestClient::new(app); @@ -236,7 +236,7 @@ mod tests { } let app = Router::new() - .nest_service("/:a", Router::new().route("/:b", get(|| async move {}))) + .nest_service("/{a}", Router::new().route("/{b}", get(|| async move {}))) .layer(map_request(assert_no_matched_path)); let client = TestClient::new(app); @@ -253,7 +253,7 @@ mod tests { } let app = Router::new() - .nest("/:a", Router::new().route("/:b", get(|| async move {}))) + .nest("/{a}", Router::new().route("/{b}", get(|| async move {}))) .layer(map_request(assert_matched_path)); let client = TestClient::new(app); @@ -265,14 +265,14 @@ mod tests { #[crate::test] async fn can_extract_nested_matched_path_in_middleware_on_nested_router() { async fn extract_matched_path(matched_path: MatchedPath, req: Request) -> Request { - assert_eq!(matched_path.as_str(), "/:a/:b"); + assert_eq!(matched_path.as_str(), "/{a}/{b}"); req } let app = Router::new().nest( - "/:a", + "/{a}", Router::new() - .route("/:b", get(|| async move {})) + .route("/{b}", get(|| async move {})) .layer(map_request(extract_matched_path)), ); @@ -286,14 +286,14 @@ mod tests { async fn can_extract_nested_matched_path_in_middleware_on_nested_router_via_extension() { async fn extract_matched_path(req: Request) -> Request { let matched_path = req.extensions().get::().unwrap(); - assert_eq!(matched_path.as_str(), "/:a/:b"); + assert_eq!(matched_path.as_str(), "/{a}/{b}"); req } let app = Router::new().nest( - "/:a", + "/{a}", Router::new() - .route("/:b", get(|| async move {})) + .route("/{b}", get(|| async move {})) .layer(map_request(extract_matched_path)), ); @@ -309,7 +309,7 @@ mod tests { assert!(path.is_none()); } - let app = Router::new().nest_service("/:a", handler.into_service()); + let app = Router::new().nest_service("/{a}", handler.into_service()); let client = TestClient::new(app); @@ -323,7 +323,7 @@ mod tests { use tower::ServiceExt; let app = Router::new().route( - "/*path", + "/{*path}", any(|req: Request| { Router::new() .nest("/", Router::new().route("/foo", get(|| async {}))) @@ -351,4 +351,65 @@ mod tests { let res = client.get("/foo/bar").await; assert_eq!(res.status(), StatusCode::OK); } + + #[crate::test] + async fn matching_colon() { + let app = Router::new().without_v07_checks().route( + "/:foo", + get(|path: MatchedPath| async move { path.as_str().to_owned() }), + ); + + let client = TestClient::new(app); + + let res = client.get("/:foo").await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().await, "/:foo"); + + let res = client.get("/:bar").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + + let res = client.get("/foo").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } + + #[crate::test] + async fn matching_asterisk() { + let app = Router::new().without_v07_checks().route( + "/*foo", + get(|path: MatchedPath| async move { path.as_str().to_owned() }), + ); + + let client = TestClient::new(app); + + let res = client.get("/*foo").await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().await, "/*foo"); + + let res = client.get("/*bar").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + + let res = client.get("/foo").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } + + #[crate::test] + async fn matching_braces() { + let app = Router::new().route( + // Double braces are interpreted by matchit as single literal brace + "/{{foo}}", + get(|path: MatchedPath| async move { path.as_str().to_owned() }), + ); + + let client = TestClient::new(app); + + let res = client.get("/{foo}").await; + assert_eq!(res.status(), StatusCode::OK); + assert_eq!(res.text().await, "/{{foo}}"); + + let res = client.get("/foo").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + + let res = client.get("/{{foo}}").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); + } } diff --git a/axum/src/extract/nested_path.rs b/axum/src/extract/nested_path.rs index 72712a4e9a..c326da28e7 100644 --- a/axum/src/extract/nested_path.rs +++ b/axum/src/extract/nested_path.rs @@ -262,4 +262,22 @@ mod tests { let res = client.get("/api/users").await; assert_eq!(res.status(), StatusCode::OK); } + + #[crate::test] + async fn nesting_with_braces() { + let api = Router::new().route( + "/users", + get(|nested_path: NestedPath| { + assert_eq!(nested_path.as_str(), "/{{api}}"); + async {} + }), + ); + + let app = Router::new().nest("/{{api}}", api); + + let client = TestClient::new(app); + + let res = client.get("/{api}/users").await; + assert_eq!(res.status(), StatusCode::OK); + } } diff --git a/axum/src/extract/path/de.rs b/axum/src/extract/path/de.rs index 8ba8a431e9..0b0ab53356 100644 --- a/axum/src/extract/path/de.rs +++ b/axum/src/extract/path/de.rs @@ -1,5 +1,4 @@ use super::{ErrorKind, PathDeserializationError}; -use crate::util::PercentDecodedStr; use serde::{ de::{self, DeserializeSeed, EnumAccess, Error, MapAccess, SeqAccess, VariantAccess, Visitor}, forward_to_deserialize_any, Deserializer, @@ -33,7 +32,7 @@ macro_rules! parse_single_value { let value = self.url_params[0].1.parse().map_err(|_| { PathDeserializationError::new(ErrorKind::ParseError { - value: self.url_params[0].1.as_str().to_owned(), + value: self.url_params[0].1.as_ref().to_owned(), expected_type: $ty, }) })?; @@ -43,12 +42,12 @@ macro_rules! parse_single_value { } pub(crate) struct PathDeserializer<'de> { - url_params: &'de [(Arc, PercentDecodedStr)], + url_params: &'de [(Arc, Arc)], } impl<'de> PathDeserializer<'de> { #[inline] - pub(crate) fn new(url_params: &'de [(Arc, PercentDecodedStr)]) -> Self { + pub(crate) fn new(url_params: &'de [(Arc, Arc)]) -> Self { PathDeserializer { url_params } } } @@ -216,9 +215,9 @@ impl<'de> Deserializer<'de> for PathDeserializer<'de> { } struct MapDeserializer<'de> { - params: &'de [(Arc, PercentDecodedStr)], + params: &'de [(Arc, Arc)], key: Option>, - value: Option<&'de PercentDecodedStr>, + value: Option<&'de Arc>, } impl<'de> MapAccess<'de> for MapDeserializer<'de> { @@ -300,19 +299,19 @@ macro_rules! parse_value { let kind = match key { KeyOrIdx::Key(key) => ErrorKind::ParseErrorAtKey { key: key.to_owned(), - value: self.value.as_str().to_owned(), + value: self.value.as_ref().to_owned(), expected_type: $ty, }, KeyOrIdx::Idx { idx: index, key: _ } => ErrorKind::ParseErrorAtIndex { index, - value: self.value.as_str().to_owned(), + value: self.value.as_ref().to_owned(), expected_type: $ty, }, }; PathDeserializationError::new(kind) } else { PathDeserializationError::new(ErrorKind::ParseError { - value: self.value.as_str().to_owned(), + value: self.value.as_ref().to_owned(), expected_type: $ty, }) } @@ -325,7 +324,7 @@ macro_rules! parse_value { #[derive(Debug)] struct ValueDeserializer<'de> { key: Option>, - value: &'de PercentDecodedStr, + value: &'de Arc, } impl<'de> Deserializer<'de> for ValueDeserializer<'de> { @@ -414,7 +413,7 @@ impl<'de> Deserializer<'de> for ValueDeserializer<'de> { { struct PairDeserializer<'de> { key: Option>, - value: Option<&'de PercentDecodedStr>, + value: Option<&'de Arc>, } impl<'de> SeqAccess<'de> for PairDeserializer<'de> { @@ -576,7 +575,7 @@ impl<'de> VariantAccess<'de> for UnitVariant { } struct SeqDeserializer<'de> { - params: &'de [(Arc, PercentDecodedStr)], + params: &'de [(Arc, Arc)], idx: usize, } @@ -629,7 +628,7 @@ mod tests { a: i32, } - fn create_url_params(values: I) -> Vec<(Arc, PercentDecodedStr)> + fn create_url_params(values: I) -> Vec<(Arc, Arc)> where I: IntoIterator, K: AsRef, @@ -637,7 +636,7 @@ mod tests { { values .into_iter() - .map(|(k, v)| (Arc::from(k.as_ref()), PercentDecodedStr::new(v).unwrap())) + .map(|(k, v)| (Arc::from(k.as_ref()), Arc::from(v.as_ref()))) .collect() } @@ -669,9 +668,10 @@ mod tests { check_single_value!(f32, "123", 123.0); check_single_value!(f64, "123", 123.0); check_single_value!(String, "abc", "abc"); - check_single_value!(String, "one%20two", "one two"); + check_single_value!(String, "one%20two", "one%20two"); + check_single_value!(String, "one two", "one two"); check_single_value!(&str, "abc", "abc"); - check_single_value!(&str, "one%20two", "one two"); + check_single_value!(&str, "one two", "one two"); check_single_value!(char, "a", 'a'); let url_params = create_url_params(vec![("a", "B")]); diff --git a/axum/src/extract/path/mod.rs b/axum/src/extract/path/mod.rs index 330e270ebc..df3d20443e 100644 --- a/axum/src/extract/path/mod.rs +++ b/axum/src/extract/path/mod.rs @@ -6,7 +6,6 @@ mod de; use crate::{ extract::{rejection::*, FromRequestParts}, routing::url_params::UrlParams, - util::PercentDecodedStr, }; use async_trait::async_trait; use axum_core::response::{IntoResponse, Response}; @@ -44,7 +43,7 @@ use std::{fmt, sync::Arc}; /// // ... /// } /// -/// let app = Router::new().route("/users/:user_id/team/:team_id", get(users_teams_show)); +/// let app = Router::new().route("/users/{user_id}/team/{team_id}", get(users_teams_show)); /// # let _: Router = app; /// ``` /// @@ -62,7 +61,7 @@ use std::{fmt, sync::Arc}; /// // ... /// } /// -/// let app = Router::new().route("/users/:user_id", get(user_info)); +/// let app = Router::new().route("/users/{user_id}", get(user_info)); /// # let _: Router = app; /// ``` /// @@ -99,7 +98,7 @@ use std::{fmt, sync::Arc}; /// } /// /// let app = Router::new().route( -/// "/users/:user_id/team/:team_id", +/// "/users/{user_id}/team/{team_id}", /// get(users_teams_show).post(users_teams_create), /// ); /// # let _: Router = app; @@ -128,7 +127,7 @@ use std::{fmt, sync::Arc}; /// } /// /// let app = Router::new() -/// .route("/users/:user_id/team/:team_id", get(params_map).post(params_vec)); +/// .route("/users/{user_id}/team/{team_id}", get(params_map).post(params_vec)); /// # let _: Router = app; /// ``` /// @@ -156,15 +155,6 @@ where async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { let params = match parts.extensions.get::() { Some(UrlParams::Params(params)) => params, - Some(UrlParams::InvalidUtf8InPathParam { key }) => { - let err = PathDeserializationError { - kind: ErrorKind::InvalidUtf8InPathParam { - key: key.to_string(), - }, - }; - let err = FailedToDeserializePathParams(err); - return Err(err.into()); - } None => { return Err(MissingPathParams.into()); } @@ -440,11 +430,11 @@ impl std::error::Error for FailedToDeserializePathParams {} /// } /// } /// -/// let app = Router::new().route("/users/:user_id/team/:team_id", get(users_teams_show)); +/// let app = Router::new().route("/users/{user_id}/team/{team_id}", get(users_teams_show)); /// # let _: Router = app; /// ``` #[derive(Debug)] -pub struct RawPathParams(Vec<(Arc, PercentDecodedStr)>); +pub struct RawPathParams(Vec<(Arc, Arc)>); #[async_trait] impl FromRequestParts for RawPathParams @@ -456,12 +446,6 @@ where async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { let params = match parts.extensions.get::() { Some(UrlParams::Params(params)) => params, - Some(UrlParams::InvalidUtf8InPathParam { key }) => { - return Err(InvalidUtf8InPathParam { - key: Arc::clone(key), - } - .into()); - } None => { return Err(MissingPathParams.into()); } @@ -491,14 +475,14 @@ impl<'a> IntoIterator for &'a RawPathParams { /// /// Created with [`RawPathParams::iter`]. #[derive(Debug)] -pub struct RawPathParamsIter<'a>(std::slice::Iter<'a, (Arc, PercentDecodedStr)>); +pub struct RawPathParamsIter<'a>(std::slice::Iter<'a, (Arc, Arc)>); impl<'a> Iterator for RawPathParamsIter<'a> { type Item = (&'a str, &'a str); fn next(&mut self) -> Option { let (key, value) = self.0.next()?; - Some((&**key, value.as_str())) + Some((&**key, &**value)) } } @@ -551,7 +535,7 @@ mod tests { #[crate::test] async fn extracting_url_params() { let app = Router::new().route( - "/users/:id", + "/users/{id}", get(|Path(id): Path| async move { assert_eq!(id, 42); }) @@ -571,7 +555,7 @@ mod tests { #[crate::test] async fn extracting_url_params_multiple_times() { - let app = Router::new().route("/users/:id", get(|_: Path, _: Path| async {})); + let app = Router::new().route("/users/{id}", get(|_: Path, _: Path| async {})); let client = TestClient::new(app); @@ -582,7 +566,7 @@ mod tests { #[crate::test] async fn percent_decoding() { let app = Router::new().route( - "/:key", + "/{key}", get(|Path(param): Path| async move { param }), ); @@ -597,11 +581,11 @@ mod tests { async fn supports_128_bit_numbers() { let app = Router::new() .route( - "/i/:key", + "/i/{key}", get(|Path(param): Path| async move { param.to_string() }), ) .route( - "/u/:key", + "/u/{key}", get(|Path(param): Path| async move { param.to_string() }), ); @@ -618,11 +602,11 @@ mod tests { async fn wildcard() { let app = Router::new() .route( - "/foo/*rest", + "/foo/{*rest}", get(|Path(param): Path| async move { param }), ) .route( - "/bar/*rest", + "/bar/{*rest}", get(|Path(params): Path>| async move { params.get("rest").unwrap().clone() }), @@ -639,7 +623,7 @@ mod tests { #[crate::test] async fn captures_dont_match_empty_path() { - let app = Router::new().route("/:key", get(|| async {})); + let app = Router::new().route("/{key}", get(|| async {})); let client = TestClient::new(app); @@ -653,7 +637,7 @@ mod tests { #[crate::test] async fn captures_match_empty_inner_segments() { let app = Router::new().route( - "/:key/method", + "/{key}/method", get(|Path(param): Path| async move { param.to_string() }), ); @@ -669,7 +653,7 @@ mod tests { #[crate::test] async fn captures_match_empty_inner_segments_near_end() { let app = Router::new().route( - "/method/:key/", + "/method/{key}/", get(|Path(param): Path| async move { param.to_string() }), ); @@ -688,7 +672,7 @@ mod tests { #[crate::test] async fn captures_match_empty_trailing_segment() { let app = Router::new().route( - "/method/:key", + "/method/{key}", get(|Path(param): Path| async move { param.to_string() }), ); @@ -720,7 +704,10 @@ mod tests { } } - let app = Router::new().route("/:key", get(|param: Path| async move { param.0 .0 })); + let app = Router::new().route( + "/{key}", + get(|param: Path| async move { param.0 .0 }), + ); let client = TestClient::new(app); @@ -734,7 +721,7 @@ mod tests { #[crate::test] async fn two_path_extractors() { - let app = Router::new().route("/:a/:b", get(|_: Path, _: Path| async {})); + let app = Router::new().route("/{a}/{b}", get(|_: Path, _: Path| async {})); let client = TestClient::new(app); @@ -750,7 +737,7 @@ mod tests { #[crate::test] async fn deserialize_into_vec_of_tuples() { let app = Router::new().route( - "/:a/:b", + "/{a}/{b}", get(|Path(params): Path>| async move { assert_eq!( params, @@ -781,31 +768,31 @@ mod tests { let app = Router::new() .route( - "/single/:a", + "/single/{a}", get(|Path(a): Path| async move { format!("single: {a}") }), ) .route( - "/tuple/:a/:b/:c", + "/tuple/{a}/{b}/{c}", get(|Path((a, b, c)): Path<(Date, Date, Date)>| async move { format!("tuple: {a} {b} {c}") }), ) .route( - "/vec/:a/:b/:c", + "/vec/{a}/{b}/{c}", get(|Path(vec): Path>| async move { let [a, b, c]: [Date; 3] = vec.try_into().unwrap(); format!("vec: {a} {b} {c}") }), ) .route( - "/vec_pairs/:a/:b/:c", + "/vec_pairs/{a}/{b}/{c}", get(|Path(vec): Path>| async move { let [(_, a), (_, b), (_, c)]: [(String, Date); 3] = vec.try_into().unwrap(); format!("vec_pairs: {a} {b} {c}") }), ) .route( - "/map/:a/:b/:c", + "/map/{a}/{b}/{c}", get(|Path(mut map): Path>| async move { let a = map.remove("a").unwrap(); let b = map.remove("b").unwrap(); @@ -814,7 +801,7 @@ mod tests { }), ) .route( - "/struct/:a/:b/:c", + "/struct/{a}/{b}/{c}", get(|Path(params): Path| async move { format!("struct: {} {} {}", params.a, params.b, params.c) }), @@ -851,8 +838,8 @@ mod tests { use serde_json::Value; let app = Router::new() - .route("/one/:a", get(|_: Path<(Value, Value)>| async {})) - .route("/two/:a/:b", get(|_: Path| async {})); + .route("/one/{a}", get(|_: Path<(Value, Value)>| async {})) + .route("/two/{a}/{b}", get(|_: Path| async {})); let client = TestClient::new(app); @@ -872,7 +859,7 @@ mod tests { #[crate::test] async fn raw_path_params() { let app = Router::new().route( - "/:a/:b/:c", + "/{a}/{b}/{c}", get(|params: RawPathParams| async move { params .into_iter() @@ -887,4 +874,61 @@ mod tests { let body = res.text().await; assert_eq!(body, "a=foo b=bar c=baz"); } + + #[tokio::test] + async fn percent_encoding_path() { + let app = Router::new().route( + "/{capture}", + get(|Path(path): Path| async move { path }), + ); + + let client = TestClient::new(app); + + let res = client.get("/%61pi").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "api"); + + let res = client.get("/%2561pi").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "%61pi"); + } + + #[tokio::test] + async fn percent_encoding_slash_in_path() { + let app = Router::new().route( + "/{capture}", + get(|Path(path): Path| async move { path }) + .fallback(|| async { panic!("not matched") }), + ); + + let client = TestClient::new(app); + + // `%2f` decodes to `/` + // Slashes are treated specially in the router + let res = client.get("/%2flash").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "/lash"); + + let res = client.get("/%2Flash").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "/lash"); + + // TODO FIXME + // This is not the correct behavior but should be so exceedingly rare that we can live with this for now. + let res = client.get("/%252flash").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + // Should be + // assert_eq!(body, "%2flash"); + assert_eq!(body, "/lash"); + + let res = client.get("/%25252flash").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "%252flash"); + } } diff --git a/axum/src/extract/request_parts.rs b/axum/src/extract/request_parts.rs index 55bc340b20..da1718795e 100644 --- a/axum/src/extract/request_parts.rs +++ b/axum/src/extract/request_parts.rs @@ -47,7 +47,7 @@ use std::convert::Infallible; /// use tower_http::trace::TraceLayer; /// /// let api_routes = Router::new() -/// .route("/users/:id", get(|| async { /* ... */ })) +/// .route("/users/{id}", get(|| async { /* ... */ })) /// .layer( /// TraceLayer::new_for_http().make_span_with(|req: &Request<_>| { /// let path = if let Some(path) = req.extensions().get::() { diff --git a/axum/src/json.rs b/axum/src/json.rs index c4435922a4..bbe4008e68 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -84,7 +84,7 @@ use serde::{de::DeserializeOwned, Serialize}; /// # unimplemented!() /// } /// -/// let app = Router::new().route("/users/:id", get(get_user)); +/// let app = Router::new().route("/users/{id}", get(get_user)); /// # let _: Router = app; /// ``` #[derive(Debug, Clone, Copy, Default)] diff --git a/axum/src/lib.rs b/axum/src/lib.rs index 601c14ae74..1a3032f681 100644 --- a/axum/src/lib.rs +++ b/axum/src/lib.rs @@ -268,7 +268,7 @@ //! }), //! ) //! .route( -//! "/users/:id", +//! "/users/{id}", //! get({ //! let shared_state = Arc::clone(&shared_state); //! move |path| get_user(path, shared_state) diff --git a/axum/src/routing/mod.rs b/axum/src/routing/mod.rs index 6564df7d62..58babe9f5f 100644 --- a/axum/src/routing/mod.rs +++ b/axum/src/routing/mod.rs @@ -99,9 +99,9 @@ impl fmt::Debug for Router { } pub(crate) const NEST_TAIL_PARAM: &str = "__private__axum_nest_tail_param"; -pub(crate) const NEST_TAIL_PARAM_CAPTURE: &str = "/*__private__axum_nest_tail_param"; +pub(crate) const NEST_TAIL_PARAM_CAPTURE: &str = "/{*__private__axum_nest_tail_param}"; pub(crate) const FALLBACK_PARAM: &str = "__private__axum_fallback"; -pub(crate) const FALLBACK_PARAM_PATH: &str = "/*__private__axum_fallback"; +pub(crate) const FALLBACK_PARAM_PATH: &str = "/{*__private__axum_fallback}"; impl Router where @@ -154,6 +154,13 @@ where } } + #[doc = include_str!("../docs/routing/without_v07_checks.md")] + pub fn without_v07_checks(self) -> Self { + self.tap_inner_mut(|this| { + this.path_router.without_v07_checks(); + }) + } + #[doc = include_str!("../docs/routing/route.md")] #[track_caller] pub fn route(self, path: &str, method_router: MethodRouter) -> Self { diff --git a/axum/src/routing/path_router.rs b/axum/src/routing/path_router.rs index 345d66712f..835c77f043 100644 --- a/axum/src/routing/path_router.rs +++ b/axum/src/routing/path_router.rs @@ -14,6 +14,7 @@ pub(super) struct PathRouter { routes: HashMap>, node: Arc, prev_route_id: RouteId, + v7_checks: bool, } impl PathRouter @@ -32,26 +33,56 @@ where } } +fn validate_path(v7_checks: bool, path: &str) -> Result<(), &'static str> { + if path.is_empty() { + return Err("Paths must start with a `/`. Use \"/\" for root routes"); + } else if !path.starts_with('/') { + return Err("Paths must start with a `/`"); + } + + if v7_checks { + validate_v07_paths(path)?; + } + + Ok(()) +} + +fn validate_v07_paths(path: &str) -> Result<(), &'static str> { + path.split('/') + .find_map(|segment| { + if segment.starts_with(':') { + Some(Err( + "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.", + )) + } else if segment.starts_with('*') { + Some(Err( + "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.", + )) + } else { + None + } + }) + .unwrap_or(Ok(())) +} + impl PathRouter where S: Clone + Send + Sync + 'static, { + pub(super) fn without_v07_checks(&mut self) { + self.v7_checks = false; + } + pub(super) fn route( &mut self, path: &str, method_router: MethodRouter, ) -> Result<(), Cow<'static, str>> { - fn validate_path(path: &str) -> Result<(), &'static str> { - if path.is_empty() { - return Err("Paths must start with a `/`. Use \"/\" for root routes"); - } else if !path.starts_with('/') { - return Err("Paths must start with a `/`"); - } - - Ok(()) - } - - validate_path(path)?; + validate_path(self.v7_checks, path)?; let endpoint = if let Some((route_id, Endpoint::MethodRouter(prev_method_router))) = self .node @@ -97,11 +128,7 @@ where path: &str, endpoint: Endpoint, ) -> Result<(), Cow<'static, str>> { - if path.is_empty() { - return Err("Paths must start with a `/`. Use \"/\" for root routes".into()); - } else if !path.starts_with('/') { - return Err("Paths must start with a `/`".into()); - } + validate_path(self.v7_checks, path)?; let id = self.next_route_id(); self.set_node(path, id)?; @@ -125,8 +152,12 @@ where routes, node, prev_route_id: _, + v7_checks, } = other; + // If either of the two did not allow paths starting with `:` or `*`, do not allow them for the merged router either. + self.v7_checks |= v7_checks; + for (id, route) in routes { let path = node .route_id_to_path @@ -162,12 +193,14 @@ where path_to_nest_at: &str, router: PathRouter, ) -> Result<(), Cow<'static, str>> { - let prefix = validate_nest_path(path_to_nest_at); + let prefix = validate_nest_path(self.v7_checks, path_to_nest_at); let PathRouter { routes, node, prev_route_id: _, + // Ignore the configuration of the nested router + v7_checks: _, } = router; for (id, endpoint) in routes { @@ -205,13 +238,13 @@ where T::Response: IntoResponse, T::Future: Send + 'static, { - let path = validate_nest_path(path_to_nest_at); + let path = validate_nest_path(self.v7_checks, path_to_nest_at); let prefix = path; let path = if path.ends_with('/') { - format!("{path}*{NEST_TAIL_PARAM}") + format!("{path}{{*{NEST_TAIL_PARAM}}}") } else { - format!("{path}/*{NEST_TAIL_PARAM}") + format!("{path}/{{*{NEST_TAIL_PARAM}}}") }; let layer = ( @@ -222,7 +255,7 @@ where self.route_endpoint(&path, endpoint.clone())?; - // `/*rest` is not matched by `/` so we need to also register a router at the + // `/{*rest}` is not matched by `/` so we need to also register a router at the // prefix itself. Otherwise if you were to nest at `/foo` then `/foo` itself // wouldn't match, which it should self.route_endpoint(prefix, endpoint.clone())?; @@ -255,6 +288,7 @@ where routes, node: self.node, prev_route_id: self.prev_route_id, + v7_checks: self.v7_checks, } } @@ -287,6 +321,7 @@ where routes, node: self.node, prev_route_id: self.prev_route_id, + v7_checks: self.v7_checks, } } @@ -309,6 +344,7 @@ where routes, node: self.node, prev_route_id: self.prev_route_id, + v7_checks: self.v7_checks, } } @@ -327,9 +363,21 @@ where } } - let path = req.uri().path().to_owned(); - - match self.node.at(&path) { + // Double encode any percent-encoded `/`s so that they're not + // interpreted by matchit. Additionally, percent-encode `%`s so that we + // can differentiate between `%2f` we have encoded to `%252f` and + // `%252f` the user might have sent us. + let path = req + .uri() + .path() + .replace("%2f", "%252f") + .replace("%2F", "%252F"); + let decode = percent_encoding::percent_decode_str(&path); + + match self.node.at(&decode + .decode_utf8() + .unwrap_or(Cow::Owned(req.uri().path().to_owned()))) + { Ok(match_) => { let id = *match_.value; @@ -358,11 +406,7 @@ where } // explicitly handle all variants in case matchit adds // new ones we need to handle differently - Err( - MatchError::NotFound - | MatchError::ExtraTrailingSlash - | MatchError::MissingTrailingSlash, - ) => Err((req, state)), + Err(MatchError::NotFound) => Err((req, state)), } } @@ -395,6 +439,7 @@ impl Default for PathRouter { routes: Default::default(), node: Default::default(), prev_route_id: RouteId(0), + v7_checks: true, } } } @@ -414,6 +459,7 @@ impl Clone for PathRouter { routes: self.routes.clone(), node: self.node.clone(), prev_route_id: self.prev_route_id, + v7_checks: self.v7_checks, } } } @@ -460,16 +506,22 @@ impl fmt::Debug for Node { } #[track_caller] -fn validate_nest_path(path: &str) -> &str { +fn validate_nest_path(v7_checks: bool, path: &str) -> &str { if path.is_empty() { // nesting at `""` and `"/"` should mean the same thing return "/"; } - if path.contains('*') { + if path.split('/').any(|segment| { + segment.starts_with("{*") && segment.ends_with('}') && !segment.ends_with("}}") + }) { panic!("Invalid route: nested routes cannot contain wildcards (*)"); } + if v7_checks { + validate_v07_paths(path).unwrap(); + } + path } diff --git a/axum/src/routing/strip_prefix.rs b/axum/src/routing/strip_prefix.rs index 0b06db4d28..53788f7399 100644 --- a/axum/src/routing/strip_prefix.rs +++ b/axum/src/routing/strip_prefix.rs @@ -1,5 +1,6 @@ use http::{Request, Uri}; use std::{ + borrow::Cow, sync::Arc, task::{Context, Poll}, }; @@ -56,17 +57,17 @@ fn strip_prefix(uri: &Uri, prefix: &str) -> Option { // ^^^^ this much is matched and the length is 4. Thus if we chop off the first 4 // characters we get the remainder // - // prefix = /api/:version + // prefix = /api/{version} // path = /api/v0/users // ^^^^^^^ this much is matched and the length is 7. let mut matching_prefix_length = Some(0); - for item in zip_longest(segments(path_and_query.path()), segments(prefix)) { + for item in zip_longest(segments(path_and_query.path()), unescaped_segments(prefix)) { // count the `/` *matching_prefix_length.as_mut().unwrap() += 1; match item { Item::Both(path_segment, prefix_segment) => { - if prefix_segment.starts_with(':') || path_segment == prefix_segment { + if is_capture(&prefix_segment) || path_segment == prefix_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(); @@ -121,7 +122,7 @@ fn strip_prefix(uri: &Uri, prefix: &str) -> Option { Some(Uri::from_parts(parts).unwrap()) } -fn segments(s: &str) -> impl Iterator { +fn segments(s: &str) -> impl Iterator> { assert!( s.starts_with('/'), "path didn't start with '/'. axum should have caught this higher up." @@ -131,6 +132,19 @@ fn segments(s: &str) -> impl Iterator { // skip one because paths always start with `/` so `/a/b` would become ["", "a", "b"] // otherwise .skip(1) + .map(Cow::Borrowed) +} + +/// This unescapes anything handled specially by `matchit`. +/// Currently, that means only `{{` and `}}` to mean literal `{` and `}` respectively. +fn unescaped_segments(s: &str) -> impl Iterator> { + segments(s).map(|segment| { + if segment.contains("{{") || segment.contains("}}") { + Cow::Owned(segment.replace("{{", "{").replace("}}", "}")) + } else { + segment + } + }) } fn zip_longest(a: I, b: I2) -> impl Iterator> @@ -148,6 +162,14 @@ where }) } +fn is_capture(segment: &str) -> bool { + segment.starts_with('{') + && segment.ends_with('}') + && !segment.starts_with("{{") + && !segment.ends_with("}}") + && !segment.starts_with("{*") +} + #[derive(Debug)] enum Item { Both(T, T), @@ -279,74 +301,89 @@ mod tests { expected = Some("/"), ); - test!(param_0, uri = "/", prefix = "/:param", expected = Some("/"),); + test!( + param_0, + uri = "/", + prefix = "/{param}", + expected = Some("/"), + ); test!( param_1, uri = "/a", - prefix = "/:param", + prefix = "/{param}", expected = Some("/"), ); test!( param_2, uri = "/a/b", - prefix = "/:param", + prefix = "/{param}", expected = Some("/b"), ); test!( param_3, uri = "/b/a", - prefix = "/:param", + prefix = "/{param}", expected = Some("/a"), ); test!( param_4, uri = "/a/b", - prefix = "/a/:param", + prefix = "/a/{param}", expected = Some("/"), ); - test!(param_5, uri = "/b/a", prefix = "/a/:param", expected = None,); + test!( + param_5, + uri = "/b/a", + prefix = "/a/{param}", + expected = None, + ); - test!(param_6, uri = "/a/b", prefix = "/:param/a", expected = None,); + test!( + param_6, + uri = "/a/b", + prefix = "/{param}/a", + expected = None, + ); test!( param_7, uri = "/b/a", - prefix = "/:param/a", + prefix = "/{param}/a", expected = Some("/"), ); test!( param_8, uri = "/a/b/c", - prefix = "/a/:param/c", + prefix = "/a/{param}/c", expected = Some("/"), ); test!( param_9, uri = "/c/b/a", - prefix = "/a/:param/c", + prefix = "/a/{param}/c", expected = None, ); test!( param_10, uri = "/a/", - prefix = "/:param", + prefix = "/{param}", expected = Some("/"), ); - test!(param_11, uri = "/a", prefix = "/:param/", expected = None,); + test!(param_11, uri = "/a", prefix = "/{param}/", expected = None,); test!( param_12, uri = "/a/", - prefix = "/:param/", + prefix = "/{param}/", expected = Some("/"), ); @@ -357,6 +394,48 @@ mod tests { expected = Some("/a"), ); + test!( + braces_1, + uri = "/{a}/a", + prefix = "/{{a}}/", + expected = Some("/a"), + ); + + test!( + braces_2, + uri = "/{a}/b", + prefix = "/{param}", + expected = Some("/b"), + ); + + test!( + braces_3, + uri = "/{a}/{b}", + prefix = "/{{a}}/{{b}}", + expected = Some("/"), + ); + + test!( + braces_4, + uri = "/{a}/{b}", + prefix = "/{{a}}/{b}", + expected = Some("/"), + ); + + test!( + braces_5, + uri = "/a/{b}", + prefix = "/a", + expected = Some("/{b}"), + ); + + test!( + braces_6, + uri = "/a/{b}", + prefix = "/{a}/{{b}}", + expected = Some("/"), + ); + #[quickcheck] fn does_not_panic(uri_and_prefix: UriAndPrefix) -> bool { let UriAndPrefix { uri, prefix } = uri_and_prefix; diff --git a/axum/src/routing/tests/mod.rs b/axum/src/routing/tests/mod.rs index 144c870dfa..25c5e4fbf6 100644 --- a/axum/src/routing/tests/mod.rs +++ b/axum/src/routing/tests/mod.rs @@ -84,9 +84,9 @@ async fn routing() { "/users", get(|_: Request| async { "users#index" }).post(|_: Request| async { "users#create" }), ) - .route("/users/:id", get(|_: Request| async { "users#show" })) + .route("/users/{id}", get(|_: Request| async { "users#show" })) .route( - "/users/:id/action", + "/users/{id}/action", get(|_: Request| async { "users#action" }), ); @@ -290,7 +290,10 @@ async fn multiple_methods_for_one_handler() { #[crate::test] async fn wildcard_sees_whole_url() { - let app = Router::new().route("/api/*rest", get(|uri: Uri| async move { uri.to_string() })); + let app = Router::new().route( + "/api/{*rest}", + get(|uri: Uri| async move { uri.to_string() }), + ); let client = TestClient::new(app); @@ -358,7 +361,7 @@ async fn with_and_without_trailing_slash() { #[crate::test] async fn wildcard_doesnt_match_just_trailing_slash() { let app = Router::new().route( - "/x/*path", + "/x/{*path}", get(|Path(path): Path| async move { path }), ); @@ -378,8 +381,8 @@ async fn wildcard_doesnt_match_just_trailing_slash() { #[crate::test] async fn what_matches_wildcard() { let app = Router::new() - .route("/*key", get(|| async { "root" })) - .route("/x/*key", get(|| async { "x" })) + .route("/{*key}", get(|| async { "root" })) + .route("/x/{*key}", get(|| async { "x" })) .fallback(|| async { "fallback" }); let client = TestClient::new(app); @@ -407,7 +410,7 @@ async fn what_matches_wildcard() { async fn static_and_dynamic_paths() { let app = Router::new() .route( - "/:key", + "/{key}", get(|Path(key): Path| async move { format!("dynamic: {key}") }), ) .route("/foo", get(|| async { "static" })); @@ -1099,3 +1102,133 @@ async fn locks_mutex_very_little() { assert_eq!(num, 1); } } + +#[crate::test] +#[should_panic( + 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." +)] +async fn colon_in_route() { + _ = Router::<()>::new().route("/:foo", get(|| async move {})); +} + +#[crate::test] +#[should_panic( + 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." +)] +async fn asterisk_in_route() { + _ = Router::<()>::new().route("/*foo", get(|| async move {})); +} + +#[crate::test] +async fn colon_in_route_allowed() { + let app = Router::<()>::new() + .without_v07_checks() + .route("/:foo", get(|| async move {})); + + let client = TestClient::new(app); + + let res = client.get("/:foo").await; + assert_eq!(res.status(), StatusCode::OK); + + let res = client.get("/foo").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[crate::test] +async fn asterisk_in_route_allowed() { + let app = Router::<()>::new() + .without_v07_checks() + .route("/*foo", get(|| async move {})); + + let client = TestClient::new(app); + + let res = client.get("/*foo").await; + assert_eq!(res.status(), StatusCode::OK); + + let res = client.get("/foo").await; + assert_eq!(res.status(), StatusCode::NOT_FOUND); +} + +#[crate::test] +async fn percent_encoding() { + let app = Router::new().route("/api", get(|| async { "api" })); + + let client = TestClient::new(app); + + let res = client.get("/%61pi").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "api"); +} + +#[crate::test] +async fn percent_encoding_slash() { + let app = Router::new() + .route("/slash/%2flash", get(|| async { "lower" })) + .route("/slash/%2Flash", get(|| async { "upper" })) + .route("/slash//lash", get(|| async { "/" })) + .route("/api/user", get(|| async { "user" })) + .route( + "/{capture}", + get(|Path(capture): Path| { + assert_eq!(capture, "api/user"); + ready("capture") + }), + ); + + let client = TestClient::new(app); + + // %2f encodes `/` + let res = client.get("/api%2fuser").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "capture"); + + let res = client.get("/slash/%2flash").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "lower"); + + let res = client.get("/slash/%2Flash").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "upper"); + + // `%25` encodes `%` + // This must not be decoded twice + let res = client.get("/slash/%252flash").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "lower"); + + let res = client.get("/slash/%252Flash").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "upper"); +} + +#[crate::test] +async fn percent_encoding_percent() { + let app = Router::new() + .route("/%61pi", get(|| async { "percent" })) + .route("/api", get(|| async { "api" })); + + let client = TestClient::new(app); + + let res = client.get("/api").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "api"); + + let res = client.get("/%61pi").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "api"); + + // `%25` encodes `%` + // This must not be decoded twice, otherwise it will become `/api` + let res = client.get("/%2561pi").await; + assert_eq!(res.status(), StatusCode::OK); + let body = res.text().await; + assert_eq!(body, "percent"); +} diff --git a/axum/src/routing/tests/nest.rs b/axum/src/routing/tests/nest.rs index 40df1f1ad8..1aa1dcd703 100644 --- a/axum/src/routing/tests/nest.rs +++ b/axum/src/routing/tests/nest.rs @@ -10,7 +10,7 @@ async fn nesting_apps() { get(|| async { "users#index" }).post(|| async { "users#create" }), ) .route( - "/users/:id", + "/users/{id}", get( |params: extract::Path>| async move { format!( @@ -22,7 +22,7 @@ async fn nesting_apps() { ), ) .route( - "/games/:id", + "/games/{id}", get( |params: extract::Path>| async move { format!( @@ -36,7 +36,7 @@ async fn nesting_apps() { let app = Router::new() .route("/", get(|| async { "hi" })) - .nest("/:version/api", api_routes); + .nest("/{version}/api", api_routes); let client = TestClient::new(app); @@ -228,7 +228,7 @@ async fn nested_multiple_routes() { } #[test] -#[should_panic = "Invalid route \"/\": insertion failed due to conflict with previously registered route: /*__private__axum_nest_tail_param"] +#[should_panic = "Invalid route \"/\": insertion failed due to conflict with previously registered route: /"] fn nested_service_at_root_with_other_routes() { let _: Router = Router::new() .nest_service("/", Router::new().route("/users", get(|| async {}))) @@ -263,7 +263,7 @@ async fn multiple_top_level_nests() { #[crate::test] #[should_panic(expected = "Invalid route: nested routes cannot contain wildcards (*)")] async fn nest_cannot_contain_wildcards() { - _ = Router::<()>::new().nest("/one/*rest", Router::new()); + _ = Router::<()>::new().nest("/one/{*rest}", Router::new()); } #[crate::test] @@ -317,11 +317,11 @@ async fn outer_middleware_still_see_whole_url() { #[crate::test] async fn nest_at_capture() { let api_routes = Router::new().route( - "/:b", + "/{b}", get(|Path((a, b)): Path<(String, String)>| async move { format!("a={a} b={b}") }), ); - let app = Router::new().nest("/:a", api_routes); + let app = Router::new().nest("/{a}", api_routes); let client = TestClient::new(app); @@ -417,3 +417,19 @@ nested_route_test!(nest_9, nest = "/a", route = "/a/", expected = "/a/a/"); nested_route_test!(nest_11, nest = "/a/", route = "/", expected = "/a/"); nested_route_test!(nest_12, nest = "/a/", route = "/a", expected = "/a/a"); nested_route_test!(nest_13, nest = "/a/", route = "/a/", expected = "/a/a/"); + +#[crate::test] +#[should_panic( + 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." +)] +async fn colon_in_route() { + _ = Router::<()>::new().nest("/:foo", Router::new()); +} + +#[crate::test] +#[should_panic( + 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." +)] +async fn asterisk_in_route() { + _ = Router::<()>::new().nest("/*foo", Router::new()); +} diff --git a/axum/src/routing/url_params.rs b/axum/src/routing/url_params.rs index eb5a08a330..64ca1cd6d4 100644 --- a/axum/src/routing/url_params.rs +++ b/axum/src/routing/url_params.rs @@ -1,46 +1,32 @@ -use crate::util::PercentDecodedStr; use http::Extensions; use matchit::Params; use std::sync::Arc; #[derive(Clone)] pub(crate) enum UrlParams { - Params(Vec<(Arc, PercentDecodedStr)>), - InvalidUtf8InPathParam { key: Arc }, + Params(Vec<(Arc, Arc)>), } pub(super) fn insert_url_params(extensions: &mut Extensions, params: Params) { let current_params = extensions.get_mut(); - if let Some(UrlParams::InvalidUtf8InPathParam { .. }) = current_params { - // nothing to do here since an error was stored earlier - return; - } - let params = params .iter() .filter(|(key, _)| !key.starts_with(super::NEST_TAIL_PARAM)) .filter(|(key, _)| !key.starts_with(super::FALLBACK_PARAM)) .map(|(k, v)| { - if let Some(decoded) = PercentDecodedStr::new(v) { - Ok((Arc::from(k), decoded)) - } else { - Err(Arc::from(k)) - } + ( + Arc::from(k), + Arc::from(v.replace("%2f", "/").replace("%2F", "/")), + ) }) - .collect::, _>>(); + .collect::>(); match (current_params, params) { - (Some(UrlParams::InvalidUtf8InPathParam { .. }), _) => { - unreachable!("we check for this state earlier in this method") - } - (_, Err(invalid_key)) => { - extensions.insert(UrlParams::InvalidUtf8InPathParam { key: invalid_key }); - } - (Some(UrlParams::Params(current)), Ok(params)) => { + (Some(UrlParams::Params(current)), params) => { current.extend(params); } - (None, Ok(params)) => { + (None, params) => { extensions.insert(UrlParams::Params(params)); } } diff --git a/axum/src/util.rs b/axum/src/util.rs index bae803db88..aee7d2d3ad 100644 --- a/axum/src/util.rs +++ b/axum/src/util.rs @@ -1,36 +1,7 @@ use pin_project_lite::pin_project; -use std::{ops::Deref, sync::Arc}; pub(crate) use self::mutex::*; -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub(crate) struct PercentDecodedStr(Arc); - -impl PercentDecodedStr { - pub(crate) fn new(s: S) -> Option - where - S: AsRef, - { - percent_encoding::percent_decode(s.as_ref().as_bytes()) - .decode_utf8() - .ok() - .map(|decoded| Self(decoded.as_ref().into())) - } - - pub(crate) fn as_str(&self) -> &str { - &self.0 - } -} - -impl Deref for PercentDecodedStr { - type Target = str; - - #[inline] - fn deref(&self) -> &Self::Target { - self.as_str() - } -} - pin_project! { #[project = EitherProj] pub(crate) enum Either { diff --git a/examples/customize-path-rejection/src/main.rs b/examples/customize-path-rejection/src/main.rs index 4231eabf60..c5f0ef9eb6 100644 --- a/examples/customize-path-rejection/src/main.rs +++ b/examples/customize-path-rejection/src/main.rs @@ -26,7 +26,7 @@ async fn main() { .init(); // build our application with a route - let app = Router::new().route("/users/:user_id/teams/:team_id", get(handler)); + let app = Router::new().route("/users/{user_id}/teams/{team_id}", get(handler)); // run it let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") diff --git a/examples/dependency-injection/src/main.rs b/examples/dependency-injection/src/main.rs index b8e1a451fa..dc4ce165e7 100644 --- a/examples/dependency-injection/src/main.rs +++ b/examples/dependency-injection/src/main.rs @@ -52,14 +52,14 @@ async fn main() { // Using trait objects is recommended unless you really need generics. let using_dyn = Router::new() - .route("/users/:id", get(get_user_dyn)) + .route("/users/{id}", get(get_user_dyn)) .route("/users", post(create_user_dyn)) .with_state(AppStateDyn { user_repo: Arc::new(user_repo.clone()), }); let using_generic = Router::new() - .route("/users/:id", get(get_user_generic::)) + .route("/users/{id}", get(get_user_generic::)) .route("/users", post(create_user_generic::)) .with_state(AppStateGeneric { user_repo }); diff --git a/examples/key-value-store/src/main.rs b/examples/key-value-store/src/main.rs index 1e2a5e748c..d8713a2cd7 100644 --- a/examples/key-value-store/src/main.rs +++ b/examples/key-value-store/src/main.rs @@ -44,7 +44,7 @@ async fn main() { // Build our application by composing routes let app = Router::new() .route( - "/:key", + "/{key}", // Add compression to `kv_get` get(kv_get.layer(CompressionLayer::new())) // But don't compress `kv_set` @@ -124,7 +124,7 @@ fn admin_routes() -> Router { Router::new() .route("/keys", delete(delete_all_keys)) - .route("/key/:key", delete(remove_key)) + .route("/key/{key}", delete(remove_key)) // Require bearer auth for all admin routes .layer(ValidateRequestHeaderLayer::bearer("secret-token")) } diff --git a/examples/stream-to-file/src/main.rs b/examples/stream-to-file/src/main.rs index a595d0d834..f016993270 100644 --- a/examples/stream-to-file/src/main.rs +++ b/examples/stream-to-file/src/main.rs @@ -37,7 +37,7 @@ async fn main() { let app = Router::new() .route("/", get(show_form).post(accept_form)) - .route("/file/:file_name", post(save_request_body)); + .route("/file/{file_name}", post(save_request_body)); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await diff --git a/examples/templates/src/main.rs b/examples/templates/src/main.rs index 3a6c82316c..36d9e68e4c 100644 --- a/examples/templates/src/main.rs +++ b/examples/templates/src/main.rs @@ -25,7 +25,7 @@ async fn main() { .init(); // build our application with some routes - let app = Router::new().route("/greet/:name", get(greet)); + let app = Router::new().route("/greet/{name}", get(greet)); // run it let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") diff --git a/examples/todos/src/main.rs b/examples/todos/src/main.rs index 2fdac41bd5..da3cb4a1c2 100644 --- a/examples/todos/src/main.rs +++ b/examples/todos/src/main.rs @@ -4,8 +4,8 @@ //! //! - `GET /todos`: return a JSON list of Todos. //! - `POST /todos`: create a new Todo. -//! - `PATCH /todos/:id`: update a specific Todo. -//! - `DELETE /todos/:id`: delete a specific Todo. +//! - `PATCH /todos/{id}`: update a specific Todo. +//! - `DELETE /todos/{id}`: delete a specific Todo. //! //! Run with //! @@ -47,7 +47,7 @@ async fn main() { // Compose the routes let app = Router::new() .route("/todos", get(todos_index).post(todos_create)) - .route("/todos/:id", patch(todos_update).delete(todos_delete)) + .route("/todos/{id}", patch(todos_update).delete(todos_delete)) // Add middleware to all routes .layer( ServiceBuilder::new() diff --git a/examples/versioning/src/main.rs b/examples/versioning/src/main.rs index a1d96e8340..ee353f06bc 100644 --- a/examples/versioning/src/main.rs +++ b/examples/versioning/src/main.rs @@ -26,7 +26,7 @@ async fn main() { .init(); // build our application with some routes - let app = Router::new().route("/:version/foo", get(handler)); + let app = Router::new().route("/{version}/foo", get(handler)); // run it let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")