API REST com Axum em Rust: Tutorial | Rust Brasil

Tutorial de API REST com Axum em Rust: rotas, handlers, JSON, middleware e banco de dados. Guia prático em português.

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.