Construire une API REST performante en Rust avec Axum et Postgres
Construis une API REST Rust prête pour la prod avec Axum, Tokio, SQLx et Postgres : routing, state, validation, erreurs, tracing, pooling, migrations.
Oublie les tutos jouets de type “hello world”. Voici comment construire une API REST prête pour la production en Rust avec Axum + SQLx + Postgres, la stack que je recommande à chaque équipe backend en 2026.
Au programme : routing propre, state partagé, JSON validé, erreurs correctes, logs structurés, pool de connexions et migrations.
Pourquoi Axum ?
- Construit sur Tokio + Tower + Hyper : des middlewares matures et composables
- Système d’extracteurs (
State,Json,Path,Query) : moins de boilerplate qu’Actix - Tests faciles, OpenAPI facile via
utoipaouaide
Si tu viens d’Express, FastAPI ou du chi de Go, Axum te semblera familier, avec la sécurité de Rust en plus.
1. Setup du projet
cargo new rust-tasks-api && cd rust-tasks-api
cargo add axum tokio -F full serde -F derive serde_json
cargo add sqlx -F runtime-tokio,postgres,macros,migrate
cargo add tracing tracing-subscriber tower tower-http validator -F derive
cargo add thiserror anyhow uuid -F v4,serde dotenvy
L’essentiel du Cargo.toml : axum 0.7, tokio 1.x, sqlx 0.8, tower-http pour CORS, trace et compression.
2. Une architecture qui tient la charge
src/
main.rs # câblage : config, pool, router, serve
config.rs # config d'env
db.rs # PgPool
error.rs # AppError -> HTTP
state.rs # AppState
routes/
mod.rs
tasks.rs # handlers
health.rs
models/
task.rs
Règle : des handlers fins. La logique métier vit dans des fonctions qui prennent &PgPool et renvoient Result<T, AppError>. Tu me remercieras à 50 routes.
3. State et pool
// state.rs
use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub db: PgPool,
}
// db.rs
use sqlx::postgres::{PgPoolOptions, PgPool};
pub async fn connect(url: &str) -> anyhow::Result<PgPool> {
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(2)
.acquire_timeout(std::time::Duration::from_secs(3))
.connect(url).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}
Pourquoi max_connections(20) ? Commence petit. Postgres déteste 200 connexions inactives. Scale avec PgBouncer plus tard, pas avec un pool géant.
4. Des erreurs bien gérées
// error.rs
use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("not found")]
NotFound,
#[error("bad request: {0}")]
BadRequest(String),
#[error(transparent)]
Db(#[from] sqlx::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, msg) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
AppError::Db(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal error".into()),
};
tracing::error!(error = ?self, "request failed");
(status, Json(json!({ "error": msg }))).into_response()
}
}
Ne expose jamais les détails de sqlx::Error aux clients. Loggue l’erreur complète, renvoie une 500 générique.
5. Handlers : du CRUD avec validation
// models/task.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use validator::Validate;
#[derive(Serialize, sqlx::FromRow)]
pub struct Task { pub id: Uuid, pub title: String, pub done: bool }
#[derive(Deserialize, Validate)]
pub struct CreateTask {
#[validate(length(min = 1, max = 200))]
pub title: String,
}
// routes/tasks.rs
use axum::{extract::{Path, State}, Json};
use uuid::Uuid;
use crate::{state::AppState, error::AppError, models::task::*};
pub async fn create_task(
State(s): State<AppState>,
Json(input): Json<CreateTask>,
) -> Result<(axum::http::StatusCode, Json<Task>), AppError> {
use validator::Validate;
input.validate().map_err(|e| AppError::BadRequest(e.to_string()))?;
let task = sqlx::query_as!(
Task,
"INSERT INTO tasks (id, title, done) VALUES ($1, $2, false) RETURNING id, title, done",
Uuid::new_v4(), input.title
).fetch_one(&s.db).await?;
Ok((axum::http::StatusCode::CREATED, Json(task)))
}
pub async fn get_task(
State(s): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Task>, AppError> {
let task = sqlx::query_as!(Task, "SELECT id, title, done FROM tasks WHERE id=$1", id)
.fetch_optional(&s.db).await?
.ok_or(AppError::NotFound)?;
Ok(Json(task))
}
Avec les macros query_as! et query!, ton SQL est vérifié à la compilation. Les fautes de frappe sont caught avant la CI.
6. Router et middlewares
// main.rs (extrait)
use axum::{routing::{get, post}, Router};
use tower_http::{trace::TraceLayer, cors::CorsLayer, compression::CompressionLayer};
use std::net::SocketAddr;
let app = Router::new()
.route("/health", get(health))
.route("/tasks", post(create_task).get(list_tasks))
.route("/tasks/:id", get(get_task))
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive())
.with_state(state);
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
axum::serve(tokio::net::TcpListener::bind(addr).await?, app).await?;
Ajoute TraceLayer dès le premier jour. Débugger la prod sans trace_id ni logs structurés, c’est l’enfer.
7. Benchmark rapide
oha -c 100 -z 20s http://localhost:8080/health
# Attends-toi à 30k-60k rps sur health, 5k-15k rps sur un SELECT Postgres simple, sur un laptop
Si tu es sous 2k rps sur des lectures triviales, vérifie : taille du pool, index manquant, allocs JSON, build debug (utilise --release !).
Prochaines étapes
- Ajoute l’auth : JWT via
jsonwebtoken+axum-login, ou clés API en Postgres - Ajoute OpenAPI :
utoipa+ Swagger UI en 30 lignes - Déploie pas cher : voir Héberger une API Rust sur Hetzner pour 6 €/mois
- Réduis la latence de queue : voir Comment j’ai réduit la p99 de 60 %
🚀 Tu veux le starter complet ? Récupère mon template Axum gratuit (cette structure + Dockerfile + CI + migrations) dans la newsletter. La Roadmap payante va 10x plus loin : tests, auth, observabilité, déploiement.