Construindo uma REST API com Axum em Rust

1. Tópico geral

Axum é um framework web da equipe do Tokio. Ele é construído sobre hyper e tower, usando o sistema de tipos do Rust para verificar handlers em tempo de compilação.

Neste artigo, você criará uma REST API para gerenciar pedidos (Order) com todas as operações CRUD. O artigo também aborda como compartilhar estado entre handlers, retornar erros estruturados em JSON e escrever testes para o roteador sem necessidade de abrir portas de rede.

O código deste artigo usa Axum 0.8 e Rust edition 2021 ou superior.

2. Configuração do projeto

Crie um novo projeto:

cargo new order-api
cd order-api

Adicione as dependências ao Cargo.toml:

[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

O papel de cada crate:

  • axum: roteador, extractors e tipos de response.
  • tokio: async runtime. Axum requer execução no Tokio.
  • serde e serde_json: conversão de structs para JSON e vice-versa.
  • tower e http-body-util: usados apenas em testes. Ajudam a enviar requisições diretamente ao roteador e ler o corpo da resposta.

3. Seu primeiro aplicativo Axum

Comece com um endpoint de verificação de saúde do serviço:

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

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

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on {}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

Este código faz três coisas:

  • #[tokio::main] transforma main em uma função async que executa no Tokio runtime.
  • Router::new().route(...) vincula o caminho /health ao método GET. O handler é um closure async que retorna &'static str. Axum converte automaticamente essa string em uma resposta 200 OK com Content-Type: text/plain.
  • axum::serve recebe um TcpListener e começa a servir requisições.

Teste a execução:

cargo run
curl -i http://localhost:3000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2

OK

4. Modelo e estado compartilhado

4.1. Definição do modelo

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub amount: u64,
}

#[derive(Debug, Deserialize)]
pub struct CreateOrder {
    pub customer: String,
    pub amount: u64,
}

Separar CreateOrder de Order é intencional. O cliente não deve enviar id por si só, pois apenas o servidor o gera. Se usar uma struct compartilhada, você precisa lidar com o caso em que o cliente envia um id arbitrário.

4.2. Estado compartilhado

Neste artigo, os dados são armazenados em memória. Na prática, isso geralmente é um pool de conexões como sqlx::PgPool.

use std::collections::HashMap;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, RwLock};

#[derive(Clone, Default)]
pub struct AppState {
    orders: Arc<RwLock<HashMap<u64, Order>>>,
    next_id: Arc<AtomicU64>,
}

Axum clona o estado para cada requisição. Portanto, AppState deve implementar Clone, e a clonagem deve ser barata. Envolver os dados em Arc atende ambos os requisitos: clonar apenas incrementa o contador de referências, e todos os handlers apontam para a mesma região de dados.

RwLock permite que múltiplas threads leiam em paralelo, mas apenas uma thread pode escrever por vez. AtomicU64 é usado para gerar id sem necessidade de lock.

Observação: Aqui usamos std::sync::RwLock porque não há .await entre a aquisição e a liberação do lock. Se você precisar manter o lock durante um .await, mude para tokio::sync::RwLock. Caso contrário, o future não será mais Send e o handler não compilará.

5. Tratamento de erros com IntoResponse

Antes de escrever os handlers, defina um tipo de erro comum. Qualquer tipo que implemente IntoResponse pode ser retornado por um handler, inclusive dentro de Result.

use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;

#[derive(Debug)]
pub enum AppError {
    NotFound(u64),
    Validation(String),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            AppError::NotFound(id) => (StatusCode::NOT_FOUND, format!("Pedido {id} não encontrado")),
            AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
        };
        (status, Json(json!({ "error": message }))).into_response()
    }
}

A tupla (StatusCode, Json<_>) também implementa IntoResponse. Assim, você pode definir o código de status e o corpo simultaneamente.

De agora em diante, o handler só precisa retornar Err(AppError::NotFound(id)). O cliente receberá uma resposta como esta:

HTTP/1.1 404 Not Found
content-type: application/json

{"error":"Pedido 42 não encontrado"}

6. Escrever os handlers CRUD

6.1. Criar um pedido

use axum::extract::State;
use std::sync::atomic::Ordering;

async fn create_order(
    State(state): State<AppState>,
    Json(input): Json<CreateOrder>,
) -> Result<(StatusCode, Json<Order>), AppError> {
    if input.customer.trim().is_empty() {
        return Err(AppError::Validation("o cliente não deve estar vazio".into()));
    }
    let id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1;
    let order = Order { id, customer: input.customer, amount: input.amount };
    state.orders.write().unwrap().insert(id, order.clone());
    Ok((StatusCode::CREATED, Json(order)))
}

Cada parâmetro do handler é um extractor:

  • State(state) obtém AppState vinculado ao roteador.
  • Json(input) lê o corpo e o deserializa em CreateOrder. Se o corpo tiver formato inválido, Axum retorna um erro antes que sua função seja executada.

fetch_add retorna o valor anterior, então você precisa adicionar 1 para que id comece em 1. O handler retorna 201 Created junto com o pedido recém-criado.

Observação: Extractors que leem o corpo, como Json, String ou Bytes, devem ser o último parâmetro. O corpo só pode ser lido uma vez. Colocar Json antes de State causará um erro de compilação do tipo “the trait Handler<_, _> is not implemented”, e essa mensagem é bastante confusa se você não souber o motivo.

6.2. Obter um pedido por id

use axum::extract::Path;

async fn get_order(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<Json<Order>, AppError> {
    state
        .orders
        .read()
        .unwrap()
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(AppError::NotFound(id))
}

Path<u64> analisa o segmento {id} da URL em u64. Se o cliente chamar /orders/abc, Axum retorna 400 Bad Request sem código adicional. Quando não encontra um pedido, ok_or converte None em um erro NotFound.

6.3. Listar com paginação

use axum::extract::Query;

#[derive(Debug, Deserialize)]
pub struct Pagination {
    #[serde(default)]
    offset: usize,
    #[serde(default = "default_limit")]
    limit: usize,
}

fn default_limit() -> usize {
    20
}

async fn list_orders(
    State(state): State<AppState>,
    Query(page): Query<Pagination>,
) -> Json<Vec<Order>> {
    let orders = state.orders.read().unwrap();
    let mut result: Vec<Order> = orders.values().cloned().collect();
    result.sort_by_key(|o| o.id);
    let result = result.into_iter().skip(page.offset).take(page.limit).collect();
    Json(result)
}

Query<Pagination> lê a query string como ?offset=10&limit=5. Graças a #[serde(default)], o cliente pode omitir ambos os parâmetros e ainda receber um resultado válido.

O código deve ordenar por id porque HashMap não garante ordem. Se omitir esta etapa, os resultados da paginação variarão entre as chamadas.

6.4. Deletar um pedido

async fn delete_order(
    State(state): State<AppState>,
    Path(id): Path<u64>,
) -> Result<StatusCode, AppError> {
    match state.orders.write().unwrap().remove(&id) {
        Some(_) => Ok(StatusCode::NO_CONTENT),
        None => Err(AppError::NotFound(id)),
    }
}

StatusCode em si também implementa IntoResponse. Retornar 204 No Content é a forma usual para operações de exclusão bem-sucedidas.

7. Montar o roteador

pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/health", get(|| async { "OK" }))
        .route("/orders", get(list_orders).post(create_order))
        .route("/orders/{id}", get(get_order).delete(delete_order))
        .with_state(state)
}

#[tokio::main]
async fn main() {
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app(AppState::default())).await.unwrap();
}

Este roteador é organizado da seguinte forma:

  • Um caminho pode ser vinculado a múltiplos métodos encadeando: get(...).post(...).
  • with_state vincula o estado ao roteador. Após essa etapa, o roteador tem o tipo Router<()> e está pronto para servir requisições.
  • A função app é separada para que os testes possam construir o roteador sem precisar abrir portas.

Experimente criar e ler um pedido:

curl -i -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customer":"Nguyen Van An","amount":150000}'

curl http://localhost:3000/orders/1
HTTP/1.1 201 Created
content-type: application/json

{"id":1,"customer":"Nguyen Van An","amount":150000}

{"id":1,"customer":"Nguyen Van An","amount":150000}

Observação: A partir do Axum 0.8, parâmetros de caminho usam a sintaxe {id} em vez de /:id de versões anteriores. Se você copiar código antigo que usa /:id, a aplicação vai fazer panic imediatamente ao construir o roteador. A sintaxe de wildcard também mudou de *rest para {*rest}.

8. Testar o roteador

Router implementa o trait Service do tower. Assim, você pode enviar requisições diretamente ao roteador usando oneshot, sem precisar executar um servidor real. Adicione um módulo de teste ao final de main.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use http_body_util::BodyExt;
    use tower::ServiceExt;

    fn post_order(json: &str) -> Request<Body> {
        Request::builder()
            .method("POST")
            .uri("/orders")
            .header("content-type", "application/json")
            .body(Body::from(json.to_owned()))
            .unwrap()
    }

    #[tokio::test]
    async fn create_then_get_order() {
        let app = app(AppState::default());

        let response = app
            .clone()
            .oneshot(post_order(r#"{"customer":"An","amount":150000}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::CREATED);

        let response = app
            .oneshot(Request::get("/orders/1").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::OK);

        let bytes = response.into_body().collect().await.unwrap().to_bytes();
        let order: Order = serde_json::from_slice(&bytes).unwrap();
        assert_eq!(order, Order { id: 1, customer: "An".into(), amount: 150000 });
    }

    #[tokio::test]
    async fn empty_customer_is_rejected() {
        let response = app(AppState::default())
            .oneshot(post_order(r#"{"customer":"  ","amount":1}"#))
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
    }

    #[tokio::test]
    async fn unknown_order_returns_404() {
        let response = app(AppState::default())
            .oneshot(Request::get("/orders/42").body(Body::empty()).unwrap())
            .await
            .unwrap();
        assert_eq!(response.status(), StatusCode::NOT_FOUND);
    }
}

Três pontos importantes para observar nos testes:

  • oneshot consome o roteador. Para enviar múltiplas requisições ao mesmo estado, você precisa clone() o roteador antes de cada chamada. O estado ainda é compartilhado porque está dentro de Arc.
  • BodyExt::collect coleta todo o corpo em Bytes, então serde_json o analisa novamente em Order.
  • Cada teste cria um novo AppState, portanto os testes são independentes uns dos outros e podem ser executados em paralelo.

Execute os testes:

cargo test
running 3 tests
test tests::unknown_order_returns_404 ... ok
test tests::empty_customer_is_rejected ... ok
test tests::create_then_get_order ... ok

test result: ok. 3 passed; 0 failed

9. Erros comuns

Falta de header Content-Type. O extractor Json requer o header application/json. Sem esse header, o cliente recebe 415 Unsupported Media Type mesmo que o corpo seja completamente válido. Este é o erro mais comum ao testar com curl.

Distinguir códigos de erro de Json. JSON com sintaxe inválida retorna 400. JSON com sintaxe válida mas campos ausentes ou tipo incorreto retorna 422. Por padrão, essas respostas têm corpo em texto simples, não JSON. Se a API precisar de um formato de erro unificado, envolva Json em um extractor personalizado e converta JsonRejection em AppError.

Usar unwrap() em locks em produção. Se uma thread fizer panic enquanto mantém um lock, o lock será “envenenado”, e qualquer unwrap() subsequente também fará panic. No código real, trate este caso ou use um banco de dados real.

Colocar tudo em um grande AppState. Conforme a aplicação cresce, você pode usar FromRef para que handlers obtenham apenas a parte do estado que precisam, como State<PgPool>, em vez de todo AppState.

Quando não usar Axum? Se você precisar de um framework “batteries-included” com ORM, templates, admin e migrações integradas, Axum exigirá que você monte muitas peças por si mesmo. Se sua equipe ainda não está familiarizada com async Rust, as mensagens de erro de trait bound do handler também serão uma barreira significativa nos estágios iniciais.

10. Conclusão

Você acabou de criar uma REST API CRUD completa com Axum 0.8. Os conceitos principais incluem:

  • Router: vincula caminhos e métodos aos handlers.
  • Extractor: State, Path, Query e Json obtêm dados da requisição de forma type-safe.
  • Shared state: dados compartilhados são encapsulados em Arc.
  • Tratamento de erro: implemente IntoResponse para retornar erros JSON estruturados.
  • Test: envie requisições diretamente ao roteador usando tower::ServiceExt::oneshot.

O próximo passo lógico é substituir HashMap por sqlx conectando ao PostgreSQL. Você também deve adicionar middleware de tower-http, como TraceLayer para registrar requisições e CorsLayer para configurar CORS. A maioria dos handlers não precisará ser alterada, pois a lógica HTTP foi separada da camada de armazenamento.

rustaxumrest-apitokiobackend