Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 204 additions & 10 deletions cot/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,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
Expand Down Expand Up @@ -1048,6 +1064,18 @@ enum RouteInner {
/// Returns a [`cot::Result<String>`] 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 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
///
/// ```
Expand All @@ -1062,6 +1090,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", query: { page = 2, search = "cot" })?;
///
/// Ok(Html::new(format!(
/// "Hello! The URL for this view is: {}",
/// url
Expand Down Expand Up @@ -1092,7 +1123,11 @@ enum RouteInner {
/// ```
#[macro_export]
macro_rules! reverse {
($request:expr, $view_name:literal $(, $($key:ident = $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,
unused_imports,
Expand All @@ -1103,8 +1138,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.
Expand Down Expand Up @@ -1260,7 +1310,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
///
Expand All @@ -1277,19 +1328,15 @@ impl From<&mut ProjectContext> for Urls {
/// use cot::router::{Route, Router};
///
/// async fn infinite_loop(request: Request) -> cot::Result<Response> {
/// 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")
)
Expand Down Expand Up @@ -2695,6 +2742,132 @@ mod tests {
assert_eq!(url, "/test/123");
}

#[test]
fn 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", query: { page = 2, search = "cot" }).unwrap();

assert_eq!(url, "/?page=2&search=cot");
}

#[test]
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]);

let request = TestRequestBuilder::get("/").router(router).build();
let url = reverse!(request, "test", id = 123, query: { page = 2 }).unwrap();

assert_eq!(url, "/test/123?page=2");
}

#[test]
fn 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", query: { search = "hello world & cot" }).unwrap();

assert_eq!(url, "/?search=hello+world+%26+cot");
}

#[test]
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]))
.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 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 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 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");
}

#[test]
fn test_reverse_redirect_macro() {
let route = Route::with_handler_and_name("/test/{id}", MockHandler, "test");
Expand All @@ -2707,6 +2880,27 @@ mod tests {
assert_eq!(response.headers().get("location").unwrap(), "/test/123");
}

#[test]
fn 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()
}
Expand Down
Loading