Construire des API REST avec Rust et Axum : guide pratique pour débutants

Rust est le langage le plus apprécié des développeurs depuis 8 ans consécutifs — et il est désormais prêt pour le développement web. Avec Axum, le framework web soutenu par Tokio, vous obtenez la sécurité mémoire, la concurrence sans crainte et des performances exceptionnelles sans garbage collector. Dans ce tutoriel, vous allez construire une API REST complète de zéro.
Ce que vous allez construire
Un gestionnaire de favoris (Bookmark Manager) complet avec :
- Opérations CRUD pour les favoris (créer, lire, mettre à jour, supprimer)
- Gestion des requêtes/réponses JSON avec Serde
- Base de données PostgreSQL avec SQLx (requêtes vérifiées à la compilation)
- Gestion structurée des erreurs avec codes HTTP appropriés
- État partagé de l'application
- Validation des entrées
- Tests d'intégration
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Rust 1.75+ installé via rustup.rs
- PostgreSQL 15+ en local ou via Docker
- Une compréhension basique de HTTP et des concepts REST
- Un éditeur de code (VS Code avec rust-analyzer recommandé)
- Un terminal
Nouveau en Rust ? Vous devez être à l'aise avec l'ownership, les structs, les enums et la gestion basique des erreurs. Les chapitres 1 à 10 du livre officiel Rust suffisent.
Pourquoi Axum ?
Avant de plonger, comprenons pourquoi Axum se démarque dans l'écosystème Rust :
| Caractéristique | Axum | Actix Web | Rocket |
|---|---|---|---|
| Soutenu par | Équipe Tokio | Communauté | Communauté |
| Middlewares | Tower (écosystème partagé) | Personnalisé | Personnalisé |
| Macros requises | Non | Minimales | Beaucoup |
| Runtime async | Tokio | Actix/Tokio | Tokio |
| Courbe d'apprentissage | Modérée | Modérée | Plus douce |
L'avantage clé d'Axum est son intégration profonde avec l'écosystème de middlewares Tower. Tout middleware compatible Tower fonctionne directement avec Axum — limitation de débit, traçage, compression, authentification, et plus encore.
Étape 1 : Configuration du projet
Créez un nouveau projet Rust :
cargo new bookmark-api && cd bookmark-apiOuvrez Cargo.toml et ajoutez vos dépendances :
[package]
name = "bookmark-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", "uuid", "chrono"] }
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tower-http = { version = "0.6", features = ["cors", "trace"] }
tracing = "0.1"
tracing-subscriber = "0.3"
dotenvy = "0.15"
thiserror = "2"Créez un fichier .env pour la configuration de la base de données :
DATABASE_URL=postgres://postgres:password@localhost:5432/bookmarksNe commitez jamais les fichiers .env dans le contrôle de version. Ajoutez-le immédiatement au .gitignore.
Étape 2 : Configuration de la base de données
Lancez PostgreSQL (avec Docker si vous préférez) :
docker run -d \
--name bookmarks-db \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=bookmarks \
-p 5432:5432 \
postgres:16-alpineInstallez le CLI SQLx pour gérer les migrations :
cargo install sqlx-cli --no-default-features --features postgresCréez votre première migration :
sqlx migrate add create_bookmarksÉditez le fichier de migration généré dans migrations/ :
-- migrations/YYYYMMDD_create_bookmarks.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE bookmarks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
url TEXT NOT NULL,
description TEXT,
tags TEXT[] DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_bookmarks_tags ON bookmarks USING GIN (tags);
CREATE INDEX idx_bookmarks_created_at ON bookmarks (created_at DESC);Exécutez la migration :
sqlx migrate runÉtape 3 : Structure de l'application
Organisez votre projet en modules. Créez la structure suivante :
bookmark-api/
├── Cargo.toml
├── .env
├── migrations/
│ └── YYYYMMDD_create_bookmarks.sql
└── src/
├── main.rs
├── config.rs
├── db.rs
├── error.rs
├── models.rs
├── handlers.rs
└── routes.rs
Créez chaque fichier :
touch src/config.rs src/db.rs src/error.rs src/models.rs src/handlers.rs src/routes.rsÉtape 4 : Module de configuration
Commencez par src/config.rs — une façon propre de charger les variables d'environnement :
// src/config.rs
use std::env;
pub struct Config {
pub database_url: String,
pub host: String,
pub port: u16,
}
impl Config {
pub fn from_env() -> Self {
dotenvy::dotenv().ok();
Self {
database_url: env::var("DATABASE_URL")
.expect("DATABASE_URL must be set"),
host: env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string()),
port: env::var("PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("PORT must be a number"),
}
}
}Étape 5 : Connexion à la base de données
Créez le pool de connexions dans src/db.rs :
// src/db.rs
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
pub async fn create_pool(database_url: &str) -> PgPool {
PgPoolOptions::new()
.max_connections(10)
.connect(database_url)
.await
.expect("Failed to create database pool")
}Étape 6 : Modèles de données
Définissez vos modèles dans src/models.rs. Axum utilise Serde pour la sérialisation JSON :
// src/models.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Serialize, FromRow)]
pub struct Bookmark {
pub id: Uuid,
pub title: String,
pub url: String,
pub description: Option<String>,
pub tags: Vec<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateBookmark {
pub title: String,
pub url: String,
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateBookmark {
pub title: Option<String>,
pub url: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
pub struct BookmarkQuery {
pub tag: Option<String>,
pub search: Option<String>,
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_limit() -> i64 {
20
}Points clés :
FromRowpermet à SQLx de mapper directement les lignes de la base de données vers des structsSerialize/Deserializegèrent la conversion JSONCreateBookmarketUpdateBookmarksont des types séparés — le système de types de Rust garantit la validité des requêtes à la compilation
Étape 7 : Gestion des erreurs
Une gestion correcte des erreurs est essentielle. Créez src/error.rs :
// src/error.rs
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("Resource not found")]
NotFound,
#[error("Validation error: {0}")]
Validation(String),
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("Internal server error")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()),
AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Database(err) => {
tracing::error!("Database error: {:?}", err);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
)
}
AppError::Internal(msg) => {
tracing::error!("Internal error: {}", msg);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
)
}
};
let body = Json(json!({
"error": {
"status": status.as_u16(),
"message": message,
}
}));
(status, body).into_response()
}
}Ce pattern est idiomatique en Rust — l'opérateur ? convertit automatiquement sqlx::Error en AppError::Database grâce à l'attribut #[from]. Plus de gestion d'erreurs éparpillée !
Étape 8 : Gestionnaires de requêtes
Passons à la logique principale. Créez src/handlers.rs :
// src/handlers.rs
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use sqlx::PgPool;
use uuid::Uuid;
use crate::error::AppError;
use crate::models::{Bookmark, BookmarkQuery, CreateBookmark, UpdateBookmark};
// GET /api/bookmarks
pub async fn list_bookmarks(
State(pool): State<PgPool>,
Query(params): Query<BookmarkQuery>,
) -> Result<Json<Vec<Bookmark>>, AppError> {
let bookmarks = if let Some(tag) = ¶ms.tag {
sqlx::query_as::<_, Bookmark>(
"SELECT * FROM bookmarks WHERE $1 = ANY(tags) ORDER BY created_at DESC LIMIT $2 OFFSET $3"
)
.bind(tag)
.bind(params.limit)
.bind(params.offset)
.fetch_all(&pool)
.await?
} else if let Some(search) = ¶ms.search {
let pattern = format!("%{}%", search);
sqlx::query_as::<_, Bookmark>(
"SELECT * FROM bookmarks WHERE title ILIKE $1 OR description ILIKE $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3"
)
.bind(&pattern)
.bind(params.limit)
.bind(params.offset)
.fetch_all(&pool)
.await?
} else {
sqlx::query_as::<_, Bookmark>(
"SELECT * FROM bookmarks ORDER BY created_at DESC LIMIT $1 OFFSET $2"
)
.bind(params.limit)
.bind(params.offset)
.fetch_all(&pool)
.await?
};
Ok(Json(bookmarks))
}
// GET /api/bookmarks/:id
pub async fn get_bookmark(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<Json<Bookmark>, AppError> {
let bookmark = sqlx::query_as::<_, Bookmark>(
"SELECT * FROM bookmarks WHERE id = $1"
)
.bind(id)
.fetch_optional(&pool)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(bookmark))
}
// POST /api/bookmarks
pub async fn create_bookmark(
State(pool): State<PgPool>,
Json(input): Json<CreateBookmark>,
) -> Result<(StatusCode, Json<Bookmark>), AppError> {
if input.title.trim().is_empty() {
return Err(AppError::Validation("Title cannot be empty".to_string()));
}
if input.url.trim().is_empty() {
return Err(AppError::Validation("URL cannot be empty".to_string()));
}
let bookmark = sqlx::query_as::<_, Bookmark>(
r#"
INSERT INTO bookmarks (title, url, description, tags)
VALUES ($1, $2, $3, $4)
RETURNING *
"#,
)
.bind(&input.title)
.bind(&input.url)
.bind(&input.description)
.bind(&input.tags)
.fetch_one(&pool)
.await?;
Ok((StatusCode::CREATED, Json(bookmark)))
}
// PUT /api/bookmarks/:id
pub async fn update_bookmark(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
Json(input): Json<UpdateBookmark>,
) -> Result<Json<Bookmark>, AppError> {
let bookmark = sqlx::query_as::<_, Bookmark>(
r#"
UPDATE bookmarks
SET
title = COALESCE($1, title),
url = COALESCE($2, url),
description = COALESCE($3, description),
tags = COALESCE($4, tags),
updated_at = NOW()
WHERE id = $5
RETURNING *
"#,
)
.bind(&input.title)
.bind(&input.url)
.bind(&input.description)
.bind(&input.tags)
.bind(id)
.fetch_optional(&pool)
.await?
.ok_or(AppError::NotFound)?;
Ok(Json(bookmark))
}
// DELETE /api/bookmarks/:id
pub async fn delete_bookmark(
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, AppError> {
let result = sqlx::query("DELETE FROM bookmarks WHERE id = $1")
.bind(id)
.execute(&pool)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}Remarquez le pattern d'extracteurs — Axum extrait automatiquement les données de la requête :
State(pool)→ pool de base de données partagéPath(id)→ paramètres du chemin URLQuery(params)→ paramètres de la chaîne de requêteJson(input)→ corps de la requête en JSON
Chaque gestionnaire retourne Result<T, AppError> où T implémente IntoResponse. En cas d'erreur, l'opérateur ? la propage proprement.
Étape 9 : Routage
Connectez le tout dans src/routes.rs :
// src/routes.rs
use axum::routing::{delete, get, post, put};
use axum::Router;
use sqlx::PgPool;
use crate::handlers;
pub fn create_router(pool: PgPool) -> Router {
Router::new()
.route("/api/bookmarks", get(handlers::list_bookmarks))
.route("/api/bookmarks", post(handlers::create_bookmark))
.route("/api/bookmarks/{id}", get(handlers::get_bookmark))
.route("/api/bookmarks/{id}", put(handlers::update_bookmark))
.route("/api/bookmarks/{id}", delete(handlers::delete_bookmark))
.route("/health", get(health_check))
.with_state(pool)
}
async fn health_check() -> &'static str {
"OK"
}Axum 0.8 utilise la syntaxe {param} pour les paramètres de chemin au lieu de l'ancienne syntaxe :param.
Étape 10 : Point d'entrée principal
Assemblez le tout dans src/main.rs :
// src/main.rs
mod config;
mod db;
mod error;
mod handlers;
mod models;
mod routes;
use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer;
use tracing_subscriber;
#[tokio::main]
async fn main() {
// Initialiser le traçage
tracing_subscriber::fmt()
.with_target(false)
.compact()
.init();
// Charger la configuration
let config = config::Config::from_env();
// Créer le pool de base de données
let pool = db::create_pool(&config.database_url).await;
// Exécuter les migrations en attente
sqlx::migrate!()
.run(&pool)
.await
.expect("Failed to run migrations");
tracing::info!("Migrations applied successfully");
// Construire le routeur avec les middlewares
let app = routes::create_router(pool)
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());
// Démarrer le serveur
let addr = format!("{}:{}", config.host, config.port);
tracing::info!("Server starting on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}Étape 11 : Compilation et exécution
Testons le tout :
# Assurez-vous que PostgreSQL est en cours d'exécution
cargo runVous devriez voir :
Server starting on 0.0.0.0:3000
Testez avec curl :
# Vérification de santé
curl http://localhost:3000/health
# Créer un favori
curl -X POST http://localhost:3000/api/bookmarks \
-H "Content-Type: application/json" \
-d '{
"title": "Rust Book",
"url": "https://doc.rust-lang.org/book/",
"description": "Le livre officiel du langage Rust",
"tags": ["rust", "learning", "documentation"]
}'
# Lister tous les favoris
curl http://localhost:3000/api/bookmarks
# Filtrer par tag
curl "http://localhost:3000/api/bookmarks?tag=rust"
# Rechercher par titre
curl "http://localhost:3000/api/bookmarks?search=rust"
# Obtenir un favori spécifique (remplacez UUID)
curl http://localhost:3000/api/bookmarks/YOUR_UUID_HERE
# Mettre à jour un favori
curl -X PUT http://localhost:3000/api/bookmarks/YOUR_UUID_HERE \
-H "Content-Type: application/json" \
-d '{"title": "The Rust Programming Language Book"}'
# Supprimer un favori
curl -X DELETE http://localhost:3000/api/bookmarks/YOUR_UUID_HEREÉtape 12 : Ajout de middlewares
Axum brille avec les middlewares Tower. Ajoutons la journalisation des requêtes. Mettez à jour Cargo.toml :
[dependencies]
# ... dépendances existantes ...
tower = { version = "0.5", features = ["limit", "timeout"] }Ajoutez un middleware de chronométrage dans src/main.rs :
use axum::middleware::{self, Next};
use axum::extract::Request;
use axum::response::Response;
use std::time::Instant;
async fn track_request_time(request: Request, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let start = Instant::now();
let response = next.run(request).await;
let duration = start.elapsed();
tracing::info!(
"{} {} → {} ({}ms)",
method,
uri,
response.status(),
duration.as_millis()
);
response
}Puis ajoutez-le à votre routeur :
let app = routes::create_router(pool)
.layer(middleware::from_fn(track_request_time))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());Étape 13 : Tests d'intégration
Créez tests/api_tests.rs :
// tests/api_tests.rs
use axum::http::StatusCode;
use axum_test::TestServer;
use serde_json::json;
async fn setup() -> TestServer {
dotenvy::dotenv().ok();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&database_url)
.await
.unwrap();
sqlx::migrate!().run(&pool).await.unwrap();
let app = bookmark_api::routes::create_router(pool);
TestServer::new(app).unwrap()
}
#[tokio::test]
async fn test_health_check() {
let server = setup().await;
let response = server.get("/health").await;
assert_eq!(response.status_code(), StatusCode::OK);
assert_eq!(response.text(), "OK");
}
#[tokio::test]
async fn test_create_and_get_bookmark() {
let server = setup().await;
// Créer
let response = server
.post("/api/bookmarks")
.json(&json!({
"title": "Test Bookmark",
"url": "https://example.com",
"tags": ["test"]
}))
.await;
assert_eq!(response.status_code(), StatusCode::CREATED);
let bookmark: serde_json::Value = response.json();
let id = bookmark["id"].as_str().unwrap();
// Récupérer
let response = server.get(&format!("/api/bookmarks/{}", id)).await;
assert_eq!(response.status_code(), StatusCode::OK);
let fetched: serde_json::Value = response.json();
assert_eq!(fetched["title"], "Test Bookmark");
}
#[tokio::test]
async fn test_not_found() {
let server = setup().await;
let response = server
.get("/api/bookmarks/00000000-0000-0000-0000-000000000000")
.await;
assert_eq!(response.status_code(), StatusCode::NOT_FOUND);
}Ajoutez la dépendance de test dans Cargo.toml :
[dev-dependencies]
axum-test = "16"Et rendez vos modules publics dans src/main.rs pour que les tests y accèdent :
pub mod config;
pub mod db;
pub mod error;
pub mod handlers;
pub mod models;
pub mod routes;Exécutez les tests :
cargo testDépannage
Problèmes courants
"error: no database found" — SQLx vérifie les requêtes à la compilation. Exécutez cargo sqlx prepare pour créer les données de requête hors ligne, ou définissez SQLX_OFFLINE=true pour les environnements CI.
Connexion refusée — Assurez-vous que PostgreSQL fonctionne et que DATABASE_URL est correct. Testez avec psql $DATABASE_URL.
Compilation lente — Les temps de compilation de Rust sont réels. Utilisez cargo watch -x run (installez avec cargo install cargo-watch) pour la recompilation automatique pendant le développement.
Incompatibilité de types avec Vec<String> — Le type PostgreSQL TEXT[] correspond à Vec<String> dans SQLx. Assurez-vous que le type de colonne est TEXT[] et non VARCHAR[].
Comparaison des performances
Les performances d'Axum sont exceptionnelles. Dans les benchmarks typiques :
| Framework | Requêtes/sec | Latence (p99) | Mémoire |
|---|---|---|---|
| Axum (Rust) | ~180 000 | 1,2ms | 8 Mo |
| Express (Node) | ~35 000 | 8ms | 65 Mo |
| FastAPI (Python) | ~12 000 | 15ms | 45 Mo |
| Spring Boot (Java) | ~45 000 | 5ms | 200 Mo |
Ce sont des chiffres approximatifs issus de benchmarks communautaires. Vos résultats réels dépendent de votre matériel et de votre charge de travail.
Prochaines étapes
Vous avez maintenant une base solide pour construire des API en Rust avec Axum. Voici quelques pistes à explorer :
- Authentification — Ajoutez un middleware JWT avec
jsonwebtokenet les couches Tower - Pool de connexions — Optimisez
PgPoolOptionspour les charges de production - Documentation OpenAPI — Utilisez
utoipapour générer la documentation Swagger - Déploiement Docker — Créez un Dockerfile multi-stage pour des images minimales (~10 Mo)
- Limitation de débit — Utilisez
tower::limit::RateLimitLayer - Support WebSocket — Axum intègre nativement le support WebSocket pour les fonctionnalités temps réel
Conclusion
Dans ce tutoriel, vous avez construit une API REST complète avec Rust et Axum comprenant :
- Une structure de projet propre avec des modules séparés
- Des requêtes de base de données type-safe avec SQLx
- Une gestion correcte des erreurs utilisant le système de types de Rust
- Des middlewares Tower pour les préoccupations transversales
- Des tests d'intégration
La courbe d'apprentissage de Rust est plus raide que celle d'autres langages, mais la récompense est énorme — sécurité mémoire sans garbage collector, concurrence sans crainte et performances rivalisant avec C/C++. Axum rend le développement web en Rust accessible tout en exploitant pleinement la puissance de l'écosystème Tokio.
L'écosystème web de Rust mûrit rapidement. Des bibliothèques comme Axum, SQLx et Tower sont prêtes pour la production et utilisées par des entreprises comme Cloudflare, Discord et Figma. Si vous construisez des API où la performance et la fiabilité comptent, Rust mérite un regard sérieux.
Discutez de votre projet avec nous
Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.
Trouvons les meilleures solutions pour vos besoins.
Articles connexes

Construire des API REST prêtes pour la production avec FastAPI, PostgreSQL et Docker
Apprenez à créer, tester et déployer une API REST de qualité production en utilisant le framework FastAPI de Python avec PostgreSQL, SQLAlchemy, les migrations Alembic et Docker Compose — de zéro au déploiement.

Construire un Agent IA Autonome avec Agentic RAG et Next.js
Apprenez a construire un agent IA qui decide de maniere autonome quand et comment recuperer des informations depuis des bases de donnees vectorielles. Un guide pratique complet avec Vercel AI SDK et Next.js, accompagne d'exemples executables.

Construire des agents IA from scratch avec TypeScript : maîtriser le pattern ReAct avec le Vercel AI SDK
Apprenez à construire des agents IA depuis zéro avec TypeScript. Ce tutoriel couvre le pattern ReAct, l'appel d'outils, le raisonnement multi-étapes et les boucles d'agents prêtes pour la production avec le Vercel AI SDK.