API REST CRUD Completa com Axum

Construa uma API REST CRUD completa em Rust com Axum, armazenamento em memória com HashMap, serialização JSON e tratamento de erros.

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

  1. Adicionar paginação — Implemente query parameters ?pagina=1&limite=10 para paginar a listagem de tarefas, retornando metadados como total de itens e total de páginas.

  2. Persistência com SQLite — Substitua o HashMap em memória por um banco SQLite usando a crate sqlx, adicionando migrations e queries parametrizadas.

  3. Validação de entrada — Use a crate validator para validar campos obrigatórios, tamanhos mínimos e máximos, e retorne mensagens de erro detalhadas em JSON.

  4. Filtros e busca — Adicione query parameters para filtrar tarefas por status (?concluida=true) e buscar por título (?busca=rust).

  5. Logging estruturado — Integre tracing e tracing-subscriber para registrar cada requisição com método, rota, status code e tempo de resposta.

Veja Também