diff --git a/axum-extra/src/extract/optional_path.rs b/axum-extra/src/extract/optional_path.rs index 5824e60029..0d41a66cd6 100644 --- a/axum-extra/src/extract/optional_path.rs +++ b/axum-extra/src/extract/optional_path.rs @@ -28,7 +28,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)] @@ -75,7 +75,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 c3bf702410..571ab67707 100644 --- a/axum-extra/src/handler/mod.rs +++ b/axum-extra/src/handler/mod.rs @@ -92,7 +92,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 faa6851628..f15ccc70b0 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 e4abe04147..d563807403 100644 --- a/axum-extra/src/protobuf.rs +++ b/axum-extra/src/protobuf.rs @@ -81,7 +81,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 bf99bbcf19..9d9aa0cbe6 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 34c9513bc3..02c5be672c 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 701a6dd0c5..ef94c3779c 100644 --- a/axum-extra/src/typed_header.rs +++ b/axum-extra/src/typed_header.rs @@ -29,7 +29,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 61e1695b00..fa272252be 100644 --- a/axum-macros/src/typed_path.rs +++ b/axum-macros/src/typed_path.rs @@ -383,8 +383,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 5c419ef372..6b8d70445d 100644 --- a/axum/Cargo.toml +++ b/axum/Cargo.toml @@ -48,7 +48,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 4f52a3ad30..244528d6a8 100644 --- a/axum/src/docs/extract.md +++ b/axum/src/docs/extract.md @@ -77,7 +77,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)) @@ -100,7 +100,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 845072f021..bb5b2ea6cb 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 e8172137bb..01be9152ed 100644 --- a/axum/src/docs/routing/route.md +++ b/axum/src/docs/routing/route.md @@ -20,15 +20,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. @@ -41,19 +41,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): @@ -64,14 +64,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 @@ -120,9 +120,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() {} @@ -151,7 +151,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..f1b465ea9e --- /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 for route registrations on the resulting router 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 99599a9c10..8fdd8e35a9 100644 --- a/axum/src/extract/matched_path.rs +++ b/axum/src/extract/matched_path.rs @@ -13,10 +13,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; @@ -38,7 +38,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::() { @@ -141,22 +141,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() }), ), ); @@ -164,17 +164,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() }), ), ), @@ -183,7 +183,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] @@ -197,7 +197,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); @@ -212,12 +212,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); @@ -234,7 +234,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); @@ -251,7 +251,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); @@ -263,14 +263,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)), ); @@ -284,14 +284,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)), ); @@ -307,7 +307,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); @@ -321,7 +321,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 {}))) @@ -349,4 +349,44 @@ 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); + } } diff --git a/axum/src/extract/path/mod.rs b/axum/src/extract/path/mod.rs index d6b9fb128e..a6779a5644 100644 --- a/axum/src/extract/path/mod.rs +++ b/axum/src/extract/path/mod.rs @@ -43,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; /// ``` /// @@ -61,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; /// ``` /// @@ -98,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; @@ -127,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; /// ``` /// @@ -438,7 +438,7 @@ 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)] @@ -548,7 +548,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); }) @@ -568,7 +568,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); @@ -579,7 +579,7 @@ mod tests { #[crate::test] async fn percent_decoding() { let app = Router::new().route( - "/:key", + "/{key}", get(|Path(param): Path| async move { param }), ); @@ -594,11 +594,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() }), ); @@ -615,11 +615,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() }), @@ -636,7 +636,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); @@ -650,7 +650,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() }), ); @@ -666,7 +666,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() }), ); @@ -685,7 +685,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() }), ); @@ -717,7 +717,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); @@ -731,7 +734,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); @@ -774,7 +777,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, @@ -805,31 +808,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(); @@ -838,7 +841,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) }), @@ -875,8 +878,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); @@ -896,7 +899,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() diff --git a/axum/src/extract/request_parts.rs b/axum/src/extract/request_parts.rs index 6ad0c7ac3b..6d9adc672c 100644 --- a/axum/src/extract/request_parts.rs +++ b/axum/src/extract/request_parts.rs @@ -46,7 +46,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 d18e6ffe05..a2dfdc2eeb 100644 --- a/axum/src/json.rs +++ b/axum/src/json.rs @@ -82,7 +82,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 27b87e907f..fcc929a6ab 100644 --- a/axum/src/lib.rs +++ b/axum/src/lib.rs @@ -251,7 +251,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 4bd8b28bdf..c9fcc0a7e1 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 6a7d3cee4b..32b3102575 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, } } @@ -313,6 +348,7 @@ where routes, node: self.node, prev_route_id: self.prev_route_id, + v7_checks: self.v7_checks, } } @@ -362,11 +398,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)), } } @@ -399,6 +431,7 @@ impl Default for PathRouter { routes: Default::default(), node: Default::default(), prev_route_id: RouteId(0), + v7_checks: true, } } } @@ -418,6 +451,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, } } } @@ -464,16 +498,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 ff8f04c390..3209da3b12 100644 --- a/axum/src/routing/strip_prefix.rs +++ b/axum/src/routing/strip_prefix.rs @@ -56,7 +56,7 @@ 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); @@ -66,7 +66,7 @@ fn strip_prefix(uri: &Uri, prefix: &str) -> Option { 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(); @@ -148,6 +148,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 +287,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("/"), ); diff --git a/axum/src/routing/tests/mod.rs b/axum/src/routing/tests/mod.rs index 978125d0a0..db5ca480da 100644 --- a/axum/src/routing/tests/mod.rs +++ b/axum/src/routing/tests/mod.rs @@ -83,9 +83,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" }), ); @@ -289,7 +289,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); @@ -357,7 +360,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 }), ); @@ -377,8 +380,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); @@ -406,7 +409,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" })); @@ -1054,3 +1057,19 @@ async fn impl_handler_for_into_response() { assert_eq!(res.status(), StatusCode::CREATED); assert_eq!(res.text().await, "thing created"); } + +#[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 {})); +} 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/examples/customize-path-rejection/src/main.rs b/examples/customize-path-rejection/src/main.rs index 8df9057f06..e784a969b8 100644 --- a/examples/customize-path-rejection/src/main.rs +++ b/examples/customize-path-rejection/src/main.rs @@ -25,7 +25,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 0170966e91..7a4719e768 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 571dbaf0a6..c2b3f51cda 100644 --- a/examples/key-value-store/src/main.rs +++ b/examples/key-value-store/src/main.rs @@ -45,7 +45,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` @@ -125,7 +125,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 73cb63647f..7c44286d87 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 6fdf48478b..872471c235 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 273f30e920..6f115daf3c 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 //! @@ -48,7 +48,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 58ea496d62..7b3ca5a581 100644 --- a/examples/versioning/src/main.rs +++ b/examples/versioning/src/main.rs @@ -25,7 +25,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")