Introdução
Axum é um framework web para Rust criado pela equipe do Tokio. Ele se destaca pela ergonomia, performance e integração nativa com o ecossistema Tokio. Neste tutorial, vamos construir uma API REST completa para gerenciar tarefas (to-do list) com banco de dados PostgreSQL.
Configurando o Projeto
Crie um novo projeto e adicione as dependências:
// Cargo.toml
// [package]
// name = "todo-api"
// version = "0.1.0"
// edition = "2021"
//
// [dependencies]
// axum = "0.8"
// tokio = { version = "1", features = ["full"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
// sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "chrono"] }
// chrono = { version = "0.4", features = ["serde"] }
// tower-http = { version = "0.6", features = ["cors", "trace"] }
// tracing = "0.1"
// tracing-subscriber = "0.3"
// dotenvy = "0.15"
// uuid = { version = "1", features = ["v4", "serde"] }
// thiserror = "2"
Estrutura do Projeto
Vamos organizar o projeto de forma modular:
src/
├── main.rs # Ponto de entrada e configuração
├── routes.rs # Definição de rotas
├── handlers.rs # Handlers dos endpoints
├── models.rs # Modelos de dados
├── errors.rs # Tratamento de erros
└── db.rs # Conexão com o banco
Modelos de Dados
Definimos nossos modelos com serde para serialização JSON e sqlx para mapeamento com o banco:
// src/models.rs
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, FromRow)]
pub struct Tarefa {
pub id: Uuid,
pub titulo: String,
pub descricao: Option<String>,
pub concluida: bool,
pub criada_em: NaiveDateTime,
pub atualizada_em: NaiveDateTime,
}
#[derive(Debug, Deserialize)]
pub struct CriarTarefa {
pub titulo: String,
pub descricao: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct AtualizarTarefa {
pub titulo: Option<String>,
pub descricao: Option<String>,
pub concluida: Option<bool>,
}
#[derive(Debug, Serialize)]
pub struct RespostaApi<T: Serialize> {
pub sucesso: bool,
pub dados: Option<T>,
pub mensagem: Option<String>,
}
impl<T: Serialize> RespostaApi<T> {
pub fn sucesso(dados: T) -> Self {
RespostaApi {
sucesso: true,
dados: Some(dados),
mensagem: None,
}
}
pub fn erro(mensagem: impl Into<String>) -> RespostaApi<()> {
RespostaApi {
sucesso: false,
dados: None,
mensagem: Some(mensagem.into()),
}
}
}
Tratamento de Erros
Criamos um tipo de erro personalizado que se integra com Axum:
// src/errors.rs
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("Tarefa não encontrada")]
NaoEncontrado,
#[error("Dados inválidos: {0}")]
DadosInvalidos(String),
#[error("Erro no banco de dados: {0}")]
BancoDeDados(#[from] sqlx::Error),
#[error("Erro interno: {0}")]
Interno(String),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, mensagem) = match &self {
ApiError::NaoEncontrado => (StatusCode::NOT_FOUND, self.to_string()),
ApiError::DadosInvalidos(_) => (StatusCode::BAD_REQUEST, self.to_string()),
ApiError::BancoDeDados(e) => {
tracing::error!("Erro no banco de dados: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Erro interno do servidor".to_string(),
)
}
ApiError::Interno(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Erro interno do servidor".to_string(),
),
};
let body = Json(json!({
"sucesso": false,
"mensagem": mensagem
}));
(status, body).into_response()
}
}
Conexão com o Banco de Dados
// src/db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn criar_pool() -> Result<PgPool, sqlx::Error> {
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL deve ser definida no ambiente");
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await?;
// Criar a tabela se não existir
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS tarefas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
titulo VARCHAR(255) NOT NULL,
descricao TEXT,
concluida BOOLEAN NOT NULL DEFAULT FALSE,
criada_em TIMESTAMP NOT NULL DEFAULT NOW(),
atualizada_em TIMESTAMP NOT NULL DEFAULT NOW()
)
"#,
)
.execute(&pool)
.await?;
tracing::info!("Conexão com o banco de dados estabelecida");
Ok(pool)
}
Handlers (Controladores)
Os handlers processam as requisições e retornam respostas:
// src/handlers.rs
use axum::extract::{Path, State};
use axum::Json;
use sqlx::PgPool;
use uuid::Uuid;
use crate::errors::ApiError;
use crate::models::{AtualizarTarefa, CriarTarefa, RespostaApi, Tarefa};
// GET /api/tarefas
pub async fn listar_tarefas(
State(pool): State<PgPool>,
) -> Result<Json<RespostaApi<Vec<Tarefa>>>, ApiError> {
let tarefas = sqlx::query_as::<_, Tarefa>(
"SELECT * FROM tarefas ORDER BY criada_em DESC"
)
.fetch_all(&pool)
.await?;
Ok(Json(RespostaApi::sucesso(tarefas)))
}
// GET /api/tarefas/:id
pub async fn obter_tarefa(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<RespostaApi<Tarefa>>, ApiError> {
let tarefa = sqlx::query_as::<_, Tarefa>(
"SELECT * FROM tarefas WHERE id = $1"
)
.bind(id)
.fetch_optional(&pool)
.await?
.ok_or(ApiError::NaoEncontrado)?;
Ok(Json(RespostaApi::sucesso(tarefa)))
}
// POST /api/tarefas
pub async fn criar_tarefa(
State(pool): State<PgPool>,
Json(dados): Json<CriarTarefa>,
) -> Result<(axum::http::StatusCode, Json<RespostaApi<Tarefa>>), ApiError> {
if dados.titulo.trim().is_empty() {
return Err(ApiError::DadosInvalidos(
"O título não pode ser vazio".to_string(),
));
}
let tarefa = sqlx::query_as::<_, Tarefa>(
r#"
INSERT INTO tarefas (titulo, descricao)
VALUES ($1, $2)
RETURNING *
"#,
)
.bind(&dados.titulo)
.bind(&dados.descricao)
.fetch_one(&pool)
.await?;
Ok((axum::http::StatusCode::CREATED, Json(RespostaApi::sucesso(tarefa))))
}
// PUT /api/tarefas/:id
pub async fn atualizar_tarefa(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(dados): Json<AtualizarTarefa>,
) -> Result<Json<RespostaApi<Tarefa>>, ApiError> {
// Verifica se a tarefa existe
let existente = sqlx::query_as::<_, Tarefa>(
"SELECT * FROM tarefas WHERE id = $1"
)
.bind(id)
.fetch_optional(&pool)
.await?
.ok_or(ApiError::NaoEncontrado)?;
let titulo = dados.titulo.unwrap_or(existente.titulo);
let descricao = dados.descricao.or(existente.descricao);
let concluida = dados.concluida.unwrap_or(existente.concluida);
let tarefa = sqlx::query_as::<_, Tarefa>(
r#"
UPDATE tarefas
SET titulo = $1, descricao = $2, concluida = $3, atualizada_em = NOW()
WHERE id = $4
RETURNING *
"#,
)
.bind(&titulo)
.bind(&descricao)
.bind(concluida)
.bind(id)
.fetch_one(&pool)
.await?;
Ok(Json(RespostaApi::sucesso(tarefa)))
}
// DELETE /api/tarefas/:id
pub async fn deletar_tarefa(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<RespostaApi<String>>, ApiError> {
let resultado = sqlx::query("DELETE FROM tarefas WHERE id = $1")
.bind(id)
.execute(&pool)
.await?;
if resultado.rows_affected() == 0 {
return Err(ApiError::NaoEncontrado);
}
Ok(Json(RespostaApi::sucesso("Tarefa deletada com sucesso".to_string())))
}
Definição de Rotas
// src/routes.rs
use axum::routing::{delete, get, post, put};
use axum::Router;
use sqlx::PgPool;
use crate::handlers;
pub fn criar_rotas(pool: PgPool) -> Router {
Router::new()
.route("/api/tarefas", get(handlers::listar_tarefas))
.route("/api/tarefas", post(handlers::criar_tarefa))
.route("/api/tarefas/{id}", get(handlers::obter_tarefa))
.route("/api/tarefas/{id}", put(handlers::atualizar_tarefa))
.route("/api/tarefas/{id}", delete(handlers::deletar_tarefa))
.route("/api/saude", get(verificar_saude))
.with_state(pool)
}
async fn verificar_saude() -> &'static str {
"API funcionando!"
}
Configuração Principal com Middleware e CORS
// src/main.rs
mod db;
mod errors;
mod handlers;
mod models;
mod routes;
use tower_http::cors::{Any, CorsLayer};
use tower_http::trace::TraceLayer;
use tracing_subscriber;
#[tokio::main]
async fn main() {
// Carregar variáveis de ambiente
dotenvy::dotenv().ok();
// Configurar logging
tracing_subscriber::fmt()
.with_target(false)
.with_max_level(tracing::Level::INFO)
.init();
// Conectar ao banco de dados
let pool = db::criar_pool()
.await
.expect("Falha ao conectar ao banco de dados");
// Configurar CORS
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
// Construir a aplicação com middleware
let app = routes::criar_rotas(pool)
.layer(cors)
.layer(TraceLayer::new_for_http());
// Iniciar o servidor
let endereco = "0.0.0.0:3000";
tracing::info!("Servidor iniciado em http://{}", endereco);
let listener = tokio::net::TcpListener::bind(endereco)
.await
.expect("Falha ao criar listener");
axum::serve(listener, app)
.await
.expect("Falha ao iniciar o servidor");
}
Middleware Personalizado
Vamos adicionar um middleware para logging de requisições:
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use std::time::Instant;
pub async fn middleware_log(req: Request, next: Next) -> Response {
let metodo = req.method().clone();
let uri = req.uri().clone();
let inicio = Instant::now();
let resposta = next.run(req).await;
let duracao = inicio.elapsed();
tracing::info!(
"{} {} -> {} em {:?}",
metodo,
uri,
resposta.status(),
duracao
);
resposta
}
Para usar o middleware na configuração de rotas:
use axum::middleware;
pub fn criar_rotas(pool: PgPool) -> Router {
Router::new()
.route("/api/tarefas", get(handlers::listar_tarefas))
.route("/api/tarefas", post(handlers::criar_tarefa))
.route("/api/tarefas/{id}", get(handlers::obter_tarefa))
.route("/api/tarefas/{id}", put(handlers::atualizar_tarefa))
.route("/api/tarefas/{id}", delete(handlers::deletar_tarefa))
.route("/api/saude", get(verificar_saude))
.layer(middleware::from_fn(middleware_log))
.with_state(pool)
}
Testando a API
Com o servidor rodando, use curl para testar:
# Verificar saúde da API
curl http://localhost:3000/api/saude
# Criar uma tarefa
curl -X POST http://localhost:3000/api/tarefas \
-H "Content-Type: application/json" \
-d '{"titulo": "Estudar Rust", "descricao": "Completar o tutorial de Axum"}'
# Listar todas as tarefas
curl http://localhost:3000/api/tarefas
# Atualizar uma tarefa (substitua o UUID)
curl -X PUT http://localhost:3000/api/tarefas/SEU-UUID-AQUI \
-H "Content-Type: application/json" \
-d '{"concluida": true}'
# Deletar uma tarefa
curl -X DELETE http://localhost:3000/api/tarefas/SEU-UUID-AQUI
Variáveis de Ambiente
Crie um arquivo .env na raiz do projeto:
DATABASE_URL=postgres://usuario:senha@localhost:5432/todo_api
Executando o Projeto
# Inicie o PostgreSQL (com Docker, por exemplo)
docker run -d --name postgres-todo \
-e POSTGRES_USER=usuario \
-e POSTGRES_PASSWORD=senha \
-e POSTGRES_DB=todo_api \
-p 5432:5432 \
postgres:16
# Execute a API
cargo run
Conclusão
Neste tutorial, construímos uma API REST completa com Axum que inclui:
- Rotas RESTful com todos os métodos CRUD
- Serialização JSON com serde
- Banco de dados PostgreSQL com sqlx
- Tratamento de erros robusto com tipos personalizados
- CORS configurado para permitir acesso de diferentes origens
- Middleware para logging e rastreamento
- Validação de dados nos handlers
O Axum é uma excelente escolha para APIs em Rust graças à sua integração com o ecossistema Tokio, sistema de tipos expressivo e performance excepcional. A partir daqui, você pode expandir o projeto adicionando autenticação (JWT), paginação, testes de integração e documentação com OpenAPI.