Router

What is Routing

Router defines which middleware and Handler will process an HTTP request. This is the most fundamental and core functionality in Salvo.

Internally, a Router is essentially composed of a series of filters. When a request arrives, the router tests itself and its descendants in order, from top to bottom, to see if they can match the request. If a match is successful, the middleware on the entire chain formed by the router and its descendant routers are executed sequentially. If during processing, the Response status is set to an error (4XX, 5XX) or a redirect (3XX), subsequent middleware and Handler will be skipped. You can also manually call ctrl.skip_rest() to skip the remaining middleware and Handler.

During the matching process, there exists a URL path information object, which can be considered as an object that must be completely consumed by filters during matching. If all filters in a certain Router match successfully, and this URL path information has been fully consumed, it is considered a "successful match".

For example:

Router::with_path("articles").get(list_articles).post(create_article);

Is actually equivalent to:

Router::new()
    // PathFilter can filter request paths. It only matches successfully if the request path contains the "articles" segment.
    // Otherwise, the match fails. For example: /articles/123 matches successfully, while /articles_list/123
    // contains "articles" but fails to match because of the trailing "_list".
    .filter(PathFilter::new("articles"))

    // If the root matches successfully and the request method is GET, the inner child router can match successfully,
    // and the request is handled by list_articles.
    .push(Router::new().filter(filters::get()).handle(list_articles))

    // If the root matches successfully and the request method is POST, the inner child router can match successfully,
    // and the request is handled by create_article.
    .push(Router::new().filter(filters::post()).handle(create_article));

If accessing GET /articles/, it is considered a successful match, and list_articles is executed. However, if accessing GET /articles/123, the route match fails and returns a 404 error because Router::with_path("articles") only consumes the /articles part of the URL path information, leaving the /123 part unconsumed, thus the match is considered failed. To achieve a successful match, the route can be changed to:

Router::with_path("articles/{**}").get(list_articles).post(create_article);

Here, {**} matches any remaining path, so it can match GET /articles/123 and execute list_articles.

Flat Definition

We can define routes in a flat style:

Router::with_path("writers").get(list_writers).post(create_writer);
Router::with_path("writers/{id}").get(show_writer).patch(edit_writer).delete(delete_writer);
Router::with_path("writers/{id}/articles").get(list_writer_articles);

Tree-like Definition

We can also define routes in a tree-like structure, which is the recommended approach:

Router::with_path("writers")
    .get(list_writers)
    .post(create_writer)
    .push(
        Router::with_path("{id}")
            .get(show_writer)
            .patch(edit_writer)
            .delete(delete_writer)
            .push(Router::with_path("articles").get(list_writer_articles)),
    );

This form of definition makes Router definitions hierarchical, clear, and simple for complex projects.

Many methods in Router return Self after being called, facilitating chained code writing. Sometimes, you need to decide how to route based on certain conditions. The routing system also provides the then function, which is easy to use:

Router::new()
    .push(
        Router::with_path("articles")
            .get(list_articles)
            .push(Router::with_path("{id}").get(show_article))
            .then(|router|{
                if admin_mode() {
                    router.post(create_article).push(
                        Router::with_path("{id}").patch(update_article).delete(delete_writer)
                    )
                } else {
                    router
                }
            }),
    );

This example means that routes for creating, editing, and deleting articles are only added when the server is in admin_mode.

Retrieving Parameters from Routes

In the code above, {id} defines a parameter. We can retrieve its value through the Request instance:

#[handler]
async fn show_writer(req: &mut Request) {
    let id = req.param::<i64>("id").unwrap();
}

{id} matches a segment in the path. Normally, an article's id is just a number. In this case, we can use a regular expression to restrict the matching rule for id, like r"{id|\d+}".

For numeric types, there's an even simpler method using <id:num>. Specific notations are:

  • {id:num} matches any number of digit characters.
  • {id:num[10]} matches exactly a specific number of digit characters; here, 10 means it matches exactly 10 digits.
  • {id:num(..10)} matches 1 to 9 digit characters.
  • {id:num(3..10)} matches 3 to 9 digit characters.
  • {id:num(..=10)} matches 1 to 10 digit characters.
  • {id:num(3..=10)} matches 3 to 10 digit characters.
  • {id:num(10..)} matches at least 10 digit characters.

You can also match all remaining path segments using {**}, {*+}, or {*?}. For better code readability, you can add appropriate names to make the path semantics clearer, e.g., {**file_path}.

  • {**}: Represents a wildcard match where the matched part can be an empty string. For example, the path /files/{**rest_path} matches /files, /files/abc.txt, /files/dir/abc.txt.
  • {*+}: Represents a wildcard match where the matched part must exist and cannot be an empty string. For example, the path /files/{*+rest_path} does NOT match /files but matches /files/abc.txt, /files/dir/abc.txt.
  • {*?}: Represents a wildcard match where the matched part can be an empty string but can only contain one path segment. For example, the path /files/{*?rest_path} does NOT match /files/dir/abc.txt but matches /files, /files/abc.txt.

Multiple expressions can be combined to match the same path segment, e.g., /articles/article_{id:num}/, /images/{name}.{ext}.

Adding Middleware

Middleware can be added via the hoop function on a router:

Router::new()
    .hoop(check_authed)
    .path("writers")
    .get(list_writers)
    .post(create_writer)
    .push(
        Router::with_path("{id}")
            .get(show_writer)
            .patch(edit_writer)
            .delete(delete_writer)
            .push(Router::with_path("articles").get(list_writer_articles)),
    );

In this example, the root router uses check_authed to check if the current user is logged in. All descendant routers are affected by this middleware.

If users are only browsing writer information and articles, we might prefer they can browse without logging in. We can define the routes like this:

Router::new()
    .push(
        Router::new()
            .hoop(check_authed)
            .path("writers")
            .post(create_writer)
            .push(Router::with_path("{id}").patch(edit_writer).delete(delete_writer)),
    )
    .push(
        Router::with_path("writers").get(list_writers).push(
            Router::with_path("{id}")
                .get(show_writer)
                .push(Router::with_path("articles").get(list_writer_articles)),
        ),
    );

Even though two routers have the same path definition path("articles"), they can still be added to the same parent router.

Filters

Router internally uses filters to determine if a route matches. Filters support basic logical operations using or or and. A router can contain multiple filters; when all filters match successfully, the route matches successfully.

Website path information is a tree structure, but this tree structure is not equivalent to the tree structure organizing routers. A website path may correspond to multiple router nodes. For example, some content under the articles/ path may require login to view, while others do not. We can organize subpaths requiring login verification under a router containing login verification middleware, and those not requiring verification under another router without it:

Router::new()
    .push(
        Router::with_path("articles")
            .get(list_articles)
            .push(Router::new().path("{id}").get(show_article)),
    )
    .push(
        Router::with_path("articles")
            .hoop(auth_check)
            .post(list_articles)
            .push(Router::new().path("{id}").patch(edit_article).delete(delete_article)),
    );

Routers use filters to filter requests and send them to the corresponding middleware and Handler for processing.

path and method are two of the most commonly used filters. path is used to match path information; method is used to match the request's Method, e.g., GET, POST, PATCH, etc.

We can connect router filters using and, or:

Router::with_filter(filters::path("hello").and(filters::get()));

Path Filter

Filters based on request paths are the most frequently used. Path filters can define parameters, for example:

Router::with_path("articles/{id}").get(show_article);
Router::with_path("files/{**rest_path}").get(serve_file)

In a Handler, they can be retrieved via the Request object's get_param function:

#[handler]
pub async fn show_article(req: &mut Request) {
    let article_id = req.param::<i64>("id");
}

#[handler]
pub async fn serve_file(req: &mut Request) {
    let rest_path = req.param::<i64>("rest_path");
}

Method Filter

Filters requests based on the HTTP request's Method, for example:

Router::new().get(show_article).patch(update_article).delete(delete_article);

Here, get, patch, delete are all Method filters. This is actually equivalent to:

use salvo::routing::filter;

let mut root_router = Router::new();
let show_router = Router::with_filter(filters::get()).handle(show_article);
let update_router = Router::with_filter(filters::patch()).handle(update_article);
let delete_router = Router::with_filter(filters::get()).handle(delete_article);
Router::new().push(show_router).push(update_router).push(delete_router);

Custom Wisp

For certain frequently occurring matching expressions, we can assign a short name via PathFilter::register_wisp_regex or PathFilter::register_wisp_builder. For instance, GUID format often appears in paths. The normal way is to write it like this every time matching is needed:

Router::with_path("/articles/<id:/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/>");
Router::with_path("/users/<id:/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/>");

Writing this complex regex every time is error-prone and makes code less readable. You can do this instead:

use salvo::routing::filter::PathFilter;

#[tokio::main]
async fn main() {
    let guid = regex::Regex::new("[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}").unwrap();
    PathFilter::register_wisp_regex("guid", guid);
    Router::new()
        .push(Router::with_path("/articles/{id:guid}").get(show_article))
        .push(Router::with_path("/users/{id:guid}").get(show_user));
}

You only need to register it once. Afterwards, you can directly use simple notation like {id:guid} to match GUIDs, simplifying code writing.

Coming from a Controller-based Web Framework, How to Understand Router?

The main differences between routing-designed web frameworks (like Salvo) and traditional MVC or Controller-designed frameworks are:

  • Flexibility: Routing design allows for more flexible definition of request processing flows, enabling precise control over the logic for each path. For example, in Salvo you can directly define handler functions for specific paths:

    Router::with_path("articles").get(list_articles).post(create_article);

    In Controller design, you typically need to define a controller class first, then define multiple methods within the class to handle different requests:

    @Controller
    public class ArticleController {
        @GetMapping("/articles")
        public List<Article> listArticles() { /* ... */ }
        
        @PostMapping("/articles")
        public Article createArticle(@RequestBody Article article) { /* ... */ }
    }
  • Middleware Integration: Routing frameworks usually provide a more concise way to integrate middleware, allowing middleware to be applied to specific routes. Salvo's middleware can be precisely applied to particular routes:

    Router::new()
        .push(
            Router::with_path("admin/articles")
                .hoop(admin_auth_middleware)  // Apply auth middleware only to admin routes
                .get(list_all_articles)
                .post(create_article),
        )
        .push(
            Router::with_path("articles")  // Public routes don't need auth
                .get(list_public_articles),
        );
  • Code Organization: Routing design tends to organize code based on functionality or API endpoints, rather than the Model-View-Controller layering of MVC. Routing design encourages organizing code according to API endpoint functionality:

    // user_routes.rs - User-related routes and handling logic
    pub fn user_routes() -> Router {
        Router::with_path("users")
            .get(list_users)
            .post(create_user)
            .push(Router::with_path("{id}").get(get_user).delete(delete_user))
    }
    
    // article_routes.rs - Article-related routes and handling logic
    pub fn article_routes() -> Router {
        Router::with_path("articles")
            .get(list_articles)
            .post(create_article)
    }
    
    // Combine routes in the main application
    let router = Router::new()
        .push(user_routes())
        .push(article_routes());
  • Lightweight: Routing design is typically more lightweight, reducing framework-enforced concepts and constraints. You can introduce only the components you need without following a strict framework structure.

Routing design makes API development more intuitive, especially suitable for building modern microservices and RESTful APIs. In frameworks like Salvo, routing is a core concept that directly reflects the structure and behavior of the API, making the code easier to understand and maintain. In contrast, traditional Controller design often requires more configuration and conventions to achieve the same functionality.

Router Struct Method Overview

CategoryMethodDescription
Creation/Accessnew()Creates a new router
routers()/routers_mut()Gets a reference/mutable reference to child routers
hoops()/hoops_mut()Gets a reference/mutable reference to middleware
filters()/filters_mut()Gets a reference/mutable reference to filters
Router Organizationunshift()Inserts a child router at the beginning
insert()Inserts a child router at a specified position
push()Adds a child router
append()Adds multiple child routers
then()Customizes router chain configuration
Middlewarewith_hoop()/hoop()Creates/adds middleware
with_hoop_when()/hoop_when()Creates/adds conditional middleware
Path Filteringwith_path()/path()Creates/adds a path filter
with_filter()/filter()Creates/adds a filter
with_filter_fn()/filter_fn()Creates/adds a function filter
Network Filteringscheme()Adds a protocol filter
host()/with_host()Adds/creates a host filter
port()/with_port()Adds/creates a port filter
HTTP Methodsget()/post()/put()Creates a route for the corresponding HTTP method
delete()/patch()/head()/options()Creates a route for the corresponding HTTP method
Handlergoal()Sets the route handler
Match Detectiondetect()Detects if the router matches the request