Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 920 minIntermédiaire

Une API web en Rust avec axum, comparée à Express

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • créer un serveur web asynchrone avec axum et tokio ;
  • lire un paramètre de chemin, une chaîne de requête et un corps JSON ;
  • renvoyer le bon code de statut HTTP ;
  • comparer l'API à son équivalent Express et dire ce que le typage apporte.

3 exercices corrigés1 schéma

Ce chapitre est facultatif. axum est un framework web pour Rust, développé par l'équipe de tokio, le moteur d'exécution asynchrone le plus utilisé de l'écosystème. Il sert à écrire des API REST rapides et fortement typées : chaque route est une fonction ordinaire, et les paramètres (chemin, chaîne de requête, corps JSON) sont lus et validés grâce à leurs types. Ce chapitre bonus expose l'inventaire de Sorbier Logistique sous forme d'API, puis écrit la même API avec Express pour comparer.

Ce chapitre suppose que tu connais les notions d'API REST : méthodes HTTP, codes de statut, JSON. Elles sont expliquées dans le cours API REST, en particulier ses chapitres sur le protocole HTTP et les codes de statut.

Préparer le projet

bash
cargo new parc-api
cd parc-api
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
toml
[dependencies]
axum = "0.8.9"
serde = { version = "1.0.229", features = ["derive"] }
tokio = { version = "1.54.1", features = ["full"] }
CrateRôleÉquivalent Node.js
tokioMoteur asynchrone : boucle d'événements, réseau, minuteriesLa boucle d'événements intégrée à Node.js
axumRouteur, extracteurs, réponses HTTPExpress
serdeConversion entre structs Rust et JSON (sérialisation)JSON.parse et JSON.stringify, avec validation en plus

Le code complet de l'API

L'API garde les postes en mémoire pour rester courte : ils sont perdus à l'arrêt du serveur. Dans un vrai projet, on brancherait une base de données (la crate sqlx, par exemple), ou le module stockage du chapitre 8.

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

use axum::{
    Json, Router,
    extract::{Path, Query, State},
    http::StatusCode,
    routing::get,
};
use serde::{Deserialize, Serialize};

#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum TypePoste {
    Fixe,
    Portable,
    Serveur,
}

#[derive(Clone, Serialize, Deserialize)]
struct Poste {
    numero: String,
    type_poste: TypePoste,
    marque: String,
    prix_ht: u32,
    annee_achat: i32,
}

/// Les postes, partagés entre toutes les requêtes.
type Parc = Arc<Mutex<Vec<Poste>>>;

/// Les paramètres de `GET /postes?type=portable`.
#[derive(Deserialize)]
struct Filtre {
    #[serde(rename = "type")]
    type_poste: Option<TypePoste>,
}

async fn lister(State(parc): State<Parc>, Query(filtre): Query<Filtre>) -> Json<Vec<Poste>> {
    let postes = parc.lock().unwrap();
    let resultat = postes
        .iter()
        .filter(|p| filtre.type_poste.is_none_or(|t| p.type_poste == t))
        .cloned()
        .collect();
    Json(resultat)
}

async fn detail(
    State(parc): State<Parc>,
    Path(numero): Path<String>,
) -> Result<Json<Poste>, StatusCode> {
    let postes = parc.lock().unwrap();
    postes
        .iter()
        .find(|p| p.numero == numero)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

async fn creer(
    State(parc): State<Parc>,
    Json(poste): Json<Poste>,
) -> Result<(StatusCode, Json<Poste>), StatusCode> {
    let mut postes = parc.lock().unwrap();
    if postes.iter().any(|p| p.numero == poste.numero) {
        return Err(StatusCode::CONFLICT);
    }
    postes.push(poste.clone());
    Ok((StatusCode::CREATED, Json(poste)))
}

#[tokio::main]
async fn main() {
    let parc: Parc = Arc::new(Mutex::new(vec![
        Poste {
            numero: "PC-0001".to_string(),
            type_poste: TypePoste::Portable,
            marque: "Dell".to_string(),
            prix_ht: 89900,
            annee_achat: 2023,
        },
        Poste {
            numero: "PC-0002".to_string(),
            type_poste: TypePoste::Fixe,
            marque: "HP".to_string(),
            prix_ht: 64900,
            annee_achat: 2020,
        },
    ]));

    let app = Router::new()
        .route("/postes", get(lister).post(creer))
        .route("/postes/{numero}", get(detail))
        .with_state(parc);

    let ecouteur = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    println!("API du parc sur http://127.0.0.1:3000");
    axum::serve(ecouteur, app).await.unwrap();
}

Lance-le avec cargo run. La première compilation prend une à deux minutes, le temps de compiler axum, tokio et leurs dépendances ; les suivantes sont rapides.

Comprendre le code

clientPOST /postesRouterchoisit creerextracteursState(parc)Json(poste)okcreer(…)ta fonctionentrée invalideaxum répond seul415, 400 ou 422Result201 + JSONou 409
Le routeur choisit la fonction, les extracteurs valident l'entrée avant elle, sa valeur de retour devient la réponse

Les routes. Router::new().route("/postes", get(lister).post(creer)) associe le chemin /postes à deux fonctions, selon la méthode HTTP. {numero}, entre accolades, est un paramètre de chemin (axum 0.8 a remplacé l'ancienne syntaxe /:numero, qui provoque désormais une panique au démarrage).

Les fonctions de traitement (handlers) sont async : pendant qu'une requête attend le réseau ou la base, tokio traite les autres. Ce sont des fonctions ordinaires, testables comme les autres.

Les extracteurs. Chaque paramètre d'une fonction de traitement dit ce qu'elle attend de la requête, et son type suffit à axum pour l'extraire et le valider :

ExtracteurLitSi la lecture échoue
State(parc): State<Parc>L'état partagé, déclaré par with_stateErreur de compilation si l'état n'est pas fourni
Path(numero): Path<String>Le paramètre {numero} du chemin400 Bad Request
Query(filtre): Query<Filtre>La chaîne de requête ?type=portable400 Bad Request
Json(poste): Json<Poste>Le corps de la requête, en JSON415, 400 ou 422, selon l'erreur

L'écriture State(parc): State<Parc> est une déstructuration (chapitre 2) : elle sort la valeur de l'enveloppe State directement dans la variable parc.

Les réponses. Une fonction de traitement renvoie tout type qu'axum sait transformer en réponse HTTP : Json<T> (corps JSON, code 200), StatusCode seul, un tuple (StatusCode, Json<T>) pour choisir le code, ou un Result dont chaque branche est une réponse. detail renvoie Result<Json<Poste>, StatusCode> : le poste avec un code 200, ou un 404.

serde. #[derive(Serialize, Deserialize)] génère la conversion entre la struct et le JSON. #[serde(rename_all = "lowercase")] écrit les variantes en minuscules ("portable"), et #[serde(rename = "type")] fait correspondre le champ type_poste au paramètre type, qui est un mot réservé en Rust.

L'état partagé. Plusieurs requêtes peuvent arriver en même temps, sur plusieurs fils d'exécution. Le vecteur est donc protégé par un Mutex (un verrou : une seule requête à la fois peut le lire ou le modifier) et partagé par un Arc (un compteur de références utilisable entre fils d'exécution). C'est ici que les règles d'emprunt du chapitre 3 montrent leur force : sans Mutex, partager un Vec modifiable entre fils d'exécution ne compile pas. L'accès concurrent non protégé, source de bugs aléatoires en C++ ou en Java, est refusé à la compilation.

Tester l'API avec curl

bash
curl -i http://127.0.0.1:3000/postes/PC-0002
text
HTTP/1.1 200 OK
content-type: application/json
content-length: 89

{"numero":"PC-0002","type_poste":"fixe","marque":"HP","prix_ht":64900,"annee_achat":2020}

Voici ce que répond l'API à différentes requêtes, toutes exécutées sur le serveur ci-dessus :

RequêteCodeCorps de la réponse
GET /postes200Les deux postes, en JSON
GET /postes?type=fixe200Le seul PC-0002
GET /postes/PC-0099404 Not FoundVide
POST /postes avec un poste PC-0003 valide201 CreatedLe poste créé
La même requête une seconde fois409 ConflictVide
POST /postes avec "type_poste":"tablette"422 Unprocessable EntityFailed to deserialize the JSON body into the target type: type_poste: unknown variant `tablette`, expected one of `fixe`, `portable`, `serveur` at line 1 column 43
POST /postes sans en-tête Content-Type: application/json415 Unsupported Media TypeExpected request with `Content-Type: application/json`
GET /postes?type=tablette400 Bad RequestFailed to deserialize query string: type: unknown variant `tablette`, …
bash
curl -i -X POST http://127.0.0.1:3000/postes \
  -H 'Content-Type: application/json' \
  -d '{"numero":"PC-0003","type_poste":"portable","marque":"Lenovo","prix_ht":104900,"annee_achat":2024}'

Les trois dernières lignes du tableau n'ont demandé aucune ligne de code : la validation découle des types. Un prix négatif ("prix_ht":-5) est refusé aussi, puisqu'un u32 ne peut pas être négatif. Le cours API REST montre d'autres outils de test, comme Bruno ou Postman, dans son chapitre Tester une API.

La même API avec Express 5

Voici la même API en Node.js 24 avec Express 5, telle qu'on l'écrit dans le cours Node.js (API REST CRUD avec Express). Elle a été exécutée : elle renvoie les mêmes codes dans les cas courants (200, 201, 404, 409, 400 pour un JSON mal formé), mais pas pour les entrées que rien ne vérifie à sa place : 422 au lieu de 415 sans en-tête Content-Type, et une liste vide (200) au lieu d'une erreur 400 pour ?type=tablette.

javascript
import express from 'express';

const TYPES = ['fixe', 'portable', 'serveur'];
const postes = [
  { numero: 'PC-0001', type_poste: 'portable', marque: 'Dell', prix_ht: 89900, annee_achat: 2023 },
  { numero: 'PC-0002', type_poste: 'fixe', marque: 'HP', prix_ht: 64900, annee_achat: 2020 },
];

const app = express();
app.use(express.json());

app.get('/postes', (req, res) => {
  const { type } = req.query;
  res.json(type ? postes.filter((p) => p.type_poste === type) : postes);
});

app.get('/postes/:numero', (req, res) => {
  const poste = postes.find((p) => p.numero === req.params.numero);
  if (!poste) return res.sendStatus(404);
  res.json(poste);
});

app.post('/postes', (req, res) => {
  const poste = req.body;
  // Rien ne vérifie le corps à notre place : il faut tout tester à la main.
  if (!poste || typeof poste.numero !== 'string' || !TYPES.includes(poste.type_poste)
      || !Number.isInteger(poste.prix_ht) || poste.prix_ht < 0) {
    return res.sendStatus(422);
  }
  if (postes.some((p) => p.numero === poste.numero)) return res.sendStatus(409);
  postes.push(poste);
  res.status(201).json(poste);
});

app.listen(3001, () => console.log('API du parc sur http://127.0.0.1:3001'));
axum (Rust)Express (Node.js)
Lignes de codeUn peu plus : les types sont déclarésMoins pour démarrer
Validation de l'entréeAutomatique, déduite des typesÀ écrire, ou une bibliothèque de plus (zod, express-validator) ; ici ?type=tablette renvoie une liste vide au lieu d'une erreur
Champ oublié dans la validationImpossible : le compilateur connaît la structAccepté en silence (ici, marque et annee_achat ne sont pas vérifiés)
Erreurs détectéesÀ la compilationÀ l'exécution, souvent en production
ConcurrencePlusieurs fils d'exécution, accès partagé vérifié par le compilateurUn seul fil pour le JavaScript, pas de verrou à gérer
Temps de compilationDe quelques secondes à quelques minutesAucun
Mémoire, performancesTrès faibles besoins, très rapideBonnes, mais plus de mémoire
Écosystème, recrutementPlus restreintImmense

Exercices

Exercice 1 sur 3 La route DELETE Réussi À revoir

Ajoute une route DELETE /postes/{numero} qui supprime un poste et répond 204 No Content, ou 404 si le numéro est inconnu. Indice : routing::delete, et Vec::retain ou position + remove.

Voir la réponseMasquer la réponse de l'exercice 1
rust
async fn supprimer(State(parc): State<Parc>, Path(numero): Path<String>) -> StatusCode {
    let mut postes = parc.lock().unwrap();
    match postes.iter().position(|p| p.numero == numero) {
        Some(index) => {
            postes.remove(index);
            StatusCode::NO_CONTENT
        }
        None => StatusCode::NOT_FOUND,
    }
}

et dans main, sur la route existante :

rust
.route("/postes/{numero}", get(detail).delete(supprimer))

.delete(…) est ici une méthode du routeur de méthodes renvoyé par get(detail) : aucun use de plus n'est nécessaire. DELETE /postes/PC-0001 répond 204, puis 404 si on le répète. 204 indique un succès sans corps de réponse, le code habituel d'une suppression.

Tu avais trouvé ?

Exercice 2 sur 3 Une erreur en JSON Réussi À revoir

Le 409 actuel n'a pas de corps : le client ne sait pas pourquoi sa requête est refusée. Modifie creer pour qu'en cas de doublon, elle renvoie le code 409 et un JSON {"erreur": "le numéro PC-0003 est déjà pris"}. Indice : ajoute la crate serde_json et sa macro json!, et change le type d'erreur en (StatusCode, Json<serde_json::Value>).

Voir la réponseMasquer la réponse de l'exercice 2

Après cargo add serde_json :

rust
use serde_json::{Value, json};

async fn creer(
    State(parc): State<Parc>,
    Json(poste): Json<Poste>,
) -> Result<(StatusCode, Json<Poste>), (StatusCode, Json<Value>)> {
    let mut postes = parc.lock().unwrap();
    if postes.iter().any(|p| p.numero == poste.numero) {
        let message = format!("le numéro {} est déjà pris", poste.numero);
        return Err((StatusCode::CONFLICT, Json(json!({ "erreur": message }))));
    }
    postes.push(poste.clone());
    Ok((StatusCode::CREATED, Json(poste)))
}

Le second POST du même poste répond alors 409 Conflict avec {"erreur":"le numéro PC-0003 est déjà pris"}. Le cours API REST recommande d'aller plus loin avec un format d'erreur normalisé pour toute l'API (concevoir une API REST).

Tu avais trouvé ?

Exercice 3 sur 3 Comparer les validations Réussi À revoir

Avec l'API Express ci-dessus, que se passe-t-il pour un POST /postes avec le corps {"numero":"PC-0004","type_poste":"fixe","prix_ht":50000} ? Et avec l'API axum ? Qu'est-ce que cela montre ?

Voir la réponseMasquer la réponse de l'exercice 3

Express accepte la requête et répond 201 : la validation écrite à la main ne vérifie ni marque ni annee_achat. Un poste incomplet entre dans l'inventaire, et l'erreur apparaîtra plus tard, ailleurs (un affichage undefined, un calcul d'âge qui donne NaN).

axum répond 422 : Failed to deserialize the JSON body into the target type: missing field `marque` at line 1 column 56. La struct Poste déclare tous ses champs comme obligatoires, et serde le vérifie.

La validation d'Express dépend de la rigueur du développeur ; celle d'axum découle du type de données, qu'on ne peut pas oublier de mettre à jour. Un champ réellement facultatif s'écrirait Option<String> en Rust.

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Cours suivant : Méthode MeriseRevoir le sommaire du cours