Chegamos ao primeiro artigo verdadeiramente orientado a projeto. Até aqui construímos fundamentos — tipos, ownership, traits, async, macros, ecossistema. Agora vamos aplicar tudo isso em algo concreto: uma API REST completa com Axum, o framework web mais moderno do ecossistema Rust.
Axum foi criado pela equipe do Tokio e se integra naturalmente com o ecossistema async de Rust. Sua filosofia é ser explícito, composável e aproveitável ao máximo das garantias de tipo do Rust. Ao final deste artigo, você terá uma API funcional com rotas, handlers, validação, tratamento de erros e estado compartilhado.
Configurando o projeto
cargo new api_tarefas
cd api_tarefas
Cargo.toml:
[package]
name = "api_tarefas"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["cors", "trace"] }
Estrutura do projeto
api_tarefas/
├── Cargo.toml
└── src/
├── main.rs
├── erro.rs
├── modelo.rs
├── estado.rs
└── rotas/
├── mod.rs
└── tarefas.rs
O modelo de dados
src/modelo.rs:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Prioridade {
Baixa,
Media,
Alta,
}
impl Default for Prioridade {
fn default() -> Self {
Prioridade::Media
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tarefa {
pub id: Uuid,
pub titulo: String,
pub descricao: Option<String>,
pub concluida: bool,
pub prioridade: Prioridade,
pub criada_em: DateTime<Utc>,
pub atualizada_em: DateTime<Utc>,
}
impl Tarefa {
pub fn nova(titulo: String, descricao: Option<String>, prioridade: Prioridade) -> Self {
let agora = Utc::now();
Tarefa {
id: Uuid::new_v4(),
titulo,
descricao,
concluida: false,
prioridade,
criada_em: agora,
atualizada_em: agora,
}
}
}
// Payload para criação
#[derive(Debug, Deserialize)]
pub struct CriarTarefa {
pub titulo: String,
pub descricao: Option<String>,
#[serde(default)]
pub prioridade: Prioridade,
}
// Payload para atualização parcial
#[derive(Debug, Deserialize)]
pub struct AtualizarTarefa {
pub titulo: Option<String>,
pub descricao: Option<String>,
pub concluida: Option<bool>,
pub prioridade: Option<Prioridade>,
}
// Resposta paginada
#[derive(Debug, Serialize)]
pub struct ListaTarefas {
pub tarefas: Vec<Tarefa>,
pub total: usize,
pub pagina: usize,
pub por_pagina: usize,
}
// Parâmetros de query
#[derive(Debug, Deserialize)]
pub struct FiltroTarefas {
#[serde(default = "pagina_padrao")]
pub pagina: usize,
#[serde(default = "por_pagina_padrao")]
pub por_pagina: usize,
pub concluida: Option<bool>,
pub prioridade: Option<Prioridade>,
}
fn pagina_padrao() -> usize { 1 }
fn por_pagina_padrao() -> usize { 10 }
O sistema de erros
src/erro.rs:
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ErroApi {
#[error("Tarefa não encontrada: {0}")]
NaoEncontrada(String),
#[error("Dados inválidos: {0}")]
DadosInvalidos(String),
#[error("Título não pode ser vazio")]
TituloVazio,
#[error("Conflito: {0}")]
Conflito(String),
#[error("Erro interno do servidor")]
ErroInterno,
}
// Converte nosso erro em resposta HTTP
impl IntoResponse for ErroApi {
fn into_response(self) -> Response {
let (status, mensagem) = match &self {
ErroApi::NaoEncontrada(msg) => (StatusCode::NOT_FOUND, msg.clone()),
ErroApi::DadosInvalidos(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
ErroApi::TituloVazio => (StatusCode::BAD_REQUEST, self.to_string()),
ErroApi::Conflito(msg) => (StatusCode::CONFLICT, msg.clone()),
ErroApi::ErroInterno => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
};
let corpo = json!({
"erro": mensagem,
"codigo": status.as_u16(),
});
(status, Json(corpo)).into_response()
}
}
pub type ResultadoApi<T> = Result<T, ErroApi>;
O estado da aplicação
src/estado.rs:
use crate::modelo::Tarefa;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
// Estado compartilhado entre todos os handlers
#[derive(Debug, Default)]
pub struct EstadoInterno {
pub tarefas: HashMap<Uuid, Tarefa>,
}
pub type EstadoApp = Arc<RwLock<EstadoInterno>>;
pub fn criar_estado() -> EstadoApp {
Arc::new(RwLock::new(EstadoInterno::default()))
}
Os handlers de rotas
src/rotas/tarefas.rs:
use axum::{
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use chrono::Utc;
use tracing::{info, warn};
use uuid::Uuid;
use crate::{
erro::{ErroApi, ResultadoApi},
estado::EstadoApp,
modelo::{AtualizarTarefa, CriarTarefa, FiltroTarefas, ListaTarefas, Tarefa},
};
// GET /tarefas
pub async fn listar_tarefas(
State(estado): State<EstadoApp>,
Query(filtro): Query<FiltroTarefas>,
) -> ResultadoApi<Json<ListaTarefas>> {
let estado = estado.read().await;
let mut tarefas: Vec<Tarefa> = estado.tarefas.values().cloned().collect();
// Aplicar filtros
if let Some(concluida) = filtro.concluida {
tarefas.retain(|t| t.concluida == concluida);
}
if let Some(ref prioridade) = filtro.prioridade {
tarefas.retain(|t| &t.prioridade == prioridade);
}
// Ordenar por data de criação (mais recentes primeiro)
tarefas.sort_by(|a, b| b.criada_em.cmp(&a.criada_em));
let total = tarefas.len();
// Paginação
let inicio = (filtro.pagina.saturating_sub(1)) * filtro.por_pagina;
let tarefas_paginadas: Vec<Tarefa> = tarefas
.into_iter()
.skip(inicio)
.take(filtro.por_pagina)
.collect();
info!(
total = total,
pagina = filtro.pagina,
"Tarefas listadas"
);
Ok(Json(ListaTarefas {
tarefas: tarefas_paginadas,
total,
pagina: filtro.pagina,
por_pagina: filtro.por_pagina,
}))
}
// GET /tarefas/:id
pub async fn buscar_tarefa(
State(estado): State<EstadoApp>,
Path(id): Path<Uuid>,
) -> ResultadoApi<Json<Tarefa>> {
let estado = estado.read().await;
let tarefa = estado
.tarefas
.get(&id)
.cloned()
.ok_or_else(|| ErroApi::NaoEncontrada(format!("Tarefa {id} não encontrada")))?;
Ok(Json(tarefa))
}
// POST /tarefas
pub async fn criar_tarefa(
State(estado): State<EstadoApp>,
Json(payload): Json<CriarTarefa>,
) -> ResultadoApi<(StatusCode, Json<Tarefa>)> {
// Validação
if payload.titulo.trim().is_empty() {
return Err(ErroApi::TituloVazio);
}
if payload.titulo.len() > 200 {
return Err(ErroApi::DadosInvalidos(
"Título não pode ter mais de 200 caracteres".to_string()
));
}
let tarefa = Tarefa::nova(
payload.titulo.trim().to_string(),
payload.descricao,
payload.prioridade,
);
info!(id = %tarefa.id, titulo = %tarefa.titulo, "Tarefa criada");
let mut estado = estado.write().await;
estado.tarefas.insert(tarefa.id, tarefa.clone());
Ok((StatusCode::CREATED, Json(tarefa)))
}
// PUT /tarefas/:id
pub async fn atualizar_tarefa(
State(estado): State<EstadoApp>,
Path(id): Path<Uuid>,
Json(payload): Json<AtualizarTarefa>,
) -> ResultadoApi<Json<Tarefa>> {
let mut estado = estado.write().await;
let tarefa = estado
.tarefas
.get_mut(&id)
.ok_or_else(|| ErroApi::NaoEncontrada(format!("Tarefa {id} não encontrada")))?;
// Aplicar atualizações parciais
if let Some(titulo) = payload.titulo {
if titulo.trim().is_empty() {
return Err(ErroApi::TituloVazio);
}
tarefa.titulo = titulo.trim().to_string();
}
if let Some(descricao) = payload.descricao {
tarefa.descricao = Some(descricao);
}
if let Some(concluida) = payload.concluida {
tarefa.concluida = concluida;
}
if let Some(prioridade) = payload.prioridade {
tarefa.prioridade = prioridade;
}
tarefa.atualizada_em = Utc::now();
info!(id = %id, "Tarefa atualizada");
Ok(Json(tarefa.clone()))
}
// DELETE /tarefas/:id
pub async fn deletar_tarefa(
State(estado): State<EstadoApp>,
Path(id): Path<Uuid>,
) -> ResultadoApi<StatusCode> {
let mut estado = estado.write().await;
if estado.tarefas.remove(&id).is_none() {
warn!(id = %id, "Tentativa de deletar tarefa inexistente");
return Err(ErroApi::NaoEncontrada(format!("Tarefa {id} não encontrada")));
}
info!(id = %id, "Tarefa deletada");
Ok(StatusCode::NO_CONTENT)
}
// DELETE /tarefas — remove todas concluídas
pub async fn limpar_concluidas(
State(estado): State<EstadoApp>,
) -> ResultadoApi<Json<serde_json::Value>> {
let mut estado = estado.write().await;
let antes = estado.tarefas.len();
estado.tarefas.retain(|_, t| !t.concluida);
let depois = estado.tarefas.len();
let removidas = antes - depois;
info!(removidas = removidas, "Tarefas concluídas removidas");
Ok(Json(serde_json::json!({
"removidas": removidas,
"restantes": depois,
})))
}
Configurando as rotas
src/rotas/mod.rs:
pub mod tarefas;
use axum::{
routing::{delete, get, post, put},
Router,
};
use crate::estado::EstadoApp;
use tarefas::*;
pub fn criar_rotas(estado: EstadoApp) -> Router {
Router::new()
.route("/tarefas", get(listar_tarefas).post(criar_tarefa))
.route("/tarefas/:id", get(buscar_tarefa).put(atualizar_tarefa).delete(deletar_tarefa))
.route("/tarefas/concluidas", delete(limpar_concluidas))
.with_state(estado)
}
O ponto de entrada
src/main.rs:
mod erro;
mod estado;
mod modelo;
mod rotas;
use axum::{
http::Method,
routing::get,
Json,
Router,
};
use estado::criar_estado;
use serde_json::json;
use tower_http::{
cors::{Any, CorsLayer},
trace::TraceLayer,
};
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
async fn saude() -> Json<serde_json::Value> {
Json(json!({
"status": "ok",
"versao": env!("CARGO_PKG_VERSION"),
"servico": "api_tarefas",
}))
}
#[tokio::main]
async fn main() {
// Configurar logging
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "api_tarefas=debug,tower_http=debug".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
// Criar estado compartilhado
let estado = criar_estado();
// Configurar CORS
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
.allow_headers(Any)
.allow_origin(Any);
// Construir aplicação
let app = Router::new()
.route("/saude", get(saude))
.merge(rotas::criar_rotas(estado))
.layer(TraceLayer::new_for_http())
.layer(cors);
let endereco = "0.0.0.0:3000";
info!("Servidor iniciando em http://{endereco}");
let listener = tokio::net::TcpListener::bind(endereco)
.await
.expect("Falha ao iniciar listener");
axum::serve(listener, app)
.await
.expect("Falha ao iniciar servidor");
}
Testando a API
Com o servidor rodando (cargo run), teste com curl:
# Verificar saúde
curl http://localhost:3000/saude
# Criar tarefas
curl -X POST http://localhost:3000/tarefas \
-H "Content-Type: application/json" \
-d '{"titulo": "Estudar Axum", "prioridade": "Alta"}'
curl -X POST http://localhost:3000/tarefas \
-H "Content-Type: application/json" \
-d '{"titulo": "Escrever testes", "descricao": "Cobertura mínima 80%"}'
curl -X POST http://localhost:3000/tarefas \
-H "Content-Type: application/json" \
-d '{"titulo": "Revisar PR", "prioridade": "Baixa"}'
# Listar todas
curl http://localhost:3000/tarefas
# Listar apenas pendentes
curl "http://localhost:3000/tarefas?concluida=false"
# Listar por prioridade
curl "http://localhost:3000/tarefas?prioridade=Alta"
# Buscar por ID (substitua pelo UUID retornado)
curl http://localhost:3000/tarefas/SEU-UUID-AQUI
# Atualizar
curl -X PUT http://localhost:3000/tarefas/SEU-UUID-AQUI \
-H "Content-Type: application/json" \
-d '{"concluida": true}'
# Deletar
curl -X DELETE http://localhost:3000/tarefas/SEU-UUID-AQUI
# Limpar concluídas
curl -X DELETE http://localhost:3000/tarefas/concluidas
Adicionando testes de integração
tests/api_tarefas.rs:
use axum::{
body::Body,
http::{Request, StatusCode},
};
use serde_json::{json, Value};
use tower::ServiceExt;
// Importa a função de criar rotas e estado
use api_tarefas::{estado::criar_estado, rotas::criar_rotas};
fn criar_app() -> axum::Router {
let estado = criar_estado();
criar_rotas(estado)
}
async fn fazer_requisicao(
app: axum::Router,
metodo: &str,
caminho: &str,
corpo: Option<Value>,
) -> (StatusCode, Value) {
let mut builder = Request::builder()
.method(metodo)
.uri(caminho)
.header("Content-Type", "application/json");
let corpo_bytes = match corpo {
Some(json) => Body::from(json.to_string()),
None => Body::empty(),
};
let request = builder.body(corpo_bytes).unwrap();
let response = app.oneshot(request).await.unwrap();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let json: Value = if bytes.is_empty() {
json!(null)
} else {
serde_json::from_slice(&bytes).unwrap_or(json!(null))
};
(status, json)
}
#[tokio::test]
async fn criar_tarefa_retorna_201() {
let app = criar_app();
let (status, corpo) = fazer_requisicao(
app,
"POST",
"/tarefas",
Some(json!({"titulo": "Teste"})),
).await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(corpo["titulo"], "Teste");
assert_eq!(corpo["concluida"], false);
assert!(corpo["id"].is_string());
}
#[tokio::test]
async fn criar_tarefa_titulo_vazio_retorna_400() {
let app = criar_app();
let (status, corpo) = fazer_requisicao(
app,
"POST",
"/tarefas",
Some(json!({"titulo": ""})),
).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(corpo["erro"].is_string());
}
#[tokio::test]
async fn listar_tarefas_inicialmente_vazio() {
let app = criar_app();
let (status, corpo) = fazer_requisicao(
app, "GET", "/tarefas", None,
).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(corpo["total"], 0);
assert!(corpo["tarefas"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn buscar_tarefa_inexistente_retorna_404() {
let app = criar_app();
let id_falso = uuid::Uuid::new_v4();
let (status, _) = fazer_requisicao(
app,
"GET",
&format!("/tarefas/{id_falso}"),
None,
).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn fluxo_completo_criar_atualizar_deletar() {
let app = criar_app();
// Criar
let (status, tarefa) = fazer_requisicao(
app.clone(),
"POST",
"/tarefas",
Some(json!({"titulo": "Tarefa Teste", "prioridade": "Alta"})),
).await;
assert_eq!(status, StatusCode::CREATED);
let id = tarefa["id"].as_str().unwrap().to_string();
// Atualizar
let (status, atualizada) = fazer_requisicao(
app.clone(),
"PUT",
&format!("/tarefas/{id}"),
Some(json!({"concluida": true})),
).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(atualizada["concluida"], true);
// Deletar
let (status, _) = fazer_requisicao(
app.clone(),
"DELETE",
&format!("/tarefas/{id}"),
None,
).await;
assert_eq!(status, StatusCode::NO_CONTENT);
// Verificar que não existe mais
let (status, _) = fazer_requisicao(
app,
"GET",
&format!("/tarefas/{id}"),
None,
).await;
assert_eq!(status, StatusCode::NOT_FOUND);
}
Execute os testes:
cargo test
O que aprendemos com esse projeto
Construir essa API revelou como os conceitos que aprendemos se encaixam na prática:
Ownership e borrowing aparecem no Arc> — múltiplos handlers compartilham o estado com segurança garantida pelo compilador. Sem locks explícitos esquecidos, sem data races.
Enums e pattern matching tornam o tratamento de erros elegante — ErroApi mapeia cada tipo de erro ao status HTTP correto, e o match no into_response garante que nenhum caso seja esquecido.
Traits são a base do Axum — IntoResponse, FromRequest, Handler. Cada handler é uma função assíncrona que implementa o trait Handler automaticamente.
Async/await permite que o servidor lide com centenas de requisições simultâneas usando apenas algumas threads — sem callback hell, sem complexidade de callback.
Generics e tipos garantem que a extração de dados JSON (Json) falhe em tempo de compilação se os tipos não baterem — não em tempo de execução.
Próximos passos para este projeto
Este projeto tem muito espaço para evolução. Nos próximos artigos veremos como adicionar persistência real com banco de dados, autenticação JWT, middleware de rate limiting e deploy. Por ora, você tem uma API funcional, testada e idiomática em Rust.
Fontes e leituras recomendadas
- Axum Documentation — https://docs.rs/axum
- Axum Examples — repositório oficial com dezenas de exemplos — https://github.com/tokio-rs/axum/tree/main/examples
- "Zero to Production in Rust" — Luca Palmieri — o livro mais completo sobre web em Rust — https://www.zero2prod.com
- Tower middleware documentation — https://docs.rs/tower
- tower-http — middlewares HTTP prontos para usar — https://docs.rs/tower-http
- "Are we web yet?" — estado atual do ecossistema web Rust — https://www.arewewebyet.org