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.serdeeserde_json: conversão de structs para JSON e vice-versa.towerehttp-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]transformamainem uma função async que executa no Tokio runtime.Router::new().route(...)vincula o caminho/healthao métodoGET. O handler é um closure async que retorna&'static str. Axum converte automaticamente essa string em uma resposta200 OKcomContent-Type: text/plain.axum::serverecebe umTcpListenere 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::RwLockporque não há.awaitentre a aquisição e a liberação do lock. Se você precisar manter o lock durante um.await, mude paratokio::sync::RwLock. Caso contrário, o future não será maisSende 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émAppStatevinculado ao roteador.Json(input)lê o corpo e o deserializa emCreateOrder. 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,StringouBytes, devem ser o último parâmetro. O corpo só pode ser lido uma vez. ColocarJsonantes deStatecausará um erro de compilação do tipo “the traitHandler<_, _>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_statevincula o estado ao roteador. Após essa etapa, o roteador tem o tipoRouter<()>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/:idde 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*restpara{*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:
oneshotconsome o roteador. Para enviar múltiplas requisições ao mesmo estado, você precisaclone()o roteador antes de cada chamada. O estado ainda é compartilhado porque está dentro deArc.BodyExt::collectcoleta todo o corpo emBytes, entãoserde_jsono analisa novamente emOrder.- 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,QueryeJsonobtêm dados da requisição de forma type-safe. - Shared state: dados compartilhados são encapsulados em
Arc. - Tratamento de erro: implemente
IntoResponsepara 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.