Écrire une REST API avec Axum en Rust
1. Aperçu
Axum est un framework web de l’équipe Tokio. Il est construit sur hyper et tower, utilisant le système de types de Rust pour vérifier les handlers au moment de la compilation.
Dans cet article, vous allez écrire une REST API pour gérer les commandes (Order) avec toutes les opérations CRUD. L’article couvre également comment partager l’état entre les handlers, retourner des erreurs structurées en JSON et écrire des tests pour le router sans avoir besoin d’ouvrir un port réseau.
Le code de cet article utilise Axum 0.8 et Rust edition 2021 ou supérieur.
2. Mise en place du projet
Créez un nouveau projet :
cargo new order-api
cd order-api
Ajoutez les dépendances au fichier Cargo.toml :
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
Le rôle de chaque crate :
axum: router, extractors et types de response.tokio: async runtime. Axum doit obligatoirement s’exécuter sur Tokio.serdeetserde_json: conversion des structs en JSON et inversement.towerethttp-body-util: utilisés seulement pour les tests. Ils permettent d’envoyer des requêtes directement au router et de lire le corps de la réponse.
3. Première application Axum
Commencez par un endpoint de health check du service :
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
let app = Router::new().route("/health", get(|| async { "OK" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
println!("Listening on {}", listener.local_addr().unwrap());
axum::serve(listener, app).await.unwrap();
}
Ce code fait trois choses :
#[tokio::main]transformemainen fonction async s’exécutant sur le runtime Tokio.Router::new().route(...)lie le chemin/healthà la méthodeGET. Le handler est une closure async retournant&'static str. Axum convertit automatiquement cette chaîne en réponse200 OKavecContent-Type: text/plain.axum::servereçoit unTcpListeneret commence à servir les requêtes.
Testez :
cargo run
curl -i http://localhost:3000/health
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 2
OK
4. Modèle et état partagé
4.1. Définir le modèle
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Order {
pub id: u64,
pub customer: String,
pub amount: u64,
}
#[derive(Debug, Deserialize)]
pub struct CreateOrder {
pub customer: String,
pub amount: u64,
}
Séparer CreateOrder de Order est intentionnel. Le client n’est pas censé envoyer lui-même l’id, car c’est le serveur qui le génère. Si vous utilisiez une seule struct, vous devriez gérer le cas où le client envoie un id arbitraire.
4.2. État partagé
Dans cet article, les données sont stockées en mémoire. En pratique, c’est généralement un pool de connexions comme sqlx::PgPool.
use std::collections::HashMap;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, RwLock};
#[derive(Clone, Default)]
pub struct AppState {
orders: Arc<RwLock<HashMap<u64, Order>>>,
next_id: Arc<AtomicU64>,
}
Axum clone l’état pour chaque requête. Par conséquent, AppState doit implémenter Clone, et le clonage doit être bon marché. Envelopper les données dans Arc répond aux deux exigences : cloner n’augmente que le compte de références, tandis que tous les handlers pointent vers la même zone de données.
RwLock permet à plusieurs threads de lire en parallèle, mais un seul thread peut écrire à la fois. AtomicU64 est utilisé pour générer les id sans avoir besoin de verrouiller.
Note : Nous utilisons
std::sync::RwLockici car aucun.awaitne se trouve entre le moment où on acquiert et le moment où on libère le verrou. Si vous devez maintenir le verrou à travers un.await, passez àtokio::sync::RwLock. Sinon, le future ne sera plusSend, et le handler ne compilera pas.
5. Gestion des erreurs avec IntoResponse
Avant d’écrire les handlers, définissez un type d’erreur commun. Tout type implémentant IntoResponse peut être retourné par un handler, même s’il est enveloppé dans un Result.
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
#[derive(Debug)]
pub enum AppError {
NotFound(u64),
Validation(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(id) => (StatusCode::NOT_FOUND, format!("Order {id} not found")),
AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
Le tuple (StatusCode, Json<_>) implémente également IntoResponse. Cela vous permet de définir le code de statut et le corps en même temps.
Désormais, le handler n’a qu’à retourner Err(AppError::NotFound(id)). Le client recevra une réponse comme suit :
HTTP/1.1 404 Not Found
content-type: application/json
{"error":"Order 42 not found"}
6. Écrire les handlers CRUD
6.1. Créer une commande
use axum::extract::State;
use std::sync::atomic::Ordering;
async fn create_order(
State(state): State<AppState>,
Json(input): Json<CreateOrder>,
) -> Result<(StatusCode, Json<Order>), AppError> {
if input.customer.trim().is_empty() {
return Err(AppError::Validation("customer must not be empty".into()));
}
let id = state.next_id.fetch_add(1, Ordering::Relaxed) + 1;
let order = Order { id, customer: input.customer, amount: input.amount };
state.orders.write().unwrap().insert(id, order.clone());
Ok((StatusCode::CREATED, Json(order)))
}
Chaque paramètre du handler est un extractor :
State(state)récupère l’AppStateattaché au router.Json(input)lit le corps et le désérialise enCreateOrder. Si le corps est mal formé, Axum retourne une erreur avant même que votre fonction n’ait une chance de s’exécuter.
fetch_add retourne l’ancienne valeur, vous devez donc ajouter 1 pour que l’id commence à 1. Le handler retourne 201 Created avec la commande nouvellement créée.
Note : Les extractors qui lisent le corps, comme
Json,StringouBytes, doivent être le dernier paramètre. Le corps ne peut être lu qu’une fois. PlacerJsonavantStatecausera une erreur de compilation de type « the traitHandler<_, _>is not implemented », et ce message est assez déroutant si vous ne connaissez pas la cause.
6.2. Récupérer une commande par id
use axum::extract::Path;
async fn get_order(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Result<Json<Order>, AppError> {
state
.orders
.read()
.unwrap()
.get(&id)
.cloned()
.map(Json)
.ok_or(AppError::NotFound(id))
}
Path<u64> parse le segment {id} de l’URL en u64. Si le client appelle /orders/abc, Axum retourne 400 Bad Request sans code supplémentaire. Si la commande n’est pas trouvée, ok_or transforme None en erreur NotFound.
6.3. Lister avec pagination
use axum::extract::Query;
#[derive(Debug, Deserialize)]
pub struct Pagination {
#[serde(default)]
offset: usize,
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
20
}
async fn list_orders(
State(state): State<AppState>,
Query(page): Query<Pagination>,
) -> Json<Vec<Order>> {
let orders = state.orders.read().unwrap();
let mut result: Vec<Order> = orders.values().cloned().collect();
result.sort_by_key(|o| o.id);
let result = result.into_iter().skip(page.offset).take(page.limit).collect();
Json(result)
}
Query<Pagination> lit la chaîne de requête comme ?offset=10&limit=5. Grâce à #[serde(default)], le client peut omettre les deux paramètres et recevoir toujours un résultat valide.
Le code doit trier par id car HashMap n’assure pas l’ordre. Si vous omettez cette étape, les résultats de la pagination changeront entre les appels.
6.4. Supprimer une commande
async fn delete_order(
State(state): State<AppState>,
Path(id): Path<u64>,
) -> Result<StatusCode, AppError> {
match state.orders.write().unwrap().remove(&id) {
Some(_) => Ok(StatusCode::NO_CONTENT),
None => Err(AppError::NotFound(id)),
}
}
StatusCode lui-même implémente également IntoResponse. Retourner 204 No Content est la façon habituelle de signifier une suppression réussie.
7. Assembler le router
pub fn app(state: AppState) -> Router {
Router::new()
.route("/health", get(|| async { "OK" }))
.route("/orders", get(list_orders).post(create_order))
.route("/orders/{id}", get(get_order).delete(delete_order))
.with_state(state)
}
#[tokio::main]
async fn main() {
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app(AppState::default())).await.unwrap();
}
Ce router est organisé comme suit :
- Un chemin peut être associé à plusieurs méthodes en les chaînant :
get(...).post(...). with_stateattache l’état au router. Après cette étape, le router a le typeRouter<()>et est prêt à servir les requêtes.- La fonction
appest séparée pour que les tests puissent construire le router sans avoir besoin d’ouvrir un port.
Essayez de créer et de lire une commande :
curl -i -X POST http://localhost:3000/orders \
-H "Content-Type: application/json" \
-d '{"customer":"Nguyen Van An","amount":150000}'
curl http://localhost:3000/orders/1
HTTP/1.1 201 Created
content-type: application/json
{"id":1,"customer":"Nguyen Van An","amount":150000}
{"id":1,"customer":"Nguyen Van An","amount":150000}
Note : À partir d’Axum 0.8, les paramètres de chemin utilisent la syntaxe
{id}au lieu de/:iddes versions précédentes. Si vous copiez du code ancien utilisant/:id, l’application panique lors de la construction du router. La syntaxe wildcard change également de*restà{*rest}.
8. Tester le router
Router implémente le trait Service de tower. Par conséquent, vous pouvez envoyer des requêtes directement au router avec oneshot, sans avoir besoin de lancer un serveur réel. Ajoutez un module test à la fin de main.rs :
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use http_body_util::BodyExt;
use tower::ServiceExt;
fn post_order(json: &str) -> Request<Body> {
Request::builder()
.method("POST")
.uri("/orders")
.header("content-type", "application/json")
.body(Body::from(json.to_owned()))
.unwrap()
}
#[tokio::test]
async fn create_then_get_order() {
let app = app(AppState::default());
let response = app
.clone()
.oneshot(post_order(r#"{"customer":"An","amount":150000}"#))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CREATED);
let response = app
.oneshot(Request::get("/orders/1").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let order: Order = serde_json::from_slice(&bytes).unwrap();
assert_eq!(order, Order { id: 1, customer: "An".into(), amount: 150000 });
}
#[tokio::test]
async fn empty_customer_is_rejected() {
let response = app(AppState::default())
.oneshot(post_order(r#"{"customer":" ","amount":1}"#))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[tokio::test]
async fn unknown_order_returns_404() {
let response = app(AppState::default())
.oneshot(Request::get("/orders/42").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
Trois points à noter dans ce test :
oneshotconsomme le router. Pour envoyer plusieurs requêtes avec le même état, vous devezclone()le router avant chaque appel. L’état est toujours partagé car il se trouve dansArc.BodyExt::collectaccumule le corps entier enBytes, puisserde_jsonle réanalyse enOrder.- Chaque test crée un nouvel
AppState, les tests sont donc indépendants les uns des autres et peuvent s’exécuter en parallèle.
Exécutez les tests :
cargo test
running 3 tests
test tests::unknown_order_returns_404 ... ok
test tests::empty_customer_is_rejected ... ok
test tests::create_then_get_order ... ok
test result: ok. 3 passed; 0 failed
9. Erreurs courantes
Header Content-Type manquant. L’extractor Json requiert le header application/json. Sans ce header, le client reçoit 415 Unsupported Media Type même si le corps est parfaitement valide. C’est l’erreur la plus courante lors des tests avec curl.
Distinguer les codes d’erreur de Json. JSON mal formé retourne 400. JSON bien formé mais avec champs manquants ou type incorrect retourne 422. Par défaut, ces réponses ont un corps en texte brut, pas en JSON. Si votre API a besoin d’un format d’erreur unifié, enveloppez Json dans un extractor personnalisé et convertissez JsonRejection en AppError.
Utiliser unwrap() sur un verrou en production. Si un thread panique en tenant un verrou, le verrou sera « empoisonné », et tous les unwrap() suivants paniquent aussi. En code réel, gérez cette situation ou utilisez une vraie base de données.
Fourrer tout dans un grand AppState. À mesure que l’application se développe, vous pouvez utiliser FromRef pour que les handlers ne prennent que la partie de l’état dont ils ont besoin, par exemple State<PgPool>, au lieu de tout AppState.
Quand ne pas utiliser Axum ? Si vous avez besoin d’un framework « tout compris » avec ORM, templates, admin et migrations intégrés, Axum vous demandera d’assembler beaucoup de pièces vous-même. Si votre équipe n’est pas familière avec l’async Rust, les messages d’erreur des trait bounds des handlers seront aussi un obstacle considérable au démarrage.
10. Conclusion
Vous venez de construire une REST API CRUD complète avec Axum 0.8. Les concepts clés incluent :
- Router : lie les chemins et les méthodes aux handlers.
- Extractor :
State,Path,QueryetJsonrécupèrent les données de la requête de manière type-safe. - État partagé : les données partagées sont enveloppées dans
Arc. - Gestion des erreurs : implémentez
IntoResponsepour retourner des erreurs JSON structurées. - Test : envoyez les requêtes directement au router avec
tower::ServiceExt::oneshot.
L’étape logique suivante est de remplacer HashMap par sqlx se connectant à PostgreSQL. Vous devriez également ajouter des middlewares de tower-http, comme TraceLayer pour journaliser les requêtes et CorsLayer pour configurer CORS. La plupart des handlers n’auront pas besoin de changer, car la logique HTTP a été séparée de la couche de stockage.