Tutoriels gratuits et ouverts à toutes et à tous · conçus au Lycée Chevrollier — AngersÀ propos

Série « Parcours Rust » · partie 8 / 8

Rust niveau 7 — Concurrence, async et API web

Exploiter tous les cœurs sans accès concurrent, écrire du code asynchrone et publier une API REST : niveau 7 du parcours Rust, avec exercices corrigés et QCM.

Développement✎ B. Jousseaume📅 17 octobre 2026⏱ 10 h📶 Avancé

1. Les fils d’exécution

Un processeur moderne a plusieurs cœurs. Un fil d’exécution (thread) permet de faire travailler plusieurs cœurs en parallèle.

use std::thread;
use std::time::Duration;

fn main() {
    let poignee = thread::spawn(|| {
        for i in 1..=3 {
            println!("  fil secondaire : étape {i}");
            thread::sleep(Duration::from_millis(50));
        }
        42                                  // valeur renvoyée par le fil
    });

    for i in 1..=3 {
        println!("fil principal : étape {i}");
        thread::sleep(Duration::from_millis(30));
    }
    let resultat = poignee.join().expect("le fil a paniqué");
    println!("le fil a renvoyé {resultat}");
}

L’ordre d’affichage change d’une exécution à l’autre : les fils avancent en même temps. join() attend la fin du fil et récupère sa valeur.

Donner des données à un fil : move

use std::thread;

fn main() {
    let notes = vec![12, 15, 9];
    let poignee = thread::spawn(move || {       // move : le fil prend possession de notes
        let somme: i32 = notes.iter().sum();
        somme
    });
    // println!("{:?}", notes);  ← refusé : notes appartient désormais au fil
    println!("somme : {}", poignee.join().unwrap());
}

Sans move, le compilateur refuse : le fil pourrait vivre plus longtemps que main et utiliser un vecteur déjà libéré. On retrouve exactement les règles du niveau 2, appliquées au parallélisme.

Découper un travail : thread::scope

use std::thread;

fn main() {
    let nombres: Vec<u64> = (1..=1_000_000).collect();
    let morceaux: Vec<&[u64]> = nombres.chunks(250_000).collect();

    let total: u64 = thread::scope(|s| {
        let poignees: Vec<_> = morceaux
            .iter()
            .map(|m| s.spawn(move || m.iter().sum::<u64>()))
            .collect();
        poignees.into_iter().map(|p| p.join().unwrap()).sum()
    });
    println!("total : {total}");     // 500000500000
}

Avec thread::scope, les fils sont garantis terminés à la sortie du bloc : ils peuvent donc emprunter des données du fil principal, sans move ni copie.

2. Partager des données : Arc et Mutex

Plusieurs fils qui modifient la même donnée, c’est la recette d’un accès concurrent (data race) : deux fils lisent 10, ajoutent 1, écrivent 11, et une incrémentation est perdue. En C ou en Java, ce bug compile et apparaît au hasard. En Rust, il ne compile pas.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let compteur = Arc::new(Mutex::new(0));
    let mut poignees = Vec::new();

    for _ in 0..8 {
        let c = Arc::clone(&compteur);           // une nouvelle poignée vers le même compteur
        poignees.push(thread::spawn(move || {
            for _ in 0..1000 {
                let mut valeur = c.lock().unwrap();  // on obtient l'accès exclusif
                *valeur += 1;
            }                                        // le verrou est relâché ici
        }));
    }
    for p in poignees {
        p.join().unwrap();
    }
    println!("compteur : {}", *compteur.lock().unwrap());   // toujours 8000
}
OutilRôle
Arc<T>Plusieurs propriétaires d’une même valeur, entre fils (compteur de références atomique)
Mutex<T>Un seul fil à la fois accède à la valeur ; lock() attend son tour
RwLock<T>Plusieurs lecteurs ou un écrivain : la règle d’emprunt, à l’exécution
types atomiquesAtomicU64… pour un simple compteur, sans verrou

3. Communiquer par canaux

« Ne communiquez pas en partageant la mémoire, partagez la mémoire en communiquant. » Un canal transporte des valeurs d’un fil à un autre.

use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (emetteur, recepteur) = mpsc::channel();

    for id in 1..=3 {
        let tx = emetteur.clone();
        thread::spawn(move || {
            for mesure in 0..3 {
                let texte = format!("capteur {id} : mesure {mesure}");
                tx.send(texte).unwrap();        // la valeur est déplacée dans le canal
                thread::sleep(Duration::from_millis(10 * id));
            }
        });
    }
    drop(emetteur);                             // sans ça, la boucle ne s'arrêterait jamais

    for message in recepteur {                  // s'arrête quand tous les émetteurs ont disparu
        println!("{message}");
    }
}

mpsc signifie multiple producers, single consumer : plusieurs émetteurs, un seul récepteur. Une valeur envoyée est déplacée : l’émetteur ne peut plus la modifier, aucun partage n’est possible.

4. La programmation asynchrone

Un serveur web passe l’essentiel de son temps à attendre : le réseau, le disque, une base de données. Créer un fil par connexion devient coûteux au-delà de quelques milliers. La programmation asynchrone permet à un petit nombre de fils de gérer des dizaines de milliers de tâches : pendant qu’une tâche attend, le fil en exécute une autre.

# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
use std::time::{Duration, Instant};
use tokio::time::sleep;

async fn interroger(serveur: &str, ms: u64) -> String {
    sleep(Duration::from_millis(ms)).await;      // attente non bloquante
    format!("{serveur} a répondu en {ms} ms")
}

#[tokio::main]
async fn main() {
    let debut = Instant::now();

    // en séquence : 300 + 200 + 100 ms
    println!("{}", interroger("srv-a", 300).await);
    println!("{}", interroger("srv-b", 200).await);
    println!("{}", interroger("srv-c", 100).await);
    println!("séquentiel : {:?}", debut.elapsed());

    // en concurrence : environ 300 ms au total
    let debut = Instant::now();
    let (a, b, c) = tokio::join!(
        interroger("srv-a", 300),
        interroger("srv-b", 200),
        interroger("srv-c", 100),
    );
    println!("{a}\n{b}\n{c}");
    println!("concurrent : {:?}", debut.elapsed());

    // tâches indépendantes
    let tache = tokio::spawn(async { interroger("srv-d", 50).await });
    println!("{}", tache.await.unwrap());
}
  • Une fonction async ne s’exécute pas quand on l’appelle : elle renvoie une future, un calcul en attente.
  • .await exécute la future et cède la main pendant les attentes.
  • Rust ne fournit pas l’exécuteur des futures : c’est le rôle de Tokio, la bibliothèque la plus utilisée, activée par #[tokio::main].
  • Dans une fonction async, on n’utilise jamais std::thread::sleep ni une lecture de fichier bloquante : elles immobilisent tout le fil et toutes les tâches qui s’y trouvent. On utilise leurs équivalents tokio::.
Fils (threads)Tâches asynchrones
Adapté àCalculs lourds (CPU)Attentes nombreuses (réseau, entrées-sorties)
Coût d’une unitéQuelques Mo de mémoireQuelques Ko
Nombre raisonnableQuelques dizainesDes dizaines de milliers

5. Une API REST avec Axum

Une API REST expose des ressources par HTTP : GET pour lire, POST pour créer, PUT pour remplacer, DELETE pour supprimer, avec des données au format JSON. Axum, construit sur Tokio, est l’un des cadriciels web les plus utilisés en Rust.

# Cargo.toml
[dependencies]
axum = "0.8"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::get,
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};

#[derive(Clone, Serialize)]
struct Poste {
    id: u32,
    nom: String,
    salle: u16,
}

#[derive(Deserialize)]
struct NouveauPoste {
    nom: String,
    salle: u16,
}

type Etat = Arc<RwLock<Vec<Poste>>>;

async fn lister(State(etat): State<Etat>) -> Json<Vec<Poste>> {
    Json(etat.read().unwrap().clone())
}

async fn lire(State(etat): State<Etat>, Path(id): Path<u32>) -> Result<Json<Poste>, StatusCode> {
    etat.read()
        .unwrap()
        .iter()
        .find(|p| p.id == id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

async fn creer(
    State(etat): State<Etat>,
    Json(entree): Json<NouveauPoste>,
) -> Result<(StatusCode, Json<Poste>), (StatusCode, String)> {
    let nom = entree.nom.trim();
    if nom.is_empty() || nom.len() > 32 {
        return Err((StatusCode::UNPROCESSABLE_ENTITY, "nom : 1 à 32 caractères".into()));
    }
    let mut postes = etat.write().unwrap();
    let id = postes.iter().map(|p| p.id).max().unwrap_or(0) + 1;
    let poste = Poste { id, nom: nom.to_string(), salle: entree.salle };
    postes.push(poste.clone());
    Ok((StatusCode::CREATED, Json(poste)))
}

#[tokio::main]
async fn main() {
    let etat: Etat = Arc::new(RwLock::new(vec![
        Poste { id: 1, nom: "PC-207-01".into(), salle: 207 },
    ]));

    let app = Router::new()
        .route("/postes", get(lister).post(creer))
        .route("/postes/{id}", get(lire))
        .with_state(etat);

    let ecoute = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    println!("API sur http://127.0.0.1:3000");
    axum::serve(ecoute, app).await.unwrap();
}
$ curl http://127.0.0.1:3000/postes
[{"id":1,"nom":"PC-207-01","salle":207}]

$ curl -i -X POST http://127.0.0.1:3000/postes \
       -H "Content-Type: application/json" -d '{"nom":"PC-112-04","salle":112}'
HTTP/1.1 201 Created
{"id":2,"nom":"PC-112-04","salle":112}

$ curl -i http://127.0.0.1:3000/postes/99
HTTP/1.1 404 Not Found

$ curl -i -X POST http://127.0.0.1:3000/postes \
       -H "Content-Type: application/json" -d '{"nom":"X","salle":70000}'
HTTP/1.1 422 Unprocessable Entity
  • Chaque route associe un chemin et une méthode HTTP à une fonction async appelée gestionnaire.
  • Les extracteurs (State, Path, Json) récupèrent et désérialisent les données de la requête : un JSON mal formé ou une salle supérieure à 65 535 sont refusés automatiquement, avant même d’atteindre ton code.
  • Un gestionnaire renvoie tout ce qui peut devenir une réponse : Json(…), un code d’état, un tuple des deux, ou un Result de ces éléments.
  • L’état partagé est un Arc<RwLock<…>> : exactement les outils de la partie 2.

6. unsafe : les limites de la garantie

Certaines opérations ne peuvent pas être vérifiées par le compilateur : manipuler une adresse mémoire brute, appeler une fonction écrite en C, dialoguer avec le matériel. Rust les autorise dans un bloc unsafe, où le programmeur prend la responsabilité de la sûreté.

fn main() {
    let x = 42;
    let adresse = &x as *const i32;       // pointeur brut : autorisé
    let valeur = unsafe { *adresse };     // le déréférencer : seulement dans unsafe
    println!("{valeur}");
}
  • unsafe ne désactive pas le vérificateur : il autorise seulement quelques opérations supplémentaires.
  • La bibliothèque standard et certaines crates en contiennent, encapsulées derrière une interface sûre et auditée.
  • Dans son propre code d’application, on n’en a presque jamais besoin. On peut l’interdire totalement en ajoutant en tête de main.rs ou lib.rs : #![forbid(unsafe_code)].
  • cargo geiger compte le code unsafe présent dans les dépendances : un indicateur utile pour choisir une crate.

7. Projet final : l’API d’inventaire du parc

8. Et après ?

DirectionPour faire quoiÀ explorer
Web completBase de données, authentification, gabarits HTMLsqlx, tower-http, askama, Leptos
Outils systèmeRemplacer des scripts par des outils rapides et sûrsclap, walkdir, sysinfo, ssh2
Réseau et sécuritéAnalyseurs de trames, scanners, outils de supervisionpnet, rustls, ring
EmbarquéMicrocontrôleurs sans système d’exploitationEmbassy, embedded-hal
WebAssemblyDu Rust exécuté dans le navigateurwasm-bindgen, wasm-pack
Aller plus loin dans le langageTout comprendre en profondeurLe livre officiel « The Rust Programming Language », Rustlings, Exercism

9. Bilan du parcours

Exercices corrigés

Ce niveau touche à des bibliothèques qui évoluent vite. Les corrigés ont été écrits pour Tokio 1 et Axum 0.8 : si une écriture a changé, la documentation de chaque crate sur docs.rs et les exemples de son dépôt sont la référence.

Exercice 7.1 — Parallélisme

Voir le corrigé
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};

fn est_premier(n: u64) -> bool {
    if n < 2 { return false; }
    let mut d = 2;
    while d * d <= n {
        if n % d == 0 { return false; }
        d += 1;
    }
    true
}

fn pseudo_aleatoire(graine: u64) -> u64 {
    let t = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().subsec_nanos() as u64;
    (t ^ graine.wrapping_mul(2654435761)) % 40 + 10
}

fn main() {
    // 1. Premiers : un fil, puis quatre
    const MAX: u64 = 200_000;
    let debut = Instant::now();
    let seul = (0..MAX).filter(|&n| est_premier(n)).count();
    println!("1 fil  : {seul} premiers en {:?}", debut.elapsed());

    let debut = Instant::now();
    let quart = MAX / 4;
    let total: usize = thread::scope(|s| {
        let poignees: Vec<_> = (0..4)
            .map(|i| s.spawn(move || (i * quart..(i + 1) * quart).filter(|&n| est_premier(n)).count()))
            .collect();
        poignees.into_iter().map(|p| p.join().unwrap()).sum()
    });
    println!("4 fils : {total} premiers en {:?}", debut.elapsed());

    // 2. Téléchargements simulés
    let (tx, rx) = mpsc::channel();
    for fil in 1..=4u64 {
        let tx = tx.clone();
        thread::spawn(move || {
            for fichier in 1..=5u64 {
                let ms = pseudo_aleatoire(fil * 10 + fichier);
                thread::sleep(Duration::from_millis(ms));
                tx.send((fil, fichier, ms)).unwrap();
            }
        });
    }
    drop(tx);
    let mut cumul = 0;
    for (fil, fichier, ms) in rx {
        println!("fil {fil} : fichier {fichier} en {ms} ms");
        cumul += ms;
    }
    println!("temps cumulé : {cumul} ms");

    // 3. Compteur atomique
    let debut = Instant::now();
    let compteur = Arc::new(AtomicU64::new(0));
    let poignees: Vec<_> = (0..8)
        .map(|_| {
            let c = Arc::clone(&compteur);
            thread::spawn(move || {
                for _ in 0..1000 {
                    c.fetch_add(1, Ordering::Relaxed);
                }
            })
        })
        .collect();
    for p in poignees { p.join().unwrap(); }
    println!("atomique : {} en {:?}", compteur.load(Ordering::Relaxed), debut.elapsed());
}

Explication

Le découpage en quatre intervalles donne des charges inégales (les grands nombres sont plus longs à tester), mais le gain reste net sur une machine à plusieurs cœurs. Le compteur atomique est plus rapide que le Mutex car il n’y a ni attente ni verrou : le processeur garantit que l’addition est indivisible.

Exercice 7.2 — Vérifier un parc de serveurs

Voir le corrigé
// Cargo.toml : tokio = { version = "1", features = ["full"] }
use std::time::{Duration, Instant};
use tokio::net::TcpStream;
use tokio::time::timeout;

async fn verifier(hote: &str, port: u16) -> (String, bool, u128) {
    let debut = Instant::now();
    let cible = format!("{hote}:{port}");
    let ok = matches!(
        timeout(Duration::from_secs(2), TcpStream::connect(&cible)).await,
        Ok(Ok(_))
    );
    (cible, ok, debut.elapsed().as_millis())
}

#[tokio::main]
async fn main() {
    let cibles = [
        ("192.168.56.10", 22), ("192.168.56.10", 80), ("192.168.56.11", 3389),
        ("192.168.56.12", 445), ("192.168.56.20", 53), ("127.0.0.1", 3000),
    ];
    let debut = Instant::now();
    let taches: Vec<_> = cibles
        .iter()
        .map(|&(h, p)| tokio::spawn(async move { verifier(h, p).await }))
        .collect();

    let mut resultats = Vec::new();
    for t in taches {
        resultats.push(t.await.expect("tâche interrompue"));
    }
    resultats.sort_by_key(|r| r.2);
    for (cible, ok, ms) in &resultats {
        println!("{cible:<22} {:<9} {ms:>5} ms", if *ok { "ouvert" } else { "fermé" });
    }
    println!("total : {:?}", debut.elapsed());
}

Explication

timeout renvoie une erreur si la future n’a pas abouti dans le délai ; connect renvoie elle-même un Result. D’où le motif Ok(Ok(_)) : « pas de dépassement de délai, et connexion réussie ». Toutes les tâches démarrent immédiatement : le temps total est celui de la plus lente, borné à 2 secondes.

Pour aller plus loin

Ce programme est un petit scanner de ports. Utilisé sur un réseau qui n’est pas le tien, il constitue une tentative d’intrusion au regard de la loi.

Projet final : l’API d’inventaire du parc

Voir le corrigé

Le corrigé complet dépasse la place d’un livret ; voici le squelette et les éléments décisifs. Les dépendances : axum = "0.8", tokio (full), serde (derive), serde_json, subtle, et pour les tests tower avec la fonctionnalité util.

#![forbid(unsafe_code)]
use axum::{
    extract::{Path, Query, Request, State},
    http::{header, StatusCode},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::get,
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use std::sync::Arc;
use tokio::sync::RwLock;

// ---------- types validés ----------
#[derive(Clone, Serialize, Deserialize, PartialEq)]
#[serde(try_from = "String", into = "String")]
struct NomPoste(String);

impl TryFrom<String> for NomPoste {
    type Error = String;
    fn try_from(s: String) -> Result<Self, String> {
        let ok = (3..=32).contains(&s.len())
            && s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-');
        if ok { Ok(NomPoste(s)) } else { Err("nom : 3 à 32 caractères A-Z, 0-9 ou -".into()) }
    }
}
impl From<NomPoste> for String {
    fn from(n: NomPoste) -> String { n.0 }
}

#[derive(Clone, Serialize, Deserialize)]
struct Poste { id: u32, nom: NomPoste, salle: u16, ram_go: u32, ip: IpAddr }

#[derive(Deserialize)]
struct Saisie { nom: NomPoste, salle: u16, ram_go: u32, ip: IpAddr }

#[derive(Deserialize)]
struct Filtre { salle: Option<u16>, ram_max: Option<u32> }

// ---------- erreurs ----------
enum ErreurApi { Introuvable, Conflit, NonAutorise, Interne }

impl IntoResponse for ErreurApi {
    fn into_response(self) -> Response {
        let (code, msg) = match self {
            ErreurApi::Introuvable => (StatusCode::NOT_FOUND, "poste introuvable"),
            ErreurApi::Conflit => (StatusCode::CONFLICT, "ce nom est déjà utilisé"),
            ErreurApi::NonAutorise => (StatusCode::UNAUTHORIZED, "authentification requise"),
            ErreurApi::Interne => (StatusCode::INTERNAL_SERVER_ERROR, "erreur interne"),
        };
        (code, Json(serde_json::json!({ "erreur": msg }))).into_response()
    }
}

// ---------- état ----------
struct Etat { postes: RwLock<Vec<Poste>>, jeton: String, fichier: String }
type Partage = Arc<Etat>;

async fn sauvegarder(e: &Etat, postes: &[Poste]) -> Result<(), ErreurApi> {
    let json = serde_json::to_string_pretty(postes).map_err(|_| ErreurApi::Interne)?;
    tokio::fs::write(&e.fichier, json).await.map_err(|err| {
        eprintln!("écriture de {} impossible : {err}", e.fichier);   // détail au journal
        ErreurApi::Interne                                            // réponse sobre
    })
}

// ---------- authentification ----------
async fn exiger_jeton(State(e): State<Partage>, req: Request, next: Next) -> Result<Response, ErreurApi> {
    use subtle::ConstantTimeEq;
    let fourni = req.headers().get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|v| v.strip_prefix("Bearer "))
        .unwrap_or("");
    if fourni.as_bytes().ct_eq(e.jeton.as_bytes()).into() {
        Ok(next.run(req).await)
    } else {
        Err(ErreurApi::NonAutorise)
    }
}

// ---------- gestionnaires ----------
async fn lister(State(e): State<Partage>, Query(f): Query<Filtre>) -> Json<Vec<Poste>> {
    let postes = e.postes.read().await;
    Json(postes.iter()
        .filter(|p| f.salle.is_none_or(|s| p.salle == s))
        .filter(|p| f.ram_max.is_none_or(|r| p.ram_go <= r))
        .cloned().collect())
}

async fn lire(State(e): State<Partage>, Path(id): Path<u32>) -> Result<Json<Poste>, ErreurApi> {
    e.postes.read().await.iter().find(|p| p.id == id).cloned().map(Json).ok_or(ErreurApi::Introuvable)
}

async fn creer(State(e): State<Partage>, Json(s): Json<Saisie>) -> Result<(StatusCode, Json<Poste>), ErreurApi> {
    let mut postes = e.postes.write().await;               // contrôle ET ajout sous le même verrou
    if postes.iter().any(|p| p.nom == s.nom) {
        return Err(ErreurApi::Conflit);
    }
    let id = postes.iter().map(|p| p.id).max().unwrap_or(0) + 1;
    let p = Poste { id, nom: s.nom, salle: s.salle, ram_go: s.ram_go, ip: s.ip };
    postes.push(p.clone());
    sauvegarder(&e, &postes).await?;
    Ok((StatusCode::CREATED, Json(p)))
}

async fn supprimer(State(e): State<Partage>, Path(id): Path<u32>) -> Result<StatusCode, ErreurApi> {
    let mut postes = e.postes.write().await;
    let avant = postes.len();
    postes.retain(|p| p.id != id);
    if postes.len() == avant { return Err(ErreurApi::Introuvable); }
    sauvegarder(&e, &postes).await?;
    Ok(StatusCode::NO_CONTENT)
}

fn application(etat: Partage) -> Router {
    let lecture = Router::new()
        .route("/postes", get(lister))
        .route("/postes/{id}", get(lire));
    let ecriture = Router::new()
        .route("/postes", axum::routing::post(creer))
        .route("/postes/{id}", axum::routing::delete(supprimer))
        .route_layer(middleware::from_fn_with_state(etat.clone(), exiger_jeton));
    lecture.merge(ecriture).with_state(etat)
}

#[tokio::main]
async fn main() {
    let jeton = std::env::var("API_JETON").expect("la variable API_JETON doit être définie");
    assert!(jeton.len() >= 32, "API_JETON doit faire au moins 32 caractères");
    let fichier = String::from("inventaire.json");
    let postes = match tokio::fs::read_to_string(&fichier).await {
        Ok(t) => serde_json::from_str(&t).expect("inventaire.json invalide"),
        Err(_) => Vec::new(),
    };
    let etat = Arc::new(Etat { postes: RwLock::new(postes), jeton, fichier });
    let ecoute = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(ecoute, application(etat)).await.unwrap();
}

Explication

#[serde(try_from = "String")] fait passer chaque nom reçu par TryFrom : un nom invalide est refusé pendant la désérialisation, avec un code 422, sans une ligne de validation dans les gestionnaires. Les routes d’écriture sont regroupées sous une couche (route_layer) qui exige le jeton : impossible d’oublier la vérification sur une nouvelle route de ce groupe. La comparaison du jeton se fait en temps constant (subtle) pour ne pas révéler, par le temps de réponse, combien de caractères sont justes. Le contrôle d’unicité et l’ajout se font sous le même verrou d’écriture : deux requêtes simultanées ne peuvent pas créer deux fois le même nom. Le verrou est ici celui de Tokio (tokio::sync::RwLock), qui peut être conservé pendant un .await, contrairement à celui de la bibliothèque standard.

is_none_or demande Rust 1.82 ou plus récent ; sur une version plus ancienne, écris f.salle.map_or(true, |s| p.salle == s).

Pour le test des routes, tower::ServiceExt::oneshot envoie une requête à l’application sans ouvrir de port :

#[cfg(test)]
mod tests {
    use super::*;
    use axum::body::Body;
    use axum::http::Request;
    use tower::ServiceExt;

    fn etat_test() -> Partage {
        Arc::new(Etat { postes: RwLock::new(Vec::new()), jeton: "x".repeat(32), fichier: "test.json".into() })
    }

    #[tokio::test]
    async fn creation_sans_jeton_refusee() {
        let req = Request::post("/postes")
            .header("content-type", "application/json")
            .body(Body::from(r#"{"nom":"PC-01","salle":207,"ram_go":8,"ip":"10.0.0.1"}"#))
            .unwrap();
        let rep = application(etat_test()).oneshot(req).await.unwrap();
        assert_eq!(rep.status(), StatusCode::UNAUTHORIZED);
    }

    #[tokio::test]
    async fn poste_inconnu() {
        let req = Request::get("/postes/42").body(Body::empty()).unwrap();
        let rep = application(etat_test()).oneshot(req).await.unwrap();
        assert_eq!(rep.status(), StatusCode::NOT_FOUND);
    }

    #[test]
    fn nom_invalide_refuse() {
        assert!(NomPoste::try_from(String::from("pc 01")).is_err());
        assert!(NomPoste::try_from(String::from("PC-207-01")).is_ok());
    }
}

Pour aller plus loin

Les refus de désérialisation (422) renvoient pour l’instant le message texte par défaut d’Axum. Pour des erreurs toutes au format JSON, on intercepte ce rejet : la documentation d’Axum le décrit dans sa partie consacrée aux extracteurs.

Les routes PUT et POST /import suivent les mêmes modèles. Pour l’import, ajoute la bibliothèque du niveau 6 en dépendance locale : analyse-journal = { path = "../analyse-journal" }, et appelle analyse::analyser(corps.lines(), 5). C’est l’intérêt d’avoir séparé bibliothèque et exécutable.

🧠 Testez vos connaissances

5 questions. Répondez puis cliquez sur « Corriger ». Rien n’est enregistré : c’est de l’auto-évaluation.

1. Pourquoi thread::spawn exige-t-il souvent move ?

💬 Le fil prend possession des données : elles ne peuvent pas être libérées pendant qu’il s’en sert.

2. Quel outil permet à plusieurs fils de modifier la même valeur en sécurité ?

💬 Arc partage la possession entre fils, Mutex garantit un seul accès à la fois. Rc n’est pas utilisable entre fils : le compilateur le refuse.

3. Que se passe-t-il quand on appelle une fonction async sans .await ?

💬 Une future ne fait rien tant qu’elle n’est pas attendue ou confiée à l’exécuteur (tokio::spawn).

4. Où doit se trouver le jeton d’authentification d’une API ?

💬 Un secret dans le code finit dans Git, et dans l’historique pour toujours. Dans une URL, il finit dans les journaux des serveurs.

5. Rust garantit-il qu’un programme est sûr ?

💬 C’est une protection majeure contre une famille de failles, pas un substitut aux bonnes pratiques.

© 2026 Bruno Jousseaume. Ce cours est publié sous licence CC BY-NC-SA 4.0 : vous pouvez le partager et l’adapter en citant l’auteur et un lien vers cette page, sans usage commercial, et en partageant vos adaptations sous la même licence.

Pour aller plus loin

Python niveau 1 — Premiers pas

Afficher, calculer, mémoriser dans des variables et dialoguer avec l’utilisateur : niveau 1 du parcours Python, avec exercices corrigés et QCM.

Débutant⏱ 4 à 5 h · 17 octobre 2026