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
cargo new parc-api
cd parc-api
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
[dependencies]
axum = "0.8.9"
serde = { version = "1.0.229", features = ["derive"] }
tokio = { version = "1.54.1", features = ["full"] }
| Crate | Rôle | Équivalent Node.js |
|---|---|---|
| tokio | Moteur asynchrone : boucle d'événements, réseau, minuteries | La boucle d'événements intégrée à Node.js |
| axum | Routeur, extracteurs, réponses HTTP | Express |
| serde | Conversion 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.
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
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 :
| Extracteur | Lit | Si la lecture échoue |
|---|---|---|
State(parc): State<Parc> | L'état partagé, déclaré par with_state | Erreur de compilation si l'état n'est pas fourni |
Path(numero): Path<String> | Le paramètre {numero} du chemin | 400 Bad Request |
Query(filtre): Query<Filtre> | La chaîne de requête ?type=portable | 400 Bad Request |
Json(poste): Json<Poste> | Le corps de la requête, en JSON | 415, 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
curl -i http://127.0.0.1:3000/postes/PC-0002
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ête | Code | Corps de la réponse |
|---|---|---|
GET /postes | 200 | Les deux postes, en JSON |
GET /postes?type=fixe | 200 | Le seul PC-0002 |
GET /postes/PC-0099 | 404 Not Found | Vide |
POST /postes avec un poste PC-0003 valide | 201 Created | Le poste créé |
| La même requête une seconde fois | 409 Conflict | Vide |
POST /postes avec "type_poste":"tablette" | 422 Unprocessable Entity | Failed 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/json | 415 Unsupported Media Type | Expected request with `Content-Type: application/json` |
GET /postes?type=tablette | 400 Bad Request | Failed to deserialize query string: type: unknown variant `tablette`, … |
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.
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 code | Un peu plus : les types sont déclarés | Moins pour démarrer |
| Validation de l'entrée | Automatique, 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 validation | Impossible : le compilateur connaît la struct | Accepté 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 |
| Concurrence | Plusieurs fils d'exécution, accès partagé vérifié par le compilateur | Un seul fil pour le JavaScript, pas de verrou à gérer |
| Temps de compilation | De quelques secondes à quelques minutes | Aucun |
| Mémoire, performances | Très faibles besoins, très rapide | Bonnes, mais plus de mémoire |
| Écosystème, recrutement | Plus restreint | Immense |
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
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 :
.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 :
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é ?