Roteador

O que é Roteamento

Router define qual middleware e Handler processarão uma requisição HTTP. Esta é a funcionalidade mais fundamental e central no Salvo.

Internamente, um Router é essencialmente composto por uma série de filtros. Quando uma requisição chega, o roteador testa a si mesmo e seus descendentes em ordem, de cima para baixo, para ver se conseguem corresponder à requisição. Se uma correspondência for bem-sucedida, o middleware em toda a cadeia formada pelo roteador e seus roteadores descendentes é executado sequencialmente. Se durante o processamento, o status da Response for definido como erro (4XX, 5XX) ou redirecionamento (3XX), o middleware subsequente e o Handler serão ignorados. Você também pode chamar manualmente ctrl.skip_rest() para pular o middleware e Handler restantes.

Durante o processo de correspondência, existe um objeto de informação do caminho da URL, que pode ser considerado como um objeto que deve ser completamente consumido pelos filtros durante a correspondência. Se todos os filtros em um determinado Router corresponderem com sucesso, e esta informação do caminho da URL tiver sido totalmente consumida, é considerada uma "correspondência bem-sucedida".

Por exemplo:

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

É na verdade equivalente a:

Router::new()
    // PathFilter pode filtrar caminhos de requisição. Só corresponde com sucesso se o caminho da requisição contiver o segmento "articles".
    // Caso contrário, a correspondência falha. Por exemplo: /articles/123 corresponde com sucesso, enquanto /articles_list/123
    // contém "articles" mas falha na correspondência devido ao sufixo "_list".
    .filter(PathFilter::new("articles"))

    // Se a raiz corresponder com sucesso e o método da requisição for GET, o roteador filho interno pode corresponder com sucesso,
    // e a requisição é tratada por list_articles.
    .push(Router::new().filter(filters::get()).handle(list_articles))

    // Se a raiz corresponder com sucesso e o método da requisição for POST, o roteador filho interno pode corresponder com sucesso,
    // e a requisição é tratada por create_article.
    .push(Router::new().filter(filters::post()).handle(create_article));

Se acessar GET /articles/, é considerada uma correspondência bem-sucedida e list_articles é executado. No entanto, se acessar GET /articles/123, a correspondência da rota falha e retorna um erro 404 porque Router::with_path("articles") consome apenas a parte /articles da informação do caminho da URL, deixando a parte /123 não consumida, portanto a correspondência é considerada falha. Para conseguir uma correspondência bem-sucedida, a rota pode ser alterada para:

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

Aqui, {**} corresponde a qualquer caminho restante, então pode corresponder a GET /articles/123 e executar list_articles.

Definição Plana

Podemos definir rotas em um 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);

Definição em Árvore

Também podemos definir rotas em uma estrutura em árvore, que é a abordagem recomendada:

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 definição torna as definições de Router hierárquicas, claras e simples para projetos complexos.

Muitos métodos em Router retornam Self após serem chamados, facilitando a escrita de código encadeado. Às vezes, você precisa decidir como rotear com base em certas condições. O sistema de roteamento também fornece a função then, que é 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 exemplo significa que as rotas para criar, editar e excluir artigos só são adicionadas quando o servidor está em admin_mode.

Recuperando Parâmetros das Rotas

No código acima, {id} define um parâmetro. Podemos recuperar seu valor através da instância Request:

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

{id} corresponde a um segmento no caminho. Normalmente, o id de um artigo é apenas um número. Neste caso, podemos usar uma expressão regular para restringir a regra de correspondência para id, como r"{id|\d+}".

Para tipos numéricos, há um método ainda mais simples usando <id:num>. Notações específicas são:

  • {id:num} corresponde a qualquer número de caracteres de dígito.
  • {id:num[10]} corresponde exatamente a um número específico de caracteres de dígito; aqui, 10 significa que corresponde exatamente a 10 dígitos.
  • {id:num(..10)} corresponde a 1 a 9 caracteres de dígito.
  • {id:num(3..10)} corresponde a 3 a 9 caracteres de dígito.
  • {id:num(..=10)} corresponde a 1 a 10 caracteres de dígito.
  • {id:num(3..=10)} corresponde a 3 a 10 caracteres de dígito.
  • {id:num(10..)} corresponde a pelo menos 10 caracteres de dígito.

Você também pode corresponder a todos os segmentos de caminho restantes usando {**}, {*+} ou {*?}. Para melhor legibilidade do código, você pode adicionar nomes apropriados para tornar a semântica do caminho mais clara, por exemplo, {**file_path}.

  • {**}: Representa uma correspondência curinga onde a parte correspondente pode ser uma string vazia. Por exemplo, o caminho /files/{**rest_path} corresponde a /files, /files/abc.txt, /files/dir/abc.txt.
  • {*+}: Representa uma correspondência curinga onde a parte correspondente deve existir e não pode ser uma string vazia. Por exemplo, o caminho /files/{*+rest_path} NÃO corresponde a /files mas corresponde a /files/abc.txt, /files/dir/abc.txt.
  • {*?}: Representa uma correspondência curinga onde a parte correspondente pode ser uma string vazia, mas só pode conter um segmento de caminho. Por exemplo, o caminho /files/{*?rest_path} NÃO corresponde a /files/dir/abc.txt mas corresponde a /files, /files/abc.txt.

Múltiplas expressões podem ser combinadas para corresponder ao mesmo segmento de caminho, por exemplo, /articles/article_{id:num}/, /images/{name}.{ext}.

Adicionando Middleware

Middleware pode ser adicionado através da função hoop em um roteador:

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

Neste exemplo, o roteador raiz usa check_authed para verificar se o usuário atual está logado. Todos os roteadores descendentes são afetados por este middleware.

Se os usuários estão apenas navegando pelas informações de writer e artigos, podemos preferir que eles possam navegar sem fazer login. Podemos definir as rotas assim:

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

Mesmo que dois roteadores tenham a mesma definição de caminho path("articles"), eles ainda podem ser adicionados ao mesmo roteador pai.

Filtros

Router internamente usa filtros para determinar se uma rota corresponde. Filtros suportam operações lógicas básicas usando or ou and. Um roteador pode conter múltiplos filtros; quando todos os filtros correspondem com sucesso, a rota corresponde com sucesso.

A informação do caminho do site é uma estrutura em árvore, mas esta estrutura em árvore não é equivalente à estrutura em árvore que organiza os roteadores. Um caminho de site pode corresponder a múltiplos nós de roteador. Por exemplo, algum conteúdo sob o caminho articles/ pode exigir login para visualização, enquanto outro não. Podemos organizar subcaminhos que requerem verificação de login sob um roteador contendo middleware de verificação de login, e aqueles que não requerem verificação sob outro roteador sem ele:

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

Roteadores usam filtros para filtrar requisições e enviá-las para o middleware e Handler correspondentes para processamento.

path e method são dois dos filtros mais comumente usados. path é usado para corresponder informações de caminho; method é usado para corresponder ao Método da requisição, por exemplo, GET, POST, PATCH, etc.

Podemos conectar filtros de roteador usando and, or:

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

Filtro de Caminho

Filtros baseados em caminhos de requisição são os mais frequentemente usados. Filtros de caminho podem definir parâmetros, por exemplo:

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

Em um Handler, eles podem ser recuperados através da função get_param do 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 requisições com base no Method da requisição HTTP, por exemplo:

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

Aqui, get, patch, delete são todos filtros de Método. Isto é na verdade 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 certas expressões de correspondência que ocorrem frequentemente, podemos atribuir um nome curto via PathFilter::register_wisp_regex ou PathFilter::register_wisp_builder. Por exemplo, o formato GUID frequentemente aparece em caminhos. A maneira normal é escrever assim toda vez que a correspondência é necessária:

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

Escrever esta regex complexa toda vez é propenso a erros e torna o código menos legível. Você pode fazer isso em vez disso:

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

Você só precisa registrá-lo uma vez. Depois disso, você pode usar diretamente notação simples como {id:guid} para corresponder a GUIDs, simplificando a escrita do código.

Vindo de um Framework Web Baseado em Controller, Como Entender o Router?

As principais diferenças entre frameworks web projetados com roteamento (como Salvo) e frameworks tradicionais MVC ou projetados com Controller são:

  • Flexibilidade: O design de roteamento permite uma definição mais flexível dos fluxos de processamento de requisições, permitindo controle preciso sobre a lógica para cada caminho. Por exemplo, no Salvo você pode definir diretamente funções handler para caminhos específicos:

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

    No design Controller, você normalmente precisa definir uma classe controller primeiro, depois definir múltiplos métodos dentro da classe para lidar com diferentes requisições:

    @Controller
    public class ArticleController {
        @GetMapping("/articles")
        public List<Article> listArticles() { /* ... */ }
        
        @PostMapping("/articles")
        public Article createArticle(@RequestBody Article article) { /* ... */ }
    }
  • Integração de Middleware: Frameworks de roteamento geralmente fornecem uma maneira mais concisa de integrar middleware, permitindo que o middleware seja aplicado a rotas específicas. O middleware do Salvo pode ser aplicado precisamente a rotas particulares:

    Router::new()
        .push(
            Router::with_path("admin/articles")
                .hoop(admin_auth_middleware)  // Aplica middleware de autenticação apenas a rotas de admin
                .get(list_all_articles)
                .post(create_article),
        )
        .push(
            Router::with_path("articles")  // Rotas públicas não precisam de autenticação
                .get(list_public_articles),
        );
  • Organização de Código: O design de roteamento tende a organizar o código com base na funcionalidade ou endpoints da API, em vez da camada Model-View-Controller do MVC. O design de roteamento incentiva organizar o código de acordo com a funcionalidade do endpoint da API:

    // user_routes.rs - Rotas e lógica de manipulação relacionadas a usuários
    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 - Rotas e lógica de manipulação relacionadas a artigos
    pub fn article_routes() -> Router {
        Router::with_path("articles")
            .get(list_articles)
            .post(create_article)
    }
    
    // Combina rotas na aplicação principal
    let router = Router::new()
        .push(user_routes())
        .push(article_routes());
  • Leveza: O design de roteamento é tipicamente mais leve, reduzindo conceitos e restrições