Skip to content

Commit

Permalink
Added prefix and route nesting to AppRoutes (#1241)
Browse files Browse the repository at this point in the history
* Added prefix and route nesting to AppRoutes

- replaced snapshot testing with normal asserts

* Applied review suggestions

* remove `ignore` attribute

* Added documentation

---------

Co-authored-by: Elad Kaplan <[email protected]>
  • Loading branch information
DenuxPlays and kaplanelad authored Feb 14, 2025
1 parent 5de4140 commit ef72496
Show file tree
Hide file tree
Showing 22 changed files with 277 additions and 41 deletions.
64 changes: 64 additions & 0 deletions docs-site/content/docs/the-app/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,70 @@ $ cargo loco routes

This command will provide you with a comprehensive overview of the controllers currently registered in your system.

## AppRoutes

`AppRoutes` is a core component of the `Loco` framework that helps you manage and organize your application's routes. It provides a convenient way to add, prefix, and collect routes from different controllers.

### Features

- **Add Routes**: Easily add routes from different controllers.
- **Prefix Routes**: Apply a common prefix to a group of routes.
- **Collect Routes**: Gather all routes into a single collection for further processing.

### Examples

#### Adding Routes

You can add routes from different controllers to `AppRoutes`:

```rust
use loco_rs::controller::AppRoutes;
use loco_rs::prelude::*;
use axum::routing::get;

fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::empty()
.add_route(Routes::new().add("/", get(home_handler)))
.add_route(Routes::new().add("/about", get(about_handler)))
}
```

### Prefixing Routes

Apply a common prefix to a group of routes:

```rust
use loco_rs::controller::AppRoutes;
use loco_rs::prelude::*;
use axum::routing::get;

fn routes(_ctx: &AppContext) -> AppRoutes {
AppRoutes::empty()
.prefix("/api")
.add_route(Routes::new().add("/users", get(users_handler)))
.add_route(Routes::new().add("/posts", get(posts_handler)))
}
```

### Nesting Routes

AppRoutes allows you to nest routes, making it easier to organize and manage complex route hierarchies.
This is particularly useful when you have a set of related routes that share a common prefix.

```rust
use loco_rs::controller::AppRoutes;
use loco_rs::prelude::*;
use axum::routing::get;

fn routes(_ctx: &AppContext) -> AppRoutes {
let route = Routes::new().add("/", get(|| async { "notes" }));
AppRoutes::with_default_routes()
.prefix("api")
.add_route(controllers::auth::routes())
.nest_prefix("v1")
.nest_route("/notes", route)
}
```

## Adding state

Expand Down
215 changes: 181 additions & 34 deletions src/controller/app_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ impl fmt::Display for ListRoutes {
let actions_str = self
.actions
.iter()
.map(std::string::ToString::to_string)
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");

Expand Down Expand Up @@ -68,34 +68,32 @@ impl AppRoutes {

#[must_use]
pub fn collect(&self) -> Vec<ListRoutes> {
let base_url_prefix = self
.get_prefix()
// add a leading slash forcefully. Axum routes must start with a leading slash.
// if we have double leading slashes - it will get normalized into a single slash later
.map_or("/".to_string(), |url| format!("/{}", url.as_str()));

self.get_routes()
.iter()
.flat_map(|controller| {
let mut uri_parts = vec![base_url_prefix.clone()];
if let Some(prefix) = controller.prefix.as_ref() {
uri_parts.push(prefix.to_string());
}
let uri_parts = controller
.prefix
.as_ref()
.map_or_else(Vec::new, |prefix| vec![prefix.to_string()]);

controller.handlers.iter().map(move |handler| {
let mut parts = uri_parts.clone();
parts.push(handler.uri.to_string());
let joined_parts = parts.join("/");

let normalized = get_normalize_url().replace_all(&joined_parts, "/");
let uri = if normalized == "/" {
let mut uri = if normalized == "/" {
normalized.to_string()
} else {
normalized.strip_suffix('/').map_or_else(
|| normalized.to_string(),
std::string::ToString::to_string,
)
normalized
.strip_suffix('/')
.map_or_else(|| normalized.to_string(), ToString::to_string)
};

if !uri.starts_with('/') {
uri.insert(0, '/');
}

ListRoutes {
uri,
actions: handler.actions.clone(),
Expand Down Expand Up @@ -132,23 +130,137 @@ impl AppRoutes {
/// ```
#[must_use]
pub fn prefix(mut self, prefix: &str) -> Self {
self.prefix = Some(prefix.to_string());
let mut prefix = prefix.to_owned();
if !prefix.ends_with('/') {
prefix.push('/');
}
if !prefix.starts_with('/') {
prefix.insert(0, '/');
}

self.prefix = Some(prefix);

self
}

/// Set a nested prefix for the routes. This prefix will be appended to any existing prefix.
///
/// # Example
///
/// In the following example, you are adding `api` as a prefix and then nesting `v1` within it:
///
/// ```rust
/// use loco_rs::controller::AppRoutes;
/// use loco_rs::tests_cfg::*;
///
/// let app_routes = AppRoutes::with_default_routes()
/// .prefix("api")
/// .add_route(controllers::auth::routes())
/// .nest_prefix("v1")
/// .add_route(controllers::home::routes());
///
/// // This will result in routes like `/api/auth` and `/api/v1/home`
/// ```
#[must_use]
pub fn nest_prefix(mut self, prefix: &str) -> Self {
let prefix = self.prefix.as_ref().map_or_else(
|| prefix.to_owned(),
|old_prefix| format!("{old_prefix}{prefix}"),
);
self = self.prefix(&prefix);

self
}

/// Set a nested route with a prefix. This route will be added with the specified prefix.
/// The prefix will only be applied to the routes given in this function.
///
/// # Example
///
/// In the following example, you are adding `api` as a prefix and then nesting a route within it:
///
/// ```rust
/// use axum::routing::get;
/// use loco_rs::controller::{AppRoutes, Routes};
///
/// let route = Routes::new().add("/notes", get(|| async { "notes" }));
/// let app_routes = AppRoutes::with_default_routes()
/// .prefix("api")
/// .nest_route("v1", route);
///
/// // This will result in routes with the prefix `/api/v1/notes`
/// ```
#[must_use]
pub fn nest_route(mut self, prefix: &str, route: Routes) -> Self {
let old_prefix = self.prefix.clone();
self = self.nest_prefix(prefix);
self = self.add_route(route);
self.prefix = old_prefix;

self
}

/// Set multiple nested routes with a prefix. These routes will be added with the specified prefix.
/// The prefix will only be applied to the routes given in this function.
///
/// # Example
///
/// In the following example, you are adding `api` as a prefix and then nesting multiple routes within it:
///
/// ```rust
/// use axum::routing::get;
/// use loco_rs::controller::{AppRoutes, Routes};
///
/// let routes = vec![
/// Routes::new().add("/notes", get(|| async { "notes" })),
/// Routes::new().add("/users", get(|| async { "users" })),
/// ];
/// let app_routes = AppRoutes::with_default_routes()
/// .prefix("api")
/// .nest_routes("v1", routes);
///
/// // This will result in routes with the prefix `/api/v1/notes` and `/api/v1/users`
/// ```
#[must_use]
pub fn nest_routes(mut self, prefix: &str, routes: Vec<Routes>) -> Self {
let old_prefix = self.prefix.clone();
self = self.nest_prefix(prefix);
self = self.add_routes(routes);
self.prefix = old_prefix;

self
}

/// Add a single route.
#[must_use]
pub fn add_route(mut self, route: Routes) -> Self {
pub fn add_route(mut self, mut route: Routes) -> Self {
let routes_prefix = {
if let Some(mut prefix) = self.prefix.clone() {
let routes_prefix = route.prefix.clone().unwrap_or_default();

prefix.push_str(routes_prefix.as_str());
Some(prefix)
} else {
route.prefix.clone()
}
};

if let Some(prefix) = routes_prefix {
route = route.prefix(prefix.as_str());
}

self.routes.push(route);

self
}

/// Add multiple routes.
#[must_use]
pub fn add_routes(mut self, mounts: Vec<Routes>) -> Self {
for mount in mounts {
self.routes.push(mount);
self = self.add_route(mount);
}

self
}

Expand Down Expand Up @@ -206,21 +318,23 @@ impl AppRoutes {

#[cfg(test)]
mod tests {

use super::*;
use crate::{prelude::*, tests_cfg};
use axum::http::Method;
use insta::assert_debug_snapshot;
use rstest::rstest;
use std::vec;
use tower::ServiceExt;

use super::*;
use crate::{prelude::*, tests_cfg};

async fn action() -> Result<Response> {
format::json("loco")
}

#[test]
fn can_load_app_route_from_default() {
for route in AppRoutes::with_default_routes().collect() {
let routes = AppRoutes::with_default_routes().collect();

for route in routes {
assert_debug_snapshot!(
format!("[{}]", route.uri.replace('/', "[slash]")),
format!("{:?} {}", route.actions, route.uri)
Expand Down Expand Up @@ -279,19 +393,52 @@ mod tests {
}
}

#[test]
fn can_nest_prefix() {
let app_router = AppRoutes::empty().prefix("api").nest_prefix("v1");

assert_eq!(app_router.get_prefix().unwrap(), "/api/v1/");
}

#[test]
fn can_nest_route() {
let route = Routes::new().add("/notes", get(action));
let app_router = AppRoutes::empty().prefix("api").nest_route("v1", route);

let routes = app_router.collect();
assert_eq!(routes.len(), 1);
assert_eq!(routes[0].uri, "/api/v1/notes");
}

#[test]
fn can_nest_routes() {
let routes = vec![
Routes::new().add("/notes", get(action)),
Routes::new().add("/users", get(action)),
];
let app_router = AppRoutes::empty().prefix("api").nest_routes("v1", routes);

for route in app_router.collect() {
assert_debug_snapshot!(
format!("[{}]", route.uri.replace('/', "[slash]")),
format!("{:?} {}", route.actions, route.uri)
);
}
}

#[rstest]
#[case(axum::http::Method::GET, get(action))]
#[case(axum::http::Method::POST, post(action))]
#[case(axum::http::Method::DELETE, delete(action))]
#[case(axum::http::Method::HEAD, head(action))]
#[case(axum::http::Method::OPTIONS, options(action))]
#[case(axum::http::Method::PATCH, patch(action))]
#[case(axum::http::Method::POST, post(action))]
#[case(axum::http::Method::PUT, put(action))]
#[case(axum::http::Method::TRACE, trace(action))]
#[case(Method::GET, get(action))]
#[case(Method::POST, post(action))]
#[case(Method::DELETE, delete(action))]
#[case(Method::HEAD, head(action))]
#[case(Method::OPTIONS, options(action))]
#[case(Method::PATCH, patch(action))]
#[case(Method::POST, post(action))]
#[case(Method::PUT, put(action))]
#[case(Method::TRACE, trace(action))]
#[tokio::test]
async fn can_request_method(
#[case] http_method: axum::http::Method,
#[case] http_method: Method,
#[case] method: axum::routing::MethodRouter<AppContext>,
) {
let router_without_prefix = Routes::new().add("/loco", method);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: src/controller/app_routes.rs
assertion_line: 370
expression: "format!(\"{:?} {}\", route.actions, route.uri)"
---
"[GET] /"
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: src/controller/app_routes.rs
assertion_line: 337
expression: "format!(\"{:?} {}\", route.actions, route.uri)"
---
"[GET] /_health"
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: src/controller/app_routes.rs
assertion_line: 337
expression: "format!(\"{:?} {}\", route.actions, route.uri)"
---
"[GET] /_ping"
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: src/controller/app_routes.rs
assertion_line: 388
expression: "format!(\"{:?} {}\", route.actions, route.uri)"
---
"[GET] /api/loco-rs"
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
source: src/controller/app_routes.rs
assertion_line: 388
expression: "format!(\"{:?} {}\", route.actions, route.uri)"
---
"[GET] /api/loco"
Loading

0 comments on commit ef72496

Please sign in to comment.