ITSkillsCenter
Développement Web

Actix-web 4 : API REST production en Rust pas-à-pas

15 min de lecture

📍 Guide principal : Rust pour le web perf-critique : choix, stack et tutoriels 2026
Cet article fait partie d’une série pratique sur Rust en production. Pour la vue d’ensemble, lire d’abord le guide.

Introduction

Vous savez ce que vaut Rust pour des services exigeants. Vous voulez maintenant écrire un vrai service. Ce tutoriel monte de zéro un service CRUD type production avec Actix-web 4.13 et Tokio, des routes, des middlewares, du JSON validé, une vraie base PostgreSQL via SQLx, des tests d’intégration et un Dockerfile distroless. Vous y trouverez tout ce qu’il faut pour partir d’un dossier vide et arriver à un binaire que vous pouvez faire tourner sous systemd ou en conteneur dans la soirée.

Le service à construire est le même catalogue de produits qu’en Go pour faciliter la comparaison directe. À la fin du tutoriel, vous aurez les briques essentielles : structure de projet idiomatique, gestion d’erreurs typées, validation déclarative, requêtes SQL vérifiées à la compilation, observabilité avec tracing, et pipeline de build optimisée pour la production.

Prérequis

  • Rust 1.88 ou plus récent — vérifiez avec rustc --version
  • Cargo (installé automatiquement avec rustup)
  • Docker 24+ pour PostgreSQL et l’image finale
  • cargo-watch et cargo-nextest recommandés mais facultatifs
  • Niveau attendu : intermédiaire — vous devez avoir déjà compilé un projet Rust et compris ownership et borrowing au niveau de base
  • Temps estimé : 120 à 150 minutes

Étape 1 — Initialiser le projet et configurer Cargo

La structure d’un projet Rust commence par cargo new --bin. Le fichier Cargo.toml est le manifeste qui déclare les métadonnées et les dépendances. Bien le configurer dès le départ vous évite des migrations pénibles plus tard. On déclare les versions précises pour Actix-web, Tokio, SQLx et les bibliothèques de base que tout service web utilise.

cargo new --bin catalogue
cd catalogue

Ouvrez ensuite Cargo.toml et remplacez son contenu par les dépendances ci-dessous. Notez que tokio est déclaré avec la feature full pendant le développement pour activer toutes les primitives ; en production très contrainte vous pouvez ne déclarer que les features nécessaires pour réduire le binaire.

[package]
name = "catalogue"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"

[dependencies]
actix-web = "4.13"
tokio = { version = "1.51", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls-ring-webpki", "postgres", "macros", "chrono"] }
chrono = { version = "0.4", features = ["serde"] }
validator = { version = "0.20", features = ["derive"] }
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-actix-web = "0.7"

[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"

Le profil release optimise pour la production : lto = "thin" active le link-time optimization sans exploser les temps de build, codegen-units = 1 permet à LLVM d’optimiser globalement le binaire au prix d’un build plus lent, et strip = "symbols" retire les symboles de debug. Lancez cargo build ; le premier run télécharge et compile toutes les dépendances en quelques minutes selon votre machine. Un build incrémental ultérieur prendra quelques secondes.

Étape 2 — Démarrer PostgreSQL pour le développement

Plutôt que d’installer PostgreSQL nativement, on utilise un conteneur Docker. C’est plus simple à reset et ne pollue pas votre environnement local. Le mode --rm garantit que le conteneur disparaît à l’arrêt ; pour la persistance entre sessions, on monterait un volume.

docker run --rm -d --name pg-cat \
  -e POSTGRES_USER=cat -e POSTGRES_PASSWORD=cat -e POSTGRES_DB=cat \
  -p 5432:5432 postgres:16-alpine

La commande lance PostgreSQL 16 sur le port 5432 avec une base cat, un utilisateur cat et un mot de passe trivial — acceptable seulement en local. Vérifiez que le conteneur tourne avec docker ps | grep pg-cat. Vous devez voir une ligne avec un statut Up et le port mappé. Configurez ensuite la variable d’environnement DATABASE_URL : export DATABASE_URL=postgres://cat:cat@localhost/cat. Cette variable sera utilisée par SQLx au build et au runtime.

Étape 3 — Créer le schéma SQL et les migrations

SQLx fournit un outil CLI sqlx-cli pour gérer les migrations. Installez-le avec cargo install sqlx-cli --no-default-features --features postgres. Cette installation prend quelques minutes la première fois. Une fois disponible, créez votre première migration.

sqlx migrate add creer_table_produits

La commande génère un fichier migrations/<timestamp>_creer_table_produits.sql. Ouvrez-le et remplissez-le avec le schéma de la table.

-- migrations/<timestamp>_creer_table_produits.sql
CREATE TABLE produits (
    id BIGSERIAL PRIMARY KEY,
    nom TEXT NOT NULL,
    prix_centime BIGINT NOT NULL CHECK (prix_centime >= 0),
    stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0),
    cree_le TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_produits_cree_le ON produits (cree_le DESC);

Lancez ensuite sqlx migrate run. La commande applique les migrations dans l’ordre et inscrit chaque migration appliquée dans une table interne _sqlx_migrations. Une sortie de type Applied 20250101000000/migrate creer_table_produits confirme le succès. Si vous avez une erreur de connexion, vérifiez que $DATABASE_URL est exporté dans votre shell.

Étape 4 — Définir le modèle métier et les erreurs

Avant tout code HTTP, on définit les types métier. La struct Produit représente le format renvoyé par l’API. La struct NouveauProduit représente le payload accepté en entrée — on les sépare parce que les champs en lecture (id, cree_le) ne sont pas pertinents en écriture. Cette séparation est une discipline qui paie en lisibilité et en sécurité.

// src/model.rs
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use validator::Validate;

#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct Produit {
    pub id: i64,
    pub nom: String,
    pub prix_centime: i64,
    pub stock: i32,
    pub cree_le: DateTime<Utc>,
}

#[derive(Debug, Deserialize, Validate)]
pub struct NouveauProduit {
    #[validate(length(min = 2, max = 120))]
    pub nom: String,
    #[validate(range(min = 0))]
    pub prix_centime: i64,
    #[validate(range(min = 0))]
    pub stock: i32,
}

Trois choses à observer. sqlx::FromRow permet à SQLx de mapper automatiquement une ligne de résultat vers la struct. Validate de la crate validator ajoute la vérification déclarative — chaque attribut produit une erreur typée si la contrainte est violée. La séparation lecture/écriture est explicite par deux structs distinctes. Compilez avec cargo check ; le compilateur ne doit signaler aucune erreur. Si vous oubliez de déclarer le module model dans main.rs, vous aurez un message clair.

Étape 5 — Définir le type d’erreur applicatif

Une bonne API ne laisse jamais fuir des messages d’erreur internes. On définit un type AppError avec thiserror qui catégorise les erreurs et implémente ResponseError d’Actix-web pour les convertir en réponses HTTP appropriées. Cette discipline rend le code des handlers concis et la gestion d’erreurs cohérente sur tout le service.

// src/error.rs
use actix_web::{HttpResponse, ResponseError, http::StatusCode};
use serde_json::json;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("ressource introuvable")]
    Introuvable,
    #[error("requete invalide : {0}")]
    Validation(String),
    #[error("erreur interne")]
    Interne(#[from] anyhow::Error),
    #[error("erreur base de donnees")]
    BD(#[from] sqlx::Error),
}

impl ResponseError for AppError {
    fn status_code(&self) -> StatusCode {
        match self {
            AppError::Introuvable => StatusCode::NOT_FOUND,
            AppError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
            AppError::Interne(_) | AppError::BD(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }

    fn error_response(&self) -> HttpResponse {
        let public = match self {
            AppError::Validation(m) => m.clone(),
            _ => self.to_string(),
        };
        HttpResponse::build(self.status_code()).json(json!({"erreur": public}))
    }
}

Le pattern est important : on logue l’erreur réelle côté serveur (avec tracing qu’on ajoutera plus loin) mais on renvoie au client uniquement un message générique pour les erreurs internes. Pour les erreurs de validation, le message est sûr à exposer parce qu’il vient du contrat d’entrée. Le compilateur valide la cohérence entre les variants et les conversions From via #[from]. Un cargo check doit toujours passer après cette étape.

Étape 6 — Écrire les handlers HTTP

Actix-web utilise des extracteurs typés : web::Json pour le payload, web::Path pour les paramètres d’URL, web::Data pour l’état partagé (le pool SQLx). Les handlers retournent Result<impl Responder, AppError>, ce qui rend le code naturel à lire et le traitement d’erreur uniforme.

// src/handlers.rs
use actix_web::{web, HttpResponse};
use sqlx::PgPool;
use validator::Validate;
use crate::error::AppError;
use crate::model::{NouveauProduit, Produit};

pub async fn creer_produit(
    pool: web::Data<PgPool>,
    payload: web::Json<NouveauProduit>,
) -> Result<HttpResponse, AppError> {
    payload.validate().map_err(|e| AppError::Validation(e.to_string()))?;

    let p = sqlx::query_as!(Produit,
        r#"INSERT INTO produits (nom, prix_centime, stock)
           VALUES ($1, $2, $3)
           RETURNING id, nom, prix_centime, stock, cree_le"#,
        payload.nom, payload.prix_centime, payload.stock
    )
    .fetch_one(pool.get_ref())
    .await?;

    Ok(HttpResponse::Created().json(p))
}

pub async fn lister_produits(
    pool: web::Data<PgPool>,
    web::Query(params): web::Query<ListerParams>,
) -> Result<HttpResponse, AppError> {
    let limite = params.limite.unwrap_or(50).clamp(1, 200);
    let items = sqlx::query_as!(Produit,
        "SELECT id, nom, prix_centime, stock, cree_le FROM produits ORDER BY id DESC LIMIT $1",
        limite as i64
    )
    .fetch_all(pool.get_ref())
    .await?;
    Ok(HttpResponse::Ok().json(items))
}

#[derive(serde::Deserialize)]
pub struct ListerParams {
    pub limite: Option<i32>,
}

La macro sqlx::query_as! est le cœur de la valeur ajoutée de SQLx. À la compilation, SQLx se connecte à la base via $DATABASE_URL, prépare la requête, vérifie que les colonnes retournées correspondent aux champs de Produit, et que les paramètres positionnels sont bien typés. Une erreur de schéma fait échouer cargo check, pas la production. La méthode clamp(1, 200) sur le paramètre limite protège contre les abus en bornant la valeur. Vérifiez avec cargo check que tout compile ; un échec ici signifie souvent que DATABASE_URL n’est pas exporté dans votre environnement.

Étape 7 — Câbler le routeur, les middlewares et le pool

Le main.rs orchestre l’application. On installe d’abord tracing-subscriber pour le logging structuré, on crée le pool SQLx, on monte les routes et les middlewares (logger, panic recovery), puis on démarre le serveur HTTP. Le tout en une vingtaine de lignes lisibles.

// src/main.rs
mod error;
mod handlers;
mod model;

use actix_web::{App, HttpServer, middleware, web};
use sqlx::postgres::PgPoolOptions;
use std::env;
use tracing_actix_web::TracingLogger;
use tracing_subscriber::{EnvFilter, fmt::layer, prelude::*};

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    tracing_subscriber::registry()
        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
        .with(layer().json())
        .init();

    let dsn = env::var("DATABASE_URL").expect("DATABASE_URL manquant");
    let pool = PgPoolOptions::new()
        .max_connections(20)
        .connect(&dsn)
        .await
        .expect("connexion postgres");

    sqlx::migrate!("./migrations").run(&pool).await.expect("migrations");

    HttpServer::new(move || {
        App::new()
            .wrap(middleware::Compress::default())
            .wrap(TracingLogger::default())
            .app_data(web::Data::new(pool.clone()))
            .route("/healthz", web::get().to(|| async { "ok" }))
            .route("/produits", web::post().to(handlers::creer_produit))
            .route("/produits", web::get().to(handlers::lister_produits))
    })
    .bind(("0.0.0.0", 8080))?
    .run()
    .await
}

Plusieurs choix méritent commentaire. max_connections(20) dimensionne le pool selon une règle de pouce de 2 à 4 connexions par cœur applicatif ; ajustez selon votre charge. sqlx::migrate! embarque les migrations dans le binaire à la compilation, donc le déploiement n’a besoin que du binaire, pas du dossier migrations/. TracingLogger ajoute un span par requête avec son ID, ce qui permet de corréler les logs métier avec la requête HTTP. Lancez avec cargo run ; vous devez voir un log JSON indiquant que le serveur est en écoute, puis un curl http://localhost:8080/healthz renvoie ok.

Étape 8 — Tester l’API en intégration

Pour tester le service de bout en bout, on écrit un test d’intégration qui démarre le serveur Actix en mémoire, fait des appels HTTP via actix_web::test et vérifie les réponses. Le test partage le pool SQLx avec une base de test isolée. La discipline est de tronquer les tables au début de chaque test pour assurer l’indépendance.

// tests/integration.rs
use actix_web::{test, App, web};
use catalogue::{handlers, error};
use sqlx::postgres::PgPoolOptions;

#[actix_web::test]
async fn test_creer_produit() {
    let dsn = std::env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL manquant");
    let pool = PgPoolOptions::new().max_connections(2).connect(&dsn).await.unwrap();
    sqlx::query("TRUNCATE produits RESTART IDENTITY").execute(&pool).await.unwrap();

    let app = test::init_service(
        App::new()
            .app_data(web::Data::new(pool.clone()))
            .route("/produits", web::post().to(handlers::creer_produit))
    ).await;

    let req = test::TestRequest::post().uri("/produits").set_json(serde_json::json!({
        "nom": "souris", "prix_centime": 1500, "stock": 5
    })).to_request();

    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), 201);
}

Pour exécuter les tests, exposez d’abord votre code applicatif en bibliothèque en créant un src/lib.rs qui réexporte les modules : pub mod error; pub mod handlers; pub mod model;. Lancez ensuite cargo test --test integration avec une variable TEST_DATABASE_URL pointant sur une base dédiée aux tests. Une sortie test test_creer_produit ... ok signe que tout le pipeline fonctionne — sérialisation, validation, SQL, désérialisation. Pour des tests plus volumineux, cargo nextest run donne une expérience nettement plus fluide.

Étape 9 — Compiler en release et empaqueter pour la production

La dernière étape transforme votre code en livrable. Un Dockerfile multi-stage compile le binaire en mode release puis le copie dans une image distroless minimaliste. Le résultat tient typiquement entre 30 et 50 mégaoctets, exécute sous un utilisateur non privilégié, et embarque rien d’autre que ce qui est strictement nécessaire.

# Dockerfile
FROM rust:1.88-slim AS build
WORKDIR /src
RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/*
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release

FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=build /src/target/release/catalogue /catalogue
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/catalogue"]

L’image distroless/cc-debian12 est nécessaire si vous avez des dépendances qui linkent dynamiquement avec libc et OpenSSL. Pour un binaire totalement statique, vous pouvez cibler x86_64-unknown-linux-musl et utiliser distroless/static, plus petite encore. Lancez docker build -t catalogue:0.1.0 . ; la première compilation est lente (5 à 10 minutes selon votre machine) parce que toutes les dépendances sont compilées. Les builds suivants bénéficient du cache Docker. Un docker run --rm -e DATABASE_URL=... -p 8080:8080 catalogue:0.1.0 reproduit le comportement local.

Erreurs fréquentes

Erreur Cause Solution
error: failed to find PostgreSQL au build SQLx ne peut pas vérifier les requêtes sans connexion Exporter DATABASE_URL ou utiliser cargo sqlx prepare en mode offline
Le serveur ne répond pas et les logs sont silencieux tracing-subscriber non initialisé ou EnvFilter trop strict Vérifier l’init et exporter RUST_LOG=info
Compilation très longue après chaque modif codegen-units = 1 actif aussi en debug Mettre ce paramètre uniquement dans [profile.release]
Deadlock sur Mutex dans un handler async Utilisation de std::sync::Mutex au lieu de tokio::sync::Mutex Toujours utiliser le Mutex Tokio dans du code async
JSON renvoyé mais champ manquant Champ Rust pas annoté pour serde, ou nom snake_case mal géré Vérifier les #[serde(rename = "...")]

Tutoriels frères

Pour aller plus loin

FAQ

Pourquoi pas Axum à la place d’Actix-web ?
Le tutoriel équivalent en Axum tient en autant de lignes et l’expérience finale est similaire. Actix a été choisi ici pour sa maturité (la version 4.x est stable depuis 2022) et pour son système d’extracteurs très pédagogique. Le tutoriel Axum sur ce site couvre la voie alternative.

Faut-il préférer SQLx ou Diesel ?
SQLx pour 90 % des cas en 2026 : async natif, vérification compile-time, écriture SQL directe. Diesel reste excellent quand vous voulez un ORM avec un DSL typé et que la performance brute du build vous importe moins.

Comment déployer sans Docker ?
Compilez en release, copiez le binaire sur le serveur cible, créez un service systemd qui le lance. Pour la cross-compilation, l’outil cross simplifie la production de binaires Linux depuis n’importe quelle machine.

Comment ajouter de l’authentification ?
Pour du JWT, la crate jsonwebtoken couplée à un middleware Actix qui vérifie le token et injecte un UserId dans les extracteurs des handlers. Pour OAuth/OIDC, openidconnect est la référence dans l’écosystème.

Le mode offline de SQLx, c’est quoi ?
SQLx peut générer un fichier sqlx-data.json ou un dossier .sqlx/ qui contient la métadonnée des requêtes vérifiées localement. Versionné dans Git, ce fichier permet de compiler sans accès à la base, ce qui est essentiel en CI ou en build Docker. La commande est cargo sqlx prepare.

Comment instrumenter pour Prometheus ?
La crate actix-web-prom expose automatiquement les métriques HTTP standard sur un endpoint /metrics. Pour des métriques métier custom, utiliser la crate metrics avec son backend prometheus.

Sponsoriser ce contenu

Cet emplacement est à vous

Position premium en fin d'article — c'est l'instant où les lecteurs sont le plus engagés. Réservez cet espace pour votre marque, votre formation ou votre offre.

Recevoir nos tarifs
Publicité