From f198cc76847c373968c3a505f3f147762990d3f7 Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:19:19 -0400 Subject: [PATCH 1/3] feat: add query parameter support to the reverse! macro Path parameters are passed right after the view name, and query parameters can now be added after a semicolon. They get appended to the generated URL as a percent-encoded query string, so you don't have to build it by hand anymore. --- cot/src/router.rs | 74 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/cot/src/router.rs b/cot/src/router.rs index 5818776ca..20dd09c61 100644 --- a/cot/src/router.rs +++ b/cot/src/router.rs @@ -529,6 +529,22 @@ pub fn split_view_name(view_name: &str) -> (Option<&str>, &str) { } } +// used in the reverse! macro; not part of public API +#[doc(hidden)] +#[must_use] +pub fn reverse_with_query(url: String, query_params: &[(&str, String)]) -> String { + if query_params.is_empty() { + return url; + } + + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (key, value) in query_params { + serializer.append_pair(key, value); + } + + format!("{url}?{}", serializer.finish()) +} + /// A route that can be used to route requests to their respective views. /// /// Non-empty route paths may omit the leading slash. Cot normalizes them by @@ -807,6 +823,14 @@ enum RouteInner { /// Returns a [`cot::Result`] that contains the URL for the view. You /// will typically want to append `?` to the macro call to get the URL. /// +/// # Query parameters +/// +/// Path parameters are passed as `key = value` pairs right after the view name. +/// Query parameters can be added after a semicolon, using the same syntax. They +/// are appended to the generated URL as a percent-encoded query string, so +/// `reverse!(request, "home"; page = 2)` returns `/?page=2`. The values only +/// need to implement [`ToString`], just like path parameters do. +/// /// # Examples /// /// ``` @@ -821,6 +845,9 @@ enum RouteInner { /// let url = reverse!(request, "home")?; /// let url = reverse!(request, "my_custom_app:home")?; /// +/// // with query parameters, this returns `/?page=2&search=cot`: +/// let url = reverse!(request, "home"; page = 2, search = "cot")?; +/// /// Ok(Html::new(format!( /// "Hello! The URL for this view is: {}", /// url @@ -851,7 +878,10 @@ enum RouteInner { /// ``` #[macro_export] macro_rules! reverse { - ($request:expr, $view_name:literal $(, $($key:ident = $value:expr),*)?) => {{ + ($request:expr, $view_name:literal + $(, $($key:ident = $value:expr),* )? + $(; $($query_key:ident = $query_value:expr),* )? + ) => {{ #[allow( clippy::allow_attributes, unused_imports, @@ -863,6 +893,10 @@ macro_rules! reverse { $request .router() .reverse(app_name, view_name, &$crate::reverse_param_map!($( $($key = $value),* )?)) + .map(|url| $crate::router::reverse_with_query( + url, + &[$( $( (stringify!($query_key), ::std::string::ToString::to_string(&$query_value)) ),* )?], + )) }}; } @@ -1296,6 +1330,44 @@ mod tests { assert_eq!(url, "/test/123"); } + #[test] + fn test_reverse_macro_query_params() { + let route = Route::with_handler_and_name("/", MockHandler, "home"); + let router = Router::with_urls(vec![route]); + + let request = TestRequestBuilder::get("/").router(router).build(); + let url = reverse!(request, "home"; page = 2, search = "cot").unwrap(); + + assert_eq!(url, "/?page=2&search=cot"); + } + + #[test] + fn test_reverse_macro_path_and_query_params() { + let route = Route::with_handler_and_name("/test/{id}", MockHandler, "test"); + let router = Router::with_urls(vec![route]); + + let request = TestRequestBuilder::get("/").router(router).build(); + let url = reverse!(request, "test", id = 123; page = 2).unwrap(); + + assert_eq!(url, "/test/123?page=2"); + } + + #[test] + fn test_reverse_macro_query_params_are_encoded() { + let route = Route::with_handler_and_name("/", MockHandler, "home"); + let router = Router::with_urls(vec![route]); + + let request = TestRequestBuilder::get("/").router(router).build(); + let url = reverse!(request, "home"; search = "hello world & cot").unwrap(); + + assert_eq!(url, "/?search=hello+world+%26+cot"); + } + + #[test] + fn reverse_with_query_leaves_url_unchanged_when_empty() { + assert_eq!(reverse_with_query("/test".to_string(), &[]), "/test"); + } + #[test] fn test_reverse_redirect_macro() { let route = Route::with_handler_and_name("/test/{id}", MockHandler, "test"); From 2afa43378ec29cab4a8b7a4a009dcafeb3a2eab1 Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 5 Oct 2026 16:11:06 +0000 Subject: [PATCH 2/3] address PR comments: - change syntax to use `query:` - extend to reverse_redirect macro --- cot/src/router.rs | 162 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 142 insertions(+), 20 deletions(-) diff --git a/cot/src/router.rs b/cot/src/router.rs index c012667a3..2b9bd1cbd 100644 --- a/cot/src/router.rs +++ b/cot/src/router.rs @@ -826,10 +826,14 @@ enum RouteInner { /// # Query parameters /// /// Path parameters are passed as `key = value` pairs right after the view name. -/// Query parameters can be added after a semicolon, using the same syntax. They -/// are appended to the generated URL as a percent-encoded query string, so -/// `reverse!(request, "home"; page = 2)` returns `/?page=2`. The values only -/// need to implement [`ToString`], just like path parameters do. +/// Query parameters are passed in a `query: { ... }` block and are appended to +/// the generated URL as a percent-encoded query string. The values only need to +/// implement [`ToString`], just like path parameters do. +/// +/// The query block must come after any path parameters. For example, +/// `reverse!(request, "article", id = 5, query: { page = 2, sort = "new" })` +/// generates `/articles/5?page=2&sort=new` for a route at `/articles/{id}`. +/// An empty query block leaves the URL unchanged. /// /// # Examples /// @@ -846,7 +850,7 @@ enum RouteInner { /// let url = reverse!(request, "my_custom_app:home")?; /// /// // with query parameters, this returns `/?page=2&search=cot`: -/// let url = reverse!(request, "home"; page = 2, search = "cot")?; +/// let url = reverse!(request, "home", query: { page = 2, search = "cot" })?; /// /// Ok(Html::new(format!( /// "Hello! The URL for this view is: {}", @@ -878,9 +882,10 @@ enum RouteInner { /// ``` #[macro_export] macro_rules! reverse { - ($request:expr, $view_name:literal - $(, $($key:ident = $value:expr),* )? - $(; $($query_key:ident = $query_value:expr),* )? + // Once all path parameters have been collected, only a query block or the + // end of the invocation is allowed. + (@parse $request:expr, $view_name:literal, [$($key:ident = $value:expr,)*], + $(query: { $($query_key:ident = $query_value:expr),* $(,)? } $(,)?)? ) => {{ #[allow( clippy::allow_attributes, @@ -892,12 +897,23 @@ macro_rules! reverse { let app_name = app_name.or_else(|| $request.app_name()); $request .router() - .reverse(app_name, view_name, &$crate::reverse_param_map!($( $($key = $value),* )?)) + .reverse(app_name, view_name, &$crate::reverse_param_map!($($key = $value),*)) .map(|url| $crate::router::reverse_with_query( url, &[$( $( (stringify!($query_key), ::std::string::ToString::to_string(&$query_value)) ),* )?], )) }}; + // Consume one path parameter per expansion so `query = value` and + // `query: { ... }` can be distinguished without an ambiguous repetition. + (@parse $request:expr, $view_name:literal, [$($params:tt)*], + $key:ident = $value:expr $(, $($rest:tt)*)? + ) => { + $crate::reverse!(@parse $request, $view_name, + [$($params)* $key = $value,], $($($rest)*)?) + }; + ($request:expr, $view_name:literal $(, $($params:tt)*)?) => { + $crate::reverse!(@parse $request, $view_name, [], $($($params)*)?) + }; } /// A helper structure to allow reversing URLs from a request handler. @@ -1053,7 +1069,8 @@ impl From<&mut ProjectContext> for Urls { /// response with a redirect. /// /// This macro is a shorthand for creating a response with a redirect to a URL -/// generated by the [`reverse!`] macro. +/// generated by the [`reverse!`] macro. It accepts the same path parameters and +/// optional `query: { ... }` block. /// /// # Return value /// @@ -1070,19 +1087,15 @@ impl From<&mut ProjectContext> for Urls { /// use cot::router::{Route, Router}; /// /// async fn infinite_loop(request: Request) -> cot::Result { -/// Ok(reverse_redirect!(request, "home")?) +/// Ok(reverse_redirect!(request, "home", query: { page = 2 })?) /// } /// /// let router = Router::with_urls([Route::with_handler_and_name("/", infinite_loop, "home")]); /// ``` #[macro_export] macro_rules! reverse_redirect { - ($request:expr, $view_name:literal $(, $($key:ident = $value:expr),*)?) => { - $crate::reverse!( - $request, - $view_name, - $( $($key = $value),* )? - ).map(|url| + ($($args:tt)*) => { + $crate::reverse!($($args)*).map(|url| $crate::response::IntoResponse::into_response($crate::response::Redirect::new(url)) .expect("Failed to build response") ) @@ -1336,7 +1349,7 @@ mod tests { let router = Router::with_urls(vec![route]); let request = TestRequestBuilder::get("/").router(router).build(); - let url = reverse!(request, "home"; page = 2, search = "cot").unwrap(); + let url = reverse!(request, "home", query: { page = 2, search = "cot" }).unwrap(); assert_eq!(url, "/?page=2&search=cot"); } @@ -1347,7 +1360,7 @@ mod tests { let router = Router::with_urls(vec![route]); let request = TestRequestBuilder::get("/").router(router).build(); - let url = reverse!(request, "test", id = 123; page = 2).unwrap(); + let url = reverse!(request, "test", id = 123, query: { page = 2 }).unwrap(); assert_eq!(url, "/test/123?page=2"); } @@ -1358,11 +1371,99 @@ mod tests { let router = Router::with_urls(vec![route]); let request = TestRequestBuilder::get("/").router(router).build(); - let url = reverse!(request, "home"; search = "hello world & cot").unwrap(); + let url = reverse!(request, "home", query: { search = "hello world & cot" }).unwrap(); assert_eq!(url, "/?search=hello+world+%26+cot"); } + #[test] + fn test_reverse_macros_distinguish_query_path_parameter() { + let route = Route::with_handler_and_name("/search/{query}/{id}", MockHandler, "search"); + let request = TestRequestBuilder::get("/") + .router(Router::with_urls([route])) + .build(); + + let url = reverse!(request, "search", query = "cot", id = 5, + query: { query = "new", page = 1 + 1, }, + ) + .unwrap(); + let response = cot::reverse_redirect!(request, "search", query = "cot", id = 5, + query: { query = "new", page = 1 + 1, }, + ) + .unwrap(); + + assert_eq!(url, "/search/cot/5?query=new&page=2"); + assert_eq!(response.headers().get("location").unwrap(), url.as_str()); + } + + #[test] + fn test_reverse_macros_without_query_params() { + let request = TestRequestBuilder::get("/") + .router(Router::with_urls([ + Route::with_handler_and_name("/", MockHandler, "home"), + Route::with_handler_and_name("/test/{id}", MockHandler, "test"), + ])) + .build(); + + assert_eq!(reverse!(request, "home").unwrap(), "/"); + assert_eq!(reverse!(request, "home",).unwrap(), "/"); + assert_eq!(reverse!(request, "home", query: {}).unwrap(), "/"); + assert_eq!(reverse!(request, "test", id = 5,).unwrap(), "/test/5"); + assert_eq!( + reverse!(request, "test", id = 5, query: {}).unwrap(), + "/test/5" + ); + assert_eq!( + cot::reverse_redirect!(request, "home").unwrap().headers()["location"], + "/" + ); + assert_eq!( + cot::reverse_redirect!(request, "home", query: {}) + .unwrap() + .headers()["location"], + "/" + ); + assert_eq!( + cot::reverse_redirect!(request, "test", id = 5, query: {}) + .unwrap() + .headers()["location"], + "/test/5" + ); + } + + #[test] + fn test_reverse_macros_query_params_with_urls() { + let route = Route::with_handler_and_name("/", MockHandler, "home"); + let request = TestRequestBuilder::get("/") + .router(Router::with_urls([route])) + .build(); + let urls = Urls::from_request(&request); + + let url = + reverse!(urls, "home", query: { search = "hello world & cot", page = 2 }).unwrap(); + let response = cot::reverse_redirect!(urls, "home", + query: { search = "hello world & cot", page = 2 } + ) + .unwrap(); + + assert_eq!(url, "/?search=hello+world+%26+cot&page=2"); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), url.as_str()); + } + + #[test] + fn test_reverse_macros_query_params_preserve_errors() { + let route = Route::with_handler_and_name("/test/{id}", MockHandler, "test"); + let request = TestRequestBuilder::get("/") + .router(Router::with_urls([route])) + .build(); + + assert!(reverse!(request, "missing", query: { page = 2 }).is_err()); + assert!(cot::reverse_redirect!(request, "missing", query: { page = 2 }).is_err()); + assert!(reverse!(request, "test", query: { page = 2 }).is_err()); + assert!(cot::reverse_redirect!(request, "test", query: { page = 2 }).is_err()); + } + #[test] fn reverse_with_query_leaves_url_unchanged_when_empty() { assert_eq!(reverse_with_query("/test".to_string(), &[]), "/test"); @@ -1380,6 +1481,27 @@ mod tests { assert_eq!(response.headers().get("location").unwrap(), "/test/123"); } + #[test] + fn test_reverse_redirect_macro_query_params() { + let route = Route::with_handler_and_name("/test/{id}", MockHandler, "test"); + let router = Router::with_urls(vec![route]); + + let request = TestRequestBuilder::get("/").router(router).build(); + let response = cot::reverse_redirect!( + request, + "test", + id = 5, + query: { page = 2, sort = "new", foo = "bar" } + ) + .unwrap(); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("location").unwrap(), + "/test/5?page=2&sort=new&foo=bar" + ); + } + fn test_request() -> Request { TestRequestBuilder::get("/test").build() } From 82dd0e941f7c7ce2c0cce12e2fc5ec0080de2def Mon Sep 17 00:00:00 2001 From: Elijah Date: Mon, 5 Oct 2026 17:32:18 +0000 Subject: [PATCH 3/3] address nits --- cot/src/router.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cot/src/router.rs b/cot/src/router.rs index 2b9bd1cbd..59f8be5f0 100644 --- a/cot/src/router.rs +++ b/cot/src/router.rs @@ -1344,7 +1344,7 @@ mod tests { } #[test] - fn test_reverse_macro_query_params() { + fn reverse_macro_query_params() { let route = Route::with_handler_and_name("/", MockHandler, "home"); let router = Router::with_urls(vec![route]); @@ -1355,7 +1355,7 @@ mod tests { } #[test] - fn test_reverse_macro_path_and_query_params() { + fn reverse_macro_path_and_query_params() { let route = Route::with_handler_and_name("/test/{id}", MockHandler, "test"); let router = Router::with_urls(vec![route]); @@ -1366,7 +1366,7 @@ mod tests { } #[test] - fn test_reverse_macro_query_params_are_encoded() { + fn reverse_macro_query_params_are_encoded() { let route = Route::with_handler_and_name("/", MockHandler, "home"); let router = Router::with_urls(vec![route]); @@ -1377,7 +1377,7 @@ mod tests { } #[test] - fn test_reverse_macros_distinguish_query_path_parameter() { + fn reverse_macros_distinguish_query_path_parameter() { let route = Route::with_handler_and_name("/search/{query}/{id}", MockHandler, "search"); let request = TestRequestBuilder::get("/") .router(Router::with_urls([route])) @@ -1397,7 +1397,7 @@ mod tests { } #[test] - fn test_reverse_macros_without_query_params() { + fn reverse_macros_without_query_params() { let request = TestRequestBuilder::get("/") .router(Router::with_urls([ Route::with_handler_and_name("/", MockHandler, "home"), @@ -1432,7 +1432,7 @@ mod tests { } #[test] - fn test_reverse_macros_query_params_with_urls() { + fn reverse_macros_query_params_with_urls() { let route = Route::with_handler_and_name("/", MockHandler, "home"); let request = TestRequestBuilder::get("/") .router(Router::with_urls([route])) @@ -1452,7 +1452,7 @@ mod tests { } #[test] - fn test_reverse_macros_query_params_preserve_errors() { + fn reverse_macros_query_params_preserve_errors() { let route = Route::with_handler_and_name("/test/{id}", MockHandler, "test"); let request = TestRequestBuilder::get("/") .router(Router::with_urls([route])) @@ -1482,7 +1482,7 @@ mod tests { } #[test] - fn test_reverse_redirect_macro_query_params() { + fn reverse_redirect_macro_query_params() { let route = Route::with_handler_and_name("/test/{id}", MockHandler, "test"); let router = Router::with_urls(vec![route]);