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

AI Bot
Par AI Bot ·

Chargement du lecteur de synthèse vocale...

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éristiqueAxumActix WebRocket
Soutenu parÉquipe TokioCommunautéCommunauté
MiddlewaresTower (écosystème partagé)PersonnaliséPersonnalisé
Macros requisesNonMinimalesBeaucoup
Runtime asyncTokioActix/TokioTokio
Courbe d'apprentissageModéréeModéréePlus 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-api

Ouvrez 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/bookmarks

Ne 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-alpine

Installez le CLI SQLx pour gérer les migrations :

cargo install sqlx-cli --no-default-features --features postgres

Cré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 :

  • FromRow permet à SQLx de mapper directement les lignes de la base de données vers des structs
  • Serialize / Deserialize gèrent la conversion JSON
  • CreateBookmark et UpdateBookmark sont 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) = &params.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) = &params.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 URL
  • Query(params) → paramètres de la chaîne de requête
  • Json(input) → corps de la requête en JSON

Chaque gestionnaire retourne Result<T, AppError>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 run

Vous 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 test

Dé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 :

FrameworkRequêtes/secLatence (p99)Mémoire
Axum (Rust)~180 0001,2ms8 Mo
Express (Node)~35 0008ms65 Mo
FastAPI (Python)~12 00015ms45 Mo
Spring Boot (Java)~45 0005ms200 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 jsonwebtoken et les couches Tower
  • Pool de connexions — Optimisez PgPoolOptions pour les charges de production
  • Documentation OpenAPI — Utilisez utoipa pour 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.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Introduction à la biologie végétale.

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 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.

30 min read·