Axum é um framework web moderno e ergonômico para Rust, construído sobre o ecossistema tokio e tower. Ele combina a segurança e performance de Rust com uma API expressiva, inspirada em frameworks como Express (Node.js) e Flask (Python). Nesta aula, vamos introduzir os conceitos fundamentais para construir um servidor web com axum: rotas, handlers, extractors e respostas. Ao final, você terá uma base sólida para criar aplicações web reais, desde APIs REST até páginas dinâmicas.

Axum se destaca por sua composabilidade: cada parte do servidor (como middlewares, rotas e extractors) é um componente reutilizável que pode ser combinado de forma flexível. Além disso, ele é totalmente assíncrono, aproveitando o modelo de concorrência do tokio, o que o torna ideal para aplicações de alta performance. Vamos começar configurando um projeto e construindo nosso primeiro servidor.

Rotas

Rotas são os caminhos que definem como o servidor responde às requisições HTTP. Em axum, uma rota associa um método HTTP (GET, POST, PUT, DELETE, etc.) e um padrão de path a um handler. O mecanismo de roteamento é baseado no crate `matchit`, que oferece alta performance e suporte a parâmetros dinâmicos.

Para definir rotas, usamos o método `route` do objeto `Router`. Cada rota recebe o path (como string) e um handler. O path pode conter segmentos fixos e segmentos dinâmicos, que são capturados e passados ao handler. Por exemplo, `/users/:id` captura o valor do segmento após `/users/` e o disponibiliza como um extractor.

Exemplo básico de rotas:

use axum::{Router, routing::get};

async fn root() -> &'static str {
    "Olá, mundo!"
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    println!("Servidor rodando em http://127.0.0.1:3000");
    axum::serve(listener, app).await.unwrap();
}

Neste exemplo, a rota raiz (`/`) responde a requisições GET com o texto "Olá, mundo!". O método `get` é um atalho para `MethodRouter::get`, mas podemos usar `any` para aceitar qualquer método, ou combinar métodos como `get(...).post(...)`.

Rotas com parâmetros dinâmicos são igualmente simples. Vamos criar uma rota que recebe um ID numérico:

use axum::{Router, routing::get, extract::Path};

async fn show_user(Path(id): Path<u32>) -> String {
    format!("Usuário {}", id)
}

let app = Router::new()
    .route("/users/:id", get(show_user));

Note que o handler recebe um `Path` como argumento, que é um extractor que extrai o valor do segmento dinâmico. O tipo `u32` indica que o valor deve ser um inteiro sem sinal; se não for, axum retornará um erro 400 automaticamente.

Handlers

Handlers são funções assíncronas que processam uma requisição e retornam uma resposta. Em axum, um handler pode ser uma função assíncrona que recebe zero ou mais extractors como argumentos e retorna algo que implemente `IntoResponse`. Isso inclui tipos como `String`, `&'static str`, `Html`, `Json`, `StatusCode`, entre outros.

A assinatura de um handler é flexível: você pode combinar extractors de várias formas, e axum se encarrega de extrair os dados da requisição e chamar a função com os argumentos corretos. Isso elimina muito código boilerplate e torna os handlers limpos e declarativos.

Vejamos um handler que retorna HTML e outro que retorna JSON:

use axum::{response::Html, Json};
use serde_json::json;

async fn page() -> Html<&'static str> {
    Html("<h1>Página inicial</h1>")
}

async fn api_info() -> Json<serde_json::Value> {
    Json(json!({
        "name": "axum",
        "version": "0.7"
    }))
}

Note que o handler `api_info` retorna `Json` que é serializado como JSON na resposta. O tipo `Json` é um extractor e também um tipo de resposta, o que facilita a criação de APIs.

Handlers podem ser combinados em um mesmo path com diferentes métodos. Por exemplo, para uma rota que aceita GET e POST, podemos encadear:

use axum::{routing::{get, post}, Router};

async fn list_items() -> &'static str {
    "Lista de itens"
}

async fn create_item() -> &'static str {
    "Item criado"
}

let app = Router::new()
    .route("/items", get(list_items).post(create_item));

Se um método não for suportado, axum retorna automaticamente um 405 Method Not Allowed.

Extractors

Extractors são tipos que implementam a trait `FromRequest` (ou `FromRequestParts` para partes da requisição). Eles permitem extrair informações da requisição de forma tipada e segura. Axum já fornece vários extractors prontos, como `Path`, `Query`, `Json`, `State`, `HeaderMap`, `UserAgent`, entre outros.

O extractor `Path` captura parâmetros da rota. O extractor `Query` extrai parâmetros da query string. O extractor `Json` desserializa o corpo da requisição para um tipo `T` que implemente `serde::Deserialize`. O extractor `State` permite acessar estado compartilhado da aplicação, como uma conexão com banco de dados.

Exemplo com `Query` e `Json`:

use axum::{extract::Query, Json};
use serde::Deserialize;

#[derive(Deserialize)]
struct SearchParams {
    q: String,
    page: Option<u32>,
}

async fn search(Query(params): Query<SearchParams>) -> String {
    format!("Buscando por '{}' na página {:?}", params.q, params.page)
}

#[derive(Deserialize)]
struct CreateUser {
    name: String,
    email: String,
}

async fn create_user(Json(payload): Json<CreateUser>) -> String {
    format!("Criando usuário {} com email {}", payload.name, payload.email)
}

É importante notar que a ordem dos extractors importa: extractors que consomem o corpo da requisição, como `Json`, devem ser os últimos argumentos do handler, pois consomem o corpo. Extratores como `Path` e `Query` podem ser usados em qualquer posição.

Para usar `State`, primeiro precisamos adicionar o estado ao router com `.with_state(state)`. O tipo do estado deve ser `Clone + Send + Sync + 'static`. Exemplo:

use axum::{extract::State, Router};
use std::sync::Arc;

struct AppState {
    db: Arc<dyn Database>, // suponha um trait Database
}

async fn get_user(State(state): State<Arc<AppState>>, Path(id): Path<u32>) -> String {
    // use state.db para buscar o usuário
    format!("Buscando usuário {}", id)
}

let state = Arc::new(AppState { db: /* ... */ });
let app = Router::new()
    .route("/users/:id", get(get_user))
    .with_state(state);

Respostas

Em axum, qualquer tipo que implemente `IntoResponse` pode ser retornado de um handler. Isso inclui tipos primitivos como `String`, `&'static str`, `StatusCode`, `Vec`, e também tipos mais complexos como `Html`, `Json`, `Redirect`, `Response`, entre outros. A trait `IntoResponse` permite que você defina como converter seu tipo em uma resposta HTTP.

O tipo `Response` do `http` crate é o tipo base. Você pode construí-lo manualmente usando `Response::builder()`, mas normalmente usamos os atalhos fornecidos.

Exemplo de resposta com status customizado:

use axum::{response::IntoResponse, http::StatusCode};

async fn not_found() -> impl IntoResponse {
    (StatusCode::NOT_FOUND, "Recurso não encontrado")
}

Aqui retornamos uma tupla `(StatusCode, &'static str)`, que também implementa `IntoResponse`. Isso é útil para respostas com corpo e status.

Para retornar JSON, usamos `Json` que serializa o valor `T` com `serde_json`. Para HTML, usamos `Html` que define o content-type como `text/html`. Para redirecionamentos, temos `Redirect::to("/outro")`.

Exemplo de resposta com `Redirect`:

use axum::response::Redirect;

async fn old_page() -> Redirect {
    Redirect::to("/new-page")
}

Além disso, você pode implementar `IntoResponse` para seus próprios tipos, permitindo que handlers retornem tipos personalizados de forma limpa. Isso é especialmente útil em aplicações maiores para centralizar a lógica de resposta.

Boas práticas

Ao trabalhar com axum, algumas práticas recomendadas ajudam a manter o código organizado e escalável:

  • Use módulos para separar rotas, handlers e estado da aplicação.
  • Aproveite os extractors para evitar manipulação manual de requisições.
  • Prefira retornar tipos de resposta específicos (como `Json`, `Html`) em vez de `String` quando o conteúdo tiver um formato definido.
  • Utilize `State` para compartilhar recursos como conexões de banco de dados, evitando criar novas conexões a cada requisição.
  • Trate erros de forma consistente: defina um tipo de erro que implemente `IntoResponse` para retornar respostas de erro padronizadas.
  • Use o `Router` para agrupar rotas relacionadas, como `/users` e `/posts`, e depois combine-os com `nest`.

Referências

Exercícios

  1. Crie um servidor axum com uma rota `/` que retorne a string "Bem-vindo ao axum!".

    ✓ Resposta:
    use axum::{Router, routing::get};
    
    async fn root() -> &'static str {
        "Bem-vindo ao axum!"
    }
    
    #[tokio::main]
    async fn main() {
        let app = Router::new().route("/", get(root));
        let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
        axum::serve(listener, app).await.unwrap();
    }
  2. Adicione uma rota `/users/:id` que retorne o ID como uma string, por exemplo, "User ID: 42".

    ✓ Resposta:
    use axum::{Router, routing::get, extract::Path};
    
    async fn user(Path(id): Path<u32>) -> String {
        format!("User ID: {}", id)
    }
    
    let app = Router::new().route("/users/:id", get(user));
  3. Crie um handler que receba um parâmetro de query `q` e retorne "Searching for: {q}". Use o extractor `Query`.

    ✓ Resposta:
    use axum::{extract::Query, Router, routing::get};
    use serde::Deserialize;
    
    #[derive(Deserialize)]
    struct SearchParams {
        q: String,
    }
    
    async fn search(Query(params): Query<SearchParams>) -> String {
        format!("Searching for: {}", params.q)
    }
    
    let app = Router::new().route("/search", get(search));
  4. Implemente uma rota POST `/users` que receba um JSON com `name` e `email` e retorne uma mensagem de sucesso contendo esses dados.

    ✓ Resposta:
    use axum::{routing::post, Json, Router};
    use serde::Deserialize;
    
    #[derive(Deserialize)]
    struct CreateUser {
        name: String,
        email: String,
    }
    
    async fn create_user(Json(payload): Json<CreateUser>) -> String {
        format!("User {} created with email {}", payload.name, payload.email)
    }
    
    let app = Router::new().route("/users", post(create_user));
  5. Use o extractor `State` para compartilhar um valor inteiro (por exemplo, um contador) entre handlers. Crie duas rotas: uma que retorna o valor atual e outra que incrementa e retorna o novo valor (simule com um `Mutex`).

    ✓ Resposta:
    use axum::{extract::State, routing::get, Router};
    use std::sync::{Arc, Mutex};
    
    struct AppState {
        counter: Mutex<u32>,
    }
    
    async fn get_counter(State(state): State<Arc<AppState>>) -> String {
        let counter = state.counter.lock().unwrap();
        format!("Counter: {}", *counter)
    }
    
    async fn increment(State(state): State<Arc<AppState>>) -> String {
        let mut counter = state.counter.lock().unwrap();
        *counter += 1;
        format!("Counter incremented to: {}", *counter)
    }
    
    let state = Arc::new(AppState { counter: Mutex::new(0) });
    let app = Router::new()
        .route("/counter", get(get_counter))
        .route("/increment", get(increment))
        .with_state(state);