Neste projeto vamos construir do zero uma API REST CRUD completa usando o framework Axum. A API vai gerenciar uma coleção de tarefas (to-do items) com operações de criação, leitura, atualização e exclusão. Utilizaremos armazenamento em memória com HashMap protegido por Arc<Mutex<>>, serialização JSON com serde, e tratamento de erros robusto com códigos de status HTTP adequados.
Este é um projeto fundamental para quem quer desenvolver serviços web em Rust. Ao final, você terá uma API funcional que pode ser estendida com banco de dados, autenticação e muito mais.
O Que Vamos Construir
Uma API REST para gerenciamento de tarefas com as seguintes funcionalidades:
- GET /tarefas — Listar todas as tarefas
- GET /tarefas/:id — Buscar uma tarefa específica
- POST /tarefas — Criar uma nova tarefa
- PUT /tarefas/:id — Atualizar uma tarefa existente
- DELETE /tarefas/:id — Remover uma tarefa
- Serialização e deserialização JSON automática
- Tratamento de erros com códigos HTTP apropriados (404, 400, 201, etc.)
- Estado compartilhado thread-safe com
Arc<Mutex<HashMap>>
Estrutura do Projeto
api-rest-crud/
├── Cargo.toml
└── src/
└── main.rs
Configurando o Projeto
Crie o projeto com o Cargo:
cargo new api-rest-crud
cd api-rest-crud
Edite o Cargo.toml com as dependências necessárias:
[package]
name = "api-rest-crud"
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"] }
Usamos axum como framework web, tokio como runtime assíncrono, serde para serialização JSON e uuid para gerar identificadores únicos.
Passo 1: Definindo os Modelos de Dados
Primeiro, vamos definir as estruturas que representam nossas tarefas e o estado da aplicação:
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
// Modelo de uma tarefa
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tarefa {
pub id: String,
pub titulo: String,
pub descricao: String,
pub concluida: bool,
}
// Dados para criar uma nova tarefa (sem id, pois será gerado)
#[derive(Debug, Deserialize)]
pub struct NovaTarefa {
pub titulo: String,
pub descricao: String,
}
// Dados para atualizar uma tarefa existente
#[derive(Debug, Deserialize)]
pub struct AtualizarTarefa {
pub titulo: Option<String>,
pub descricao: Option<String>,
pub concluida: Option<bool>,
}
// Estado compartilhado da aplicação
pub type EstadoApp = Arc<Mutex<HashMap<String, Tarefa>>>;
A struct Tarefa representa o recurso completo com id, titulo, descricao e concluida. As structs NovaTarefa e AtualizarTarefa servem como DTOs (Data Transfer Objects) para receber dados do cliente. O tipo EstadoApp encapsula nosso “banco de dados” em memória de forma thread-safe usando Arc para compartilhamento entre threads e Mutex para acesso exclusivo.
Passo 2: Implementando os Handlers
Cada handler corresponde a uma operação CRUD. Vamos implementá-los um por um:
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
Json,
};
// GET /tarefas — Listar todas as tarefas
async fn listar_tarefas(
State(estado): State<EstadoApp>,
) -> impl IntoResponse {
let tarefas = estado.lock().unwrap();
let lista: Vec<Tarefa> = tarefas.values().cloned().collect();
Json(lista)
}
// GET /tarefas/:id — Buscar uma tarefa por ID
async fn buscar_tarefa(
State(estado): State<EstadoApp>,
Path(id): Path<String>,
) -> Result<Json<Tarefa>, StatusCode> {
let tarefas = estado.lock().unwrap();
tarefas
.get(&id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
// POST /tarefas — Criar nova tarefa
async fn criar_tarefa(
State(estado): State<EstadoApp>,
Json(nova): Json<NovaTarefa>,
) -> impl IntoResponse {
let tarefa = Tarefa {
id: Uuid::new_v4().to_string(),
titulo: nova.titulo,
descricao: nova.descricao,
concluida: false,
};
let mut tarefas = estado.lock().unwrap();
tarefas.insert(tarefa.id.clone(), tarefa.clone());
(StatusCode::CREATED, Json(tarefa))
}
// PUT /tarefas/:id — Atualizar tarefa existente
async fn atualizar_tarefa(
State(estado): State<EstadoApp>,
Path(id): Path<String>,
Json(dados): Json<AtualizarTarefa>,
) -> Result<Json<Tarefa>, StatusCode> {
let mut tarefas = estado.lock().unwrap();
let tarefa = tarefas.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
if let Some(titulo) = dados.titulo {
tarefa.titulo = titulo;
}
if let Some(descricao) = dados.descricao {
tarefa.descricao = descricao;
}
if let Some(concluida) = dados.concluida {
tarefa.concluida = concluida;
}
Ok(Json(tarefa.clone()))
}
// DELETE /tarefas/:id — Remover tarefa
async fn remover_tarefa(
State(estado): State<EstadoApp>,
Path(id): Path<String>,
) -> StatusCode {
let mut tarefas = estado.lock().unwrap();
if tarefas.remove(&id).is_some() {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
Observe como cada handler usa State(estado) para acessar o estado compartilhado. O Mutex garante que apenas uma thread acesse o HashMap por vez. Usamos Result<Json<T>, StatusCode> para retornar o dado ou um código de erro HTTP, e o Axum converte isso automaticamente na resposta correta.
Passo 3: Configurando as Rotas e Tratamento de Erros
Vamos criar um handler para responder a rotas não encontradas e uma função para montar o roteador:
use axum::Router;
use axum::routing::{get, post, put, delete};
// Handler para rotas não encontradas
async fn rota_nao_encontrada() -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"erro": "Rota não encontrada",
"codigo": 404
})),
)
}
// Monta o roteador com todas as rotas
fn criar_roteador(estado: EstadoApp) -> Router {
Router::new()
.route("/tarefas", get(listar_tarefas).post(criar_tarefa))
.route(
"/tarefas/{id}",
get(buscar_tarefa)
.put(atualizar_tarefa)
.delete(remover_tarefa),
)
.fallback(rota_nao_encontrada)
.with_state(estado)
}
O Axum permite encadear múltiplos métodos HTTP na mesma rota usando .get(), .post(), .put() e .delete(). A função fallback captura qualquer requisição que não corresponda a nenhuma rota definida.
Passo 4: Montando o main.rs Completo
Aqui está o código completo do src/main.rs:
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use uuid::Uuid;
// === Modelos ===
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tarefa {
pub id: String,
pub titulo: String,
pub descricao: String,
pub concluida: bool,
}
#[derive(Debug, Deserialize)]
pub struct NovaTarefa {
pub titulo: String,
pub descricao: String,
}
#[derive(Debug, Deserialize)]
pub struct AtualizarTarefa {
pub titulo: Option<String>,
pub descricao: Option<String>,
pub concluida: Option<bool>,
}
pub type EstadoApp = Arc<Mutex<HashMap<String, Tarefa>>>;
// === Handlers ===
async fn listar_tarefas(State(estado): State<EstadoApp>) -> impl IntoResponse {
let tarefas = estado.lock().unwrap();
let lista: Vec<Tarefa> = tarefas.values().cloned().collect();
Json(lista)
}
async fn buscar_tarefa(
State(estado): State<EstadoApp>,
Path(id): Path<String>,
) -> Result<Json<Tarefa>, StatusCode> {
let tarefas = estado.lock().unwrap();
tarefas
.get(&id)
.cloned()
.map(Json)
.ok_or(StatusCode::NOT_FOUND)
}
async fn criar_tarefa(
State(estado): State<EstadoApp>,
Json(nova): Json<NovaTarefa>,
) -> impl IntoResponse {
let tarefa = Tarefa {
id: Uuid::new_v4().to_string(),
titulo: nova.titulo,
descricao: nova.descricao,
concluida: false,
};
let mut tarefas = estado.lock().unwrap();
tarefas.insert(tarefa.id.clone(), tarefa.clone());
(StatusCode::CREATED, Json(tarefa))
}
async fn atualizar_tarefa(
State(estado): State<EstadoApp>,
Path(id): Path<String>,
Json(dados): Json<AtualizarTarefa>,
) -> Result<Json<Tarefa>, StatusCode> {
let mut tarefas = estado.lock().unwrap();
let tarefa = tarefas.get_mut(&id).ok_or(StatusCode::NOT_FOUND)?;
if let Some(titulo) = dados.titulo {
tarefa.titulo = titulo;
}
if let Some(descricao) = dados.descricao {
tarefa.descricao = descricao;
}
if let Some(concluida) = dados.concluida {
tarefa.concluida = concluida;
}
Ok(Json(tarefa.clone()))
}
async fn remover_tarefa(
State(estado): State<EstadoApp>,
Path(id): Path<String>,
) -> StatusCode {
let mut tarefas = estado.lock().unwrap();
if tarefas.remove(&id).is_some() {
StatusCode::NO_CONTENT
} else {
StatusCode::NOT_FOUND
}
}
async fn rota_nao_encontrada() -> impl IntoResponse {
(
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"erro": "Rota não encontrada",
"codigo": 404
})),
)
}
// === Aplicação ===
#[tokio::main]
async fn main() {
let estado: EstadoApp = Arc::new(Mutex::new(HashMap::new()));
let app = Router::new()
.route("/tarefas", get(listar_tarefas).post(criar_tarefa))
.route(
"/tarefas/{id}",
get(buscar_tarefa)
.put(atualizar_tarefa)
.delete(remover_tarefa),
)
.fallback(rota_nao_encontrada)
.with_state(estado);
let endereco = "0.0.0.0:3000";
println!("Servidor rodando em http://{}", endereco);
let listener = tokio::net::TcpListener::bind(endereco).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
O main cria o estado compartilhado, monta o roteador com todas as rotas e inicia o servidor na porta 3000. O tokio::main configura o runtime assíncrono automaticamente.
Como Executar
Compile e inicie o servidor:
cargo run
Em outro terminal, teste as operações com curl:
# Criar uma tarefa
curl -X POST http://localhost:3000/tarefas \
-H "Content-Type: application/json" \
-d '{"titulo": "Estudar Rust", "descricao": "Ler o capítulo sobre ownership"}'
# Resposta:
# {"id":"a1b2c3d4-...","titulo":"Estudar Rust","descricao":"Ler o capítulo sobre ownership","concluida":false}
# Listar todas as tarefas
curl http://localhost:3000/tarefas
# Buscar uma tarefa específica (substitua pelo id retornado)
curl http://localhost:3000/tarefas/a1b2c3d4-...
# Atualizar uma tarefa
curl -X PUT http://localhost:3000/tarefas/a1b2c3d4-... \
-H "Content-Type: application/json" \
-d '{"concluida": true}'
# Remover uma tarefa
curl -X DELETE http://localhost:3000/tarefas/a1b2c3d4-...
Desafios para Expandir
Adicionar paginação — Implemente query parameters
?pagina=1&limite=10para paginar a listagem de tarefas, retornando metadados como total de itens e total de páginas.Persistência com SQLite — Substitua o
HashMapem memória por um banco SQLite usando a cratesqlx, adicionando migrations e queries parametrizadas.Validação de entrada — Use a crate
validatorpara validar campos obrigatórios, tamanhos mínimos e máximos, e retorne mensagens de erro detalhadas em JSON.Filtros e busca — Adicione query parameters para filtrar tarefas por status (
?concluida=true) e buscar por título (?busca=rust).Logging estruturado — Integre
tracingetracing-subscriberpara registrar cada requisição com método, rota, status code e tempo de resposta.
Veja Também
- HashMap na Biblioteca Padrão — Como funciona o HashMap que usamos como armazenamento
- Mutex para Exclusão Mútua — Entenda o Mutex usado para proteger o estado
- Arc para Compartilhamento entre Threads — Como Arc permite compartilhar dados entre tasks assíncronas
- Axum vs Actix: Qual Framework Escolher? — Comparativo entre os principais frameworks web de Rust
- Tutorial: API REST com Axum — Tutorial complementar sobre APIs REST