Projeto: API REST em Rust
LidoNesta aula, você construirá uma API REST completa em Rust usando axum e sqlx, cobrindo rotas CRUD, serialização JSON e tratamento de erros. O projeto é progressivo, começando pela configuração do banco de dados e terminando com uma API funcional com validação e tratamento robusto de erros.
Nesta aula, vamos colocar a mão na massa e construir uma API REST completa em Rust. Você aprenderá a usar os principais crates do ecossistema: axum para o servidor web e sqlx para acesso a banco de dados. O objetivo é criar uma API de gerenciamento de tarefas (todo list) com operações CRUD completas, usando JSON para comunicação e um tratamento de erros profissional.
Vamos avançar passo a passo, desde a configuração do projeto até a implementação de cada endpoint, incluindo validação de dados e respostas de erro adequadas. Ao final, você terá uma base sólida para construir APIs reais em Rust, com código limpo e seguro.
axum + sqlx
O axum é um framework web moderno e ergonômico, construído sobre o tokio e o tower, que oferece um modelo de roteamento baseado em funções e uma integração perfeita com o ecossistema async do Rust. Já o sqlx é uma biblioteca de banco de dados que permite escrever queries SQL de forma segura, com verificação de tipos em tempo de compilação. Combinados, eles fornecem uma base sólida para construir APIs de alto desempenho com segurança e produtividade.
Vamos configurar um projeto com as seguintes dependências:
[package]
name = "api-rest"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "macros"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "1"
validator = { version = "0.16", features = ["derive"] }
Para o banco de dados, usaremos SQLite por simplicidade, mas o sqlx também é compatível com PostgreSQL e MySQL. Precisamos criar o banco e uma tabela de tarefas:
-- schema.sql
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT 0
);
Agora, vamos configurar a conexão com o banco e o estado da aplicação:
use axum::{Router, routing::get, extract::State};
use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
db: SqlitePool,
}
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
// Cria o pool de conexões
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect("sqlite:todo.db")
.await?;
// Executa o schema
sqlx::query(include_str!("../schema.sql"))
.execute(&pool)
.await?;
// Estado compartilhado
let state = AppState { db: pool };
// Router
let app = Router::new()
.route("/", get(|| async { "API de tarefas!" }))
.with_state(state);
// Inicia o servidor
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
println!("Servidor rodando em http://127.0.0.1:3000");
axum::serve(listener, app).await?;
Ok(())
}
Este é o esqueleto básico. Agora vamos adicionar as rotas CRUD.
Rotas CRUD
CRUD é um acrônimo para Create, Read, Update, Delete (Criar, Ler, Atualizar, Deletar). Em uma API REST, essas operações são mapeadas para os métodos HTTP POST, GET, PUT/PATCH e DELETE, respectivamente.
Vamos criar as rotas para nossa API de tarefas. Primeiro, definimos o modelo de dados e a estrutura de resposta:
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
struct Task {
id: i64,
title: String,
completed: bool,
}
#[derive(Debug, Deserialize, validator::Validate)]
struct CreateTask {
#[validate(length(min = 1, max = 200))]
title: String,
}
#[derive(Debug, Deserialize, validator::Validate)]
struct UpdateTask {
#[validate(length(min = 1, max = 200))]
title: Option,
completed: Option,
}
Agora, implementamos os handlers para cada operação. Cada handler recebe o estado e possivelmente o corpo da requisição.
use axum::{extract::{Path, State}, Json, http::StatusCode};
use validator::Validate;
async fn create_task(
State(state): State,
Json(payload): Json,
) -> Result<(StatusCode, Json), ApiError> {
payload.validate()?;
let result = sqlx::query_as::<_, Task>(
"INSERT INTO tasks (title) VALUES (?) RETURNING id, title, completed"
)
.bind(&payload.title)
.fetch_one(&state.db)
.await?;
Ok((StatusCode::CREATED, Json(result)))
}
async fn get_tasks(
State(state): State,
) -> Result>, ApiError> {
let tasks = sqlx::query_as::<_, Task>("SELECT id, title, completed FROM tasks")
.fetch_all(&state.db)
.await?;
Ok(Json(tasks))
}
async fn get_task(
State(state): State,
Path(id): Path,
) -> Result, ApiError> {
let task = sqlx::query_as::<_, Task>(
"SELECT id, title, completed FROM tasks WHERE id = ?"
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(task))
}
async fn update_task(
State(state): State,
Path(id): Path,
Json(payload): Json,
) -> Result, ApiError> {
payload.validate()?;
let existing = sqlx::query_as::<_, Task>(
"SELECT id, title, completed FROM tasks WHERE id = ?"
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or(ApiError::NotFound)?;
let title = payload.title.unwrap_or(existing.title);
let completed = payload.completed.unwrap_or(existing.completed);
let updated = sqlx::query_as::<_, Task>(
"UPDATE tasks SET title = ?, completed = ? WHERE id = ? RETURNING id, title, completed"
)
.bind(title)
.bind(completed)
.bind(id)
.fetch_one(&state.db)
.await?;
Ok(Json(updated))
}
async fn delete_task(
State(state): State,
Path(id): Path,
) -> Result {
let result = sqlx::query("DELETE FROM tasks WHERE id = ?")
.bind(id)
.execute(&state.db)
.await?;
if result.rows_affected() == 0 {
return Err(ApiError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
No router, adicionamos as rotas:
let app = Router::new()
.route("/", get(root))
.route("/tasks", get(get_tasks).post(create_task))
.route("/tasks/{id}", get(get_task).put(update_task).delete(delete_task))
.with_state(state);
Note que usamos o método put para atualização. Você pode usar patch se preferir.
JSON
JSON é o formato padrão para troca de dados em APIs REST. No axum, a extração e a resposta de JSON são feitas através dos tipos Json do axum, que usam serde para serialização/deserialização.
No código acima, já usamos Json tanto para extrair o corpo da requisição quanto para enviar respostas. O axum automaticamente valida o content-type (deve ser application/json) e serializa os dados.
Para garantir que os dados enviados pelo cliente sejam válidos, usamos o crate validator para validações de campo. No exemplo, validamos que o título não seja vazio e tenha no máximo 200 caracteres. Podemos adicionar mais validações, como formato de e-mail, etc.
É importante lembrar que, ao receber dados, você deve sempre validar antes de usar, para evitar dados malformados no banco.
Tratamento de erros
Em uma API, é essencial retornar erros de forma consistente e com o código HTTP apropriado. No Rust, podemos usar o crate thiserror para definir nossos tipos de erro, e depois implementar a conversão para axum::response::Response.
Vamos criar um enum de erro que cobre as principais falhas:
use axum::{response::{Response, IntoResponse}, Json};
use http::StatusCode;
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("recurso não encontrado")]
NotFound,
#[error("dados inválidos: {0}")]
Validation(String),
#[error("erro interno do servidor")]
Internal(#[from] anyhow::Error),
#[error("erro de banco de dados")]
Database(#[from] sqlx::Error),
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error_message) = match self {
ApiError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
ApiError::Validation(msg) => (StatusCode::BAD_REQUEST, msg),
ApiError::Internal(e) => {
eprintln!("Erro interno: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "erro interno do servidor".to_string())
}
ApiError::Database(e) => {
eprintln!("Erro de banco: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "erro de banco de dados".to_string())
}
};
let body = Json(serde_json::json!({
"error": error_message,
}));
(status, body).into_response()
}
}
Note que implementamos a conversão de sqlx::Error e anyhow::Error através de #[from], permitindo usar ? nos handlers. Para erros de validação, convertemos o erro do validator em nossa mensagem.
Também precisamos de uma implementação para o erro de validação do validator. Vamos adicionar um impl:
impl From for ApiError {
fn from(err: validator::ValidationErrors) -> Self {
let msgs: Vec = err.field_errors().into_iter()
.map(|(field, errors)| {
let msgs: Vec = errors.iter().map(|e| e.message.as_ref().map(|m| m.to_string()).unwrap_or_default()).collect();
format!("{}: {}", field, msgs.join(", "))
})
.collect();
ApiError::Validation(msgs.join("; "))
}
}
Com isso, os handlers podem retornar Result<_, ApiError> e usar ? para propagar erros.
No main, devemos adicionar um fallback para rotas não encontradas e um handler para erros globais:
async fn fallback() -> impl IntoResponse {
(StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "rota não encontrada" })))
}
let app = Router::new()
.route("/", get(root))
.route("/tasks", get(get_tasks).post(create_task))
.route("/tasks/{id}", get(get_task).put(update_task).delete(delete_task))
.fallback(fallback)
.with_state(state);
Assim, qualquer rota não existente retornará um JSON de erro 404.
Boas práticas e observações finais
Algumas boas práticas ao construir APIs em Rust:
- Valide sempre os dados de entrada – use validator ou validações manuais para evitar dados inválidos.
- Use transações quando precisar de consistência em múltiplas queries.
- Defina um tipo de erro centralizado para respostas consistentes.
- Use o padrão Result para capturar erros e evitar panics.
- Documente sua API com ferramentas como utoipa ou openapi.
Neste projeto, você aprendeu a configurar axum com sqlx, criar rotas CRUD, manipular JSON e tratar erros de forma profissional. Com essa base, você pode expandir para autenticação, paginação, testes e muito mais.
Referências
- Documentação oficial do axum
- Documentação oficial do sqlx
- Serde - Serialização para Rust
- Validator - Validação para Rust
- Thiserror - Biblioteca de erros
- Códigos de status HTTP (MDN)
- Postman - Ferramenta para testar APIs
Exercícios
- Exercício 1: Adicione uma rota
GET /tasks/completedque retorna apenas as tarefas concluídas (completed = true). Implemente o handler e teste. - Exercício 2: Modifique a rota de criação para aceitar um campo
completedopcional no corpo (default false). - Exercício 3: Implemente um endpoint
DELETE /tasksque apaga todas as tarefas concluídas e retorna o número de registros removidos. - Exercício 4: Adicione um middleware de log que imprime o método e a rota de cada requisição.
- Exercício 5: Crie um teste unitário para o handler
get_taskque verifica se retorna 404 quando a tarefa não existe. Use a bibliotecatowerpara testar o router.
.route("/tasks/completed", get(get_completed_tasks)) e implemente:async fn get_completed_tasks(State(state): State) -> Result>, ApiError> {
let tasks = sqlx::query_as::<_, Task>("SELECT id, title, completed FROM tasks WHERE completed = 1")
.fetch_all(&state.db)
.await?;
Ok(Json(tasks))
}
CreateTask para ter completed: Option<bool> e no handler use payload.completed.unwrap_or(false).#[derive(Debug, Deserialize, validator::Validate)]
struct CreateTask {
#[validate(length(min = 1, max = 200))]
title: String,
completed: Option,
}
// no handler:
let completed = payload.completed.unwrap_or(false);
.route("/tasks", get(get_tasks).post(create_task).delete(delete_completed)) e o handler:async fn delete_completed(State(state): State) -> Result, ApiError> {
let result = sqlx::query("DELETE FROM tasks WHERE completed = 1")
.execute(&state.db)
.await?;
Ok(Json(serde_json::json!({ "deleted": result.rows_affected() })))
}
tower-http = { version = "0.5", features = ["trace"] } e no main:let app = Router::new()
.route("/", get(root))
.route("/tasks", get(get_tasks).post(create_task))
.route("/tasks/{id}", get(get_task).put(update_task).delete(delete_task))
.fallback(fallback)
.layer(tower_http::trace::TraceLayer::new_for_http())
.with_state(state);
tower = { version = "0.4", features = ["util"] } como dev-dependency. Depois:#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
#[tokio::test]
async fn test_get_task_not_found() {
let pool = SqlitePoolOptions::new()
.connect(":memory:")
.await
.unwrap();
sqlx::query(include_str!("../schema.sql"))
.execute(&pool)
.await
.unwrap();
let state = AppState { db: pool };
let app = Router::new()
.route("/tasks/{id}", get(get_task))
.with_state(state);
let response = app
.oneshot(Request::builder().uri("/tasks/999").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}