Router

¿Qué es el Enrutamiento?

Router define qué middleware y Handler procesarán una solicitud HTTP. Esta es la funcionalidad más fundamental y central en Salvo.

Internamente, un Router está esencialmente compuesto por una serie de filtros. Cuando llega una solicitud, el router prueba a sí mismo y a sus descendientes en orden, de arriba a abajo, para ver si pueden coincidir con la solicitud. Si una coincidencia es exitosa, el middleware en toda la cadena formada por el router y sus routers descendientes se ejecuta secuencialmente. Si durante el procesamiento, el estado de la Response se establece en un error (4XX, 5XX) o una redirección (3XX), el middleware y Handler posteriores se omitirán. También puedes llamar manualmente a ctrl.skip_rest() para omitir el middleware y Handler restantes.

Durante el proceso de coincidencia, existe un objeto de información de ruta URL, que puede considerarse como un objeto que debe ser completamente consumido por los filtros durante la coincidencia. Si todos los filtros en un Router determinado coinciden con éxito, y esta información de ruta URL ha sido completamente consumida, se considera una "coincidencia exitosa".

Por ejemplo:

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

Es en realidad equivalente a:

Router::new()
    // PathFilter puede filtrar rutas de solicitud. Solo coincide con éxito si la ruta de solicitud contiene el segmento "articles".
    // De lo contrario, la coincidencia falla. Por ejemplo: /articles/123 coincide con éxito, mientras que /articles_list/123
    // contiene "articles" pero falla en coincidir debido al "_list" final.
    .filter(PathFilter::new("articles"))

    // Si la raíz coincide con éxito y el método de solicitud es GET, el router hijo interno puede coincidir con éxito,
    // y la solicitud es manejada por list_articles.
    .push(Router::new().filter(filters::get()).handle(list_articles))

    // Si la raíz coincide con éxito y el método de solicitud es POST, el router hijo interno puede coincidir con éxito,
    // y la solicitud es manejada por create_article.
    .push(Router::new().filter(filters::post()).handle(create_article));

Si se accede a GET /articles/, se considera una coincidencia exitosa y se ejecuta list_articles. Sin embargo, si se accede a GET /articles/123, la coincidencia de ruta falla y devuelve un error 404 porque Router::with_path("articles") solo consume la parte /articles de la información de ruta URL, dejando la parte /123 sin consumir, por lo tanto, la coincidencia se considera fallida. Para lograr una coincidencia exitosa, la ruta se puede cambiar a:

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

Aquí, {**} coincide con cualquier ruta restante, por lo que puede coincidir con GET /articles/123 y ejecutar list_articles.

Definición Plana

Podemos definir rutas en un estilo plano:

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);

Definición en Forma de Árbol

También podemos definir rutas en una estructura de árbol, que es el enfoque recomendado:

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)),
    );

Esta forma de definición hace que las definiciones de Router sean jerárquicas, claras y simples para proyectos complejos.

Muchos métodos en Router devuelven Self después de ser llamados, facilitando la escritura de código encadenado. A veces, necesitas decidir cómo enrutar basándote en ciertas condiciones. El sistema de enrutamiento también proporciona la función then, que es fácil de usar:

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
                }
            }),
    );

Este ejemplo significa que las rutas para crear, editar y eliminar artículos solo se agregan cuando el servidor está en admin_mode.

Recuperando Parámetros de las Rutas

En el código anterior, {id} define un parámetro. Podemos recuperar su valor a través de la instancia Request:

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

{id} coincide con un segmento en la ruta. Normalmente, el id de un artículo es solo un número. En este caso, podemos usar una expresión regular para restringir la regla de coincidencia para id, como r"{id|\d+}".

Para tipos numéricos, hay un método aún más simple usando <id:num>. Las notaciones específicas son:

  • {id:num} coincide con cualquier número de caracteres de dígitos.
  • {id:num[10]} coincide exactamente con un número específico de caracteres de dígitos; aquí, 10 significa que coincide exactamente con 10 dígitos.
  • {id:num(..10)} coincide con 1 a 9 caracteres de dígitos.
  • {id:num(3..10)} coincide con 3 a 9 caracteres de dígitos.
  • {id:num(..=10)} coincide con 1 a 10 caracteres de dígitos.
  • {id:num(3..=10)} coincide con 3 a 10 caracteres de dígitos.
  • {id:num(10..)} coincide con al menos 10 caracteres de dígitos.

También puedes coincidir con todos los segmentos de ruta restantes usando {**}, {*+}, o {*?}. Para una mejor legibilidad del código, puedes agregar nombres apropiados para hacer la semántica de la ruta más clara, por ejemplo, {**file_path}.

  • {**}: Representa una coincidencia comodín donde la parte coincidente puede ser una cadena vacía. Por ejemplo, la ruta /files/{**rest_path} coincide con /files, /files/abc.txt, /files/dir/abc.txt.
  • {*+}: Representa una coincidencia comodín donde la parte coincidente debe existir y no puede ser una cadena vacía. Por ejemplo, la ruta /files/{*+rest_path} NO coincide con /files pero coincide con /files/abc.txt, /files/dir/abc.txt.
  • {*?}: Representa una coincidencia comodín donde la parte coincidente puede ser una cadena vacía pero solo puede contener un segmento de ruta. Por ejemplo, la ruta /files/{*?rest_path} NO coincide con /files/dir/abc.txt pero coincide con /files, /files/abc.txt.

Múltiples expresiones pueden combinarse para coincidir con el mismo segmento de ruta, por ejemplo, /articles/article_{id:num}/, /images/{name}.{ext}.

Agregando Middleware

El middleware se puede agregar a través de la función hoop en un 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)),
    );

En este ejemplo, el router raíz usa check_authed para verificar si el usuario actual ha iniciado sesión. Todos los routers descendientes se ven afectados por este middleware.

Si los usuarios solo están navegando por la información de writer y artículos, podríamos preferir que puedan navegar sin iniciar sesión. Podemos definir las rutas así:

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)),
        ),
    );

Aunque dos routers tienen la misma definición de ruta path("articles"), aún pueden agregarse al mismo router padre.

Filtros

Router internamente usa filtros para determinar si una ruta coincide. Los filtros admiten operaciones lógicas básicas usando or o and. Un router puede contener múltiples filtros; cuando todos los filtros coinciden con éxito, la ruta coincide con éxito.

La información de ruta del sitio web es una estructura de árbol, pero esta estructura de árbol no es equivalente a la estructura de árbol que organiza los routers. Una ruta de sitio web puede corresponder a múltiples nodos de router. Por ejemplo, algunos contenidos bajo la ruta articles/ pueden requerir inicio de sesión para ver, mientras que otros no. Podemos organizar las subrutas que requieren verificación de inicio de sesión bajo un router que contenga middleware de verificación de inicio de sesión, y aquellas que no requieren verificación bajo otro router sin ella:

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)),
    );

Los routers usan filtros para filtrar solicitudes y enviarlas al middleware y Handler correspondientes para su procesamiento.

path y method son dos de los filtros más comúnmente usados. path se usa para coincidir con información de ruta; method se usa para coincidir con el Método de solicitud, por ejemplo, GET, POST, PATCH, etc.

Podemos conectar filtros de router usando and, or:

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

Filtro de Ruta

Los filtros basados en rutas de solicitud son los más frecuentemente usados. Los filtros de ruta pueden definir parámetros, por ejemplo:

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

En un Handler, se pueden recuperar a través de la función get_param del objeto Request:

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

Filtro de Método

Filtra solicitudes basándose en el Method de la solicitud HTTP, por ejemplo:

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

Aquí, get, patch, delete son todos filtros de Método. Esto es en realidad equivalente a:

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);

Wisp Personalizado

Para ciertas expresiones de coincidencia que ocurren con frecuencia, podemos asignar un nombre corto a través de PathFilter::register_wisp_regex o PathFilter::register_wisp_builder. Por ejemplo, el formato GUID a menudo aparece en rutas. La forma normal es escribirlo así cada vez que se necesita coincidencia:

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}/>");

Escribir esta expresión regular compleja cada vez es propenso a errores y hace que el código sea menos legible. Puedes hacer esto en su lugar:

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));
}

Solo necesitas registrarlo una vez. Posteriormente, puedes usar directamente notación simple como {id:guid} para coincidir con GUIDs, simplificando la escritura del código.

Viniendo de un Framework Web Basado en Controladores, ¿Cómo Entender Router?

Las principales diferencias entre los frameworks web diseñados con enrutamiento (como Salvo) y los frameworks tradicionales MVC o diseñados con Controladores son:

  • Flexibilidad: El diseño de enrutamiento permite una definición más flexible de los flujos de procesamiento de solicitudes, permitiendo un control preciso sobre la lógica para cada ruta. Por ejemplo, en Salvo puedes definir directamente funciones manejadoras para rutas específicas:

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

    En el diseño de Controlador, normalmente necesitas definir primero una clase controladora, luego definir múltiples métodos dentro de la clase para manejar diferentes solicitudes:

    @Controller
    public class ArticleController {
        @GetMapping("/articles")
        public List<Article> listArticles() { /* ... */ }
        
        @PostMapping("/articles")
        public Article createArticle(@RequestBody Article article) { /* ... */ }
    }
  • Integración de Middleware: Los frameworks de enrutamiento generalmente proporcionan una forma más concisa de integrar middleware, permitiendo que el middleware se aplique a rutas específicas. El middleware de Salvo se puede aplicar con precisión a rutas particulares:

    Router::new()
        .push(
            Router::with_path("admin/articles")
                .hoop(admin_auth_middleware)  // Aplica middleware de autenticación solo a rutas de administrador
                .get(list_all_articles)
                .post(create_article),
        )
        .push(
            Router::with_path("articles")  // Las rutas públicas no necesitan autenticación
                .get(list_public_articles),
        );
  • Organización del Código: El diseño de enrutamiento tiende a organizar el código basándose en la funcionalidad o los endpoints de la API, en lugar de la capa Modelo-Vista-Controlador de MVC. El diseño de enrutamiento fomenta organizar el código según la funcionalidad del endpoint de la API:

    // user_routes.rs - Rutas y lógica de manejo relacionadas con usuarios
    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 - Rutas y lógica de manejo relacionadas con artículos
    pub fn article_routes() -> Router {
        Router::with_path("articles")
            .get(list_articles)
            .post(create_article)
    }
    
    // Combina rutas en la aplicación principal
    let router = Router::new()
        .push(user_routes())
        .push(article_routes());
  • Ligereza: El diseño de enrutamiento es típicamente más ligero, reduciendo conceptos y restricciones impuestos por el framework. Puedes introducir solo los componentes que necesitas sin seguir una estructura estricta del framework.

El diseño de enrutamiento hace