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,tls-rustls-ring-webpki,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.8, tokio 1.x, sqlx 0.9, tower-http 0.6 pour CORS, trace et compression.
Axum 0.8 a changé la syntaxe des paramètres de chemin :
/tasks/:iddevient/tasks/{id}. Les tutoriels écrits pour 0.7 ne compilent plus tels quels. Autre changement utile :FromRequestPartsn’a plus besoin de#[async_trait].SQLx 0.9 a supprimé la feature
runtime-tokio-rustls. Elle se remplace par deux features séparées :runtime-tokioplus un backend TLS, par exempletls-rustls-ring-webpki.
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 !).
8. Tester l’API sans lancer Postgres#
C’est la partie que les tutoriels sautent, et c’est celle qui décide si tu garderas des tests dans six mois. Un test qui exige un conteneur Postgres finit par être désactivé le jour où la CI est lente.
PgPool::connect_lazy construit le pool sans ouvrir de connexion. Tout ce qui
se joue avant le premier accès base devient testable sans service externe :
routing, extracteurs, validation, mapping d’erreurs.
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt; // pour .oneshot()
fn test_app() -> axum::Router {
let pool = PgPoolOptions::new()
.max_connections(1)
.connect_lazy("postgres://localhost/none")
.expect("pool paresseux");
build_router(AppState::new(pool, test_config()))
}
#[tokio::test]
async fn tasks_sans_token_renvoie_401() {
let response = test_app()
.oneshot(Request::get("/tasks").body(Body::empty()).unwrap())
.await
.unwrap();
// L'extracteur rejette avant le handler : aucune connexion n'est demandée.
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
Ces tests tournent en quelques millisecondes et attrapent les régressions les
plus fréquentes : une route renommée, une validation qui ne rejette plus, un
code HTTP qui change. Les tests qui touchent vraiment la base méritent
#[sqlx::test] et un Postgres jetable, dans un autre fichier, lancé moins
souvent.
9. Pagination : le curseur plutôt que l’offset#
OFFSET 10000 demande à Postgres de lire dix mille lignes pour les jeter. La
page 1 est rapide, la page 500 ne l’est pas, et personne ne s’en aperçoit avant
la production.
// Le curseur est la date du dernier élément rendu.
let items = sqlx::query_as::<_, Task>(
"SELECT id, title, done, created_at FROM tasks \
WHERE user_id = $1 AND ($2::timestamptz IS NULL OR created_at < $2) \
ORDER BY created_at DESC LIMIT $3",
)
.bind(user_id)
.bind(cursor)
.bind(limit.clamp(1, 100))
.fetch_all(&pool)
.await?;
Deux détails qui comptent. Le clamp : un client ne décide pas de la charge du
serveur. Et l’index composite (user_id, created_at DESC), calqué exactement
sur le WHERE puis le ORDER BY, sans lequel tout ce qui précède ne sert à
rien.
10. Arrêt gracieux#
Sans prise en charge de SIGTERM, chaque déploiement coupe les requêtes en vol. C’est la cause la plus banale d’un pic de p99 en pleine journée, et la plus facile à corriger.
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
pool.close().await; // Postgres ne voit pas 12 connexions coupées net
async fn shutdown_signal() {
let ctrl_c = async { signal::ctrl_c().await.expect("handler Ctrl+C"); };
#[cfg(unix)]
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.expect("handler SIGTERM")
.recv()
.await;
};
tokio::select! {
_ = ctrl_c => tracing::info!("SIGINT reçu"),
_ = terminate => tracing::info!("SIGTERM reçu"),
}
}
Côté Docker, pense au stop_grace_period : s’il est plus court que tes requêtes
les plus lentes, le drain est interrompu quand même. Et pas de shell dans
l’entrypoint, sinon le signal s’arrête au shell au lieu d’atteindre le binaire.
Prochaines étapes#
- Ajoute l’auth : JWT via
jsonwebtoken, ou clés API en Postgres. Le flow complet est dans l’auth JWT dans Axum comme il faut - Tout ce qui précède, déjà câblé et testé : le template Axum du site
- Ajoute OpenAPI :
utoipa+ Swagger UI en 30 lignes (et le reste de la stack dans les meilleures crates Rust pour une API) - Réduis la latence de queue : voir Comment j’ai réduit la p99 de 60 %