Sérialisation — serde
1. Qu'est-ce que serde
serde (SERialization/DEserialization) est le standard de facto pour convertir des structures Rust vers et depuis des formats de données : JSON, TOML, YAML, MessagePack, CBOR, etc.
Architecture en deux couches :
// Couche 1 — serde core
// Définit les traits Serialize et Deserialize.
// Tes types implémentent ces traits.
// Couche 2 — serde_json, toml, serde_yaml...
// Chaque crate de format sait comment lire/écrire son format.
// Elle utilise les traits de serde core pour traverser tes données.
Le principal avantage : tu annotas tes types une seule fois, et tu peux les sérialiser vers n'importe quel format sans modifier le code.
2. Ajouter serde au projet
# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }
# Formats courants (ajouter selon le besoin)
serde_json = "1"
toml = "0.8"
serde_yaml = "0.9"
La feature derive active les macros #[derive(Serialize, Deserialize)]. Sans elle, tu devrais tout implémenter à la main.
3. `#[derive(Serialize, Deserialize)]`
C'est la façon la plus courante d'utiliser serde. La macro génère le code d'(dé)sérialisation pour toi.
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Utilisateur {
id: u32,
nom: String,
email: String,
actif: bool,
}
#[derive(Debug, Serialize, Deserialize)]
struct Article {
titre: String,
contenu: String,
auteur: Utilisateur,
tags: Vec<String>,
}
Les types standard (String, Vec, Option, HashMap, entiers, flottants, booléens) sont tous sérialisables nativement.
// Option<T> se sérialise en null si None, en valeur si Some
#[derive(Serialize, Deserialize)]
struct Profil {
nom: String,
bio: Option<String>, // null en JSON si absent
age: Option<u8>,
}
4. JSON avec `serde_json`
Sérialiser (Rust → JSON)
use serde_json;
let utilisateur = Utilisateur {
id: 1,
nom: "Alice".to_string(),
email: "[email protected]".to_string(),
actif: true,
};
// Vers une String
let json = serde_json::to_string(&utilisateur)?;
// {"id":1,"nom":"Alice","email":"[email protected]","actif":true}
// Vers une String formatée (indentée)
let json_pretty = serde_json::to_string_pretty(&utilisateur)?;
// Vers un Vec<u8> (pour écrire dans un fichier, une socket...)
let bytes = serde_json::to_vec(&utilisateur)?;
Désérialiser (JSON → Rust)
let json = r#"{"id": 2, "nom": "Bob", "email": "[email protected]", "actif": false}"#;
// Depuis une &str ou String
let utilisateur: Utilisateur = serde_json::from_str(json)?;
// Depuis des bytes
let utilisateur: Utilisateur = serde_json::from_slice(&bytes)?;
// Depuis un reader (fichier, réseau...)
let fichier = std::fs::File::open("utilisateur.json")?;
let utilisateur: Utilisateur = serde_json::from_reader(fichier)?;
La valeur dynamique `serde_json::Value`
Quand la structure n'est pas connue à la compilation :
use serde_json::{json, Value};
// Construire du JSON sans struct
let payload = json!({
"action": "connexion",
"donnees": {
"id": 42,
"roles": ["admin", "user"]
}
});
// Naviguer dans un JSON arbitraire
let data: Value = serde_json::from_str(json_inconnu)?;
if let Some(nom) = data["utilisateur"]["nom"].as_str() {
println!("Nom : {nom}");
}
5. TOML avec `toml`
# config.toml
[serveur]
hote = "0.0.0.0"
port = 8080
[base_de_donnees]
url = "postgres://localhost/mydb"
pool_max = 10
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize)]
struct Config {
serveur: ConfigServeur,
base_de_donnees: ConfigDb,
}
#[derive(Debug, Deserialize, Serialize)]
struct ConfigServeur {
hote: String,
port: u16,
}
#[derive(Debug, Deserialize, Serialize)]
struct ConfigDb {
url: String,
pool_max: u32,
}
// Lire un fichier TOML
let contenu = std::fs::read_to_string("config.toml")?;
let config: Config = toml::from_str(&contenu)?;
println!("Port : {}", config.serveur.port);
// Écrire en TOML
let toml_str = toml::to_string_pretty(&config)?;
std::fs::write("config_export.toml", toml_str)?;
6. Personnaliser la sérialisation
Les attributs #[serde(...)] permettent d'adapter le comportement sans écrire de code d'(dé)sérialisation manuellement.
`#[serde(rename)]` — renommer un champ
#[derive(Serialize, Deserialize)]
struct Commande {
#[serde(rename = "order_id")]
id_commande: u64,
#[serde(rename = "customer_name")]
nom_client: String,
}
// Sérialisé en : {"order_id": 1, "customer_name": "Alice"}
`#[serde(rename_all)]` — renommer tous les champs
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReponseApi {
user_id: u32, // → userId
nom_complet: String, // → nomComplet
date_creation: String, // → dateCreation
}
// Valeurs possibles : "camelCase", "snake_case", "PascalCase",
// "SCREAMING_SNAKE_CASE", "kebab-case"
`#[serde(skip)]` — exclure un champ
#[derive(Serialize, Deserialize)]
struct Utilisateur {
id: u32,
nom: String,
#[serde(skip)]
mot_de_passe_hash: String, // jamais sérialisé ni désérialisé
#[serde(skip_serializing)]
token_interne: String, // désérialisé depuis JSON, jamais écrit
}
`#[serde(default)]` — valeur par défaut si absent
#[derive(Serialize, Deserialize)]
struct Parametres {
langue: String,
#[serde(default)] // utilise Default::default() → false
notifications: bool,
#[serde(default = "taille_par_defaut")]
taille_page: u32,
}
fn taille_par_defaut() -> u32 { 20 }
`#[serde(alias)]` — accepter plusieurs noms à la désérialisation
#[derive(Deserialize)]
struct Payload {
#[serde(alias = "username", alias = "login")]
nom_utilisateur: String,
// Accepte "nom_utilisateur", "username" ou "login" dans le JSON entrant
}
7. Sérialisation d'enums
serde gère toutes les formes d'enums. La représentation JSON peut être contrôlée avec #[serde(tag)].
Forme par défaut (externally tagged)
#[derive(Serialize, Deserialize, Debug)]
enum Evenement {
Connexion { user_id: u32 },
Message { de: String, texte: String },
Deconnexion,
}
// Serialisé en :
// {"Connexion":{"user_id":1}}
// {"Message":{"de":"Alice","texte":"Bonjour"}}
// "Deconnexion"
`#[serde(tag = "type")]` — internally tagged
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
enum Notification {
Email { destinataire: String, sujet: String },
Sms { numero: String, corps: String },
Push { token: String },
}
// Serialisé en :
// {"type":"Email","destinataire":"alice@...","sujet":"..."}
// {"type":"Sms","numero":"+33...","corps":"..."}
`#[serde(tag = "type", content = "data")]` — adjacently tagged
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Reponse {
Ok(String),
Erreur { code: u32, message: String },
}
// {"type":"Ok","data":"succès"}
// {"type":"Erreur","data":{"code":404,"message":"Non trouvé"}}
`#[serde(untagged)]` — sans tag
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum Valeur {
Entier(i64),
Flottant(f64),
Texte(String),
Booleen(bool),
}
// Serde devine le variant selon la forme des données
8. Types génériques et serde
serde s'intègre naturellement avec les génériques, à condition d'ajouter les bounds appropriés.
use serde::{Deserialize, Serialize};
// T doit implémenter Serialize + Deserialize
#[derive(Serialize, Deserialize, Debug)]
struct Page<T> {
items: Vec<T>,
total: u64,
page: u32,
par_page: u32,
}
// Utilisation
let page: Page<Utilisateur> = Page {
items: vec![/* ... */],
total: 150,
page: 1,
par_page: 20,
};
let json = serde_json::to_string(&page)?;
Pour les bounds explicites dans les impl :
use serde::{de::DeserializeOwned, Serialize};
fn envoyer_json<T: Serialize>(valeur: &T) -> String {
serde_json::to_string(valeur).unwrap()
}
fn recevoir_json<T: DeserializeOwned>(json: &str) -> Result<T, serde_json::Error> {
serde_json::from_str(json)
}
// DeserializeOwned = Deserialize<'static> — pas de lifetime à gérer
Wrapper générique de réponse API
#[derive(Serialize)]
struct ApiReponse<T: Serialize> {
succes: bool,
donnees: Option<T>,
erreur: Option<String>,
}
impl<T: Serialize> ApiReponse<T> {
fn ok(donnees: T) -> Self {
ApiReponse { succes: true, donnees: Some(donnees), erreur: None }
}
fn err(message: impl Into<String>) -> ApiReponse<()> {
ApiReponse { succes: false, donnees: None, erreur: Some(message.into()) }
}
}
9. Exemples concrets avec Runique
Entités SeaORM qui dérivent Serialize
Dans Runique, les entités SeaORM sont dans demo-app/src/entities/. Pour les exposer via une API JSON, on dérive Serialize sur le Model :
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "articles")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub titre: String,
pub contenu: String,
pub publie: bool,
#[serde(skip)] // ne pas exposer dans l'API
pub auteur_id: i32,
#[serde(rename = "createdAt")] // convention camelCase pour le front
pub created_at: DateTimeUtc,
}
Réponses JSON dans un handler Runique
use runique::prelude::*;
use serde::Serialize;
#[derive(Serialize)]
struct ArticleListeReponse {
articles: Vec<article::Model>,
total: u64,
}
pub async fn liste_articles(request: Request) -> AppResult<Response> {
let articles = article::Entity::find()
.filter(article::Column::Publie.eq(true))
.all(request.db())
.await?; // DbErr → AppError automatiquement
let total = articles.len() as u64;
Ok(Json(ArticleListeReponse { articles, total }).into_response())
}
Désérialiser un body de requête
L'extracteur Request de Runique lit un body JSON comme une table plate de textes (StrMap), pour les formulaires. Pour un JSON structuré (listes, booléens, objets imbriqués), on utilise l'extracteur Json d'Axum à la place de Request : les deux lisent le body, un handler ne peut en prendre qu'un. La base reste accessible par l'engine, que Runique place dans les extensions de chaque requête.
Le middleware CSRF s'applique toujours : un POST JSON doit porter l'en-tête X-CSRF-Token, avec le jeton que le template reçoit dans {{ csrf_token }} (par exemple posé dans une balise <meta name="csrf-token"> et relu par le JavaScript), sinon il est refusé en 403 avant d'atteindre le handler.
use runique::prelude::*;
use serde::Deserialize;
#[derive(Deserialize)]
struct CreerArticlePayload {
titre: String,
contenu: String,
#[serde(default)]
publie: bool,
tags: Vec<String>,
}
pub async fn creer_article(
Extension(engine): Extension<AEngine>,
Json(payload): Json<CreerArticlePayload>,
) -> Result<Json<article::Model>, StatusCode> {
let nouvel_article = article::ActiveModel {
titre: Set(payload.titre),
contenu: Set(payload.contenu),
publie: Set(payload.publie),
..Default::default()
};
let article = nouvel_article
.insert(&engine.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(article))
}
10. Exercices pratiques
Exercice 1 : struct de configuration
Crée une struct AppConfig avec les champs : hote: String, port: u16, debug: bool (défaut false), max_connexions: u32 (défaut 100). Lis-la depuis le TOML suivant :
hote = "127.0.0.1"
port = 3000
Exercice 2 : enum de statut
Crée un enum StatutCommande avec les variants EnAttente, EnCours { depuis: String }, Livree, Annulee { raison: String }. Sérialise-le avec #[serde(tag = "statut")] et vérifie la sortie JSON.
Exercice 3 : pagination générique
Écris une struct PaginatedResponse<T> avec items: Vec<T>, total: u64, page: u32. Sérialise-la avec une liste de strings, puis une liste d'entiers, en vérifiant que le JSON produit est correct.
Exercice 4 : alias et renommage
Crée une struct LoginPayload qui accepte "email" ou "username" pour le champ identifiant, et "password" ou "mot_de_passe" pour le mot de passe. Utilise #[serde(alias)].
11. Aide-mémoire
| Besoin | Attribut / méthode |
|---|---|
| Renommer un champ | #[serde(rename = "nom")] |
| Renommer tous les champs | #[serde(rename_all = "camelCase")] |
| Exclure un champ | #[serde(skip)] |
| Exclure à la sérialisation | #[serde(skip_serializing)] |
| Valeur par défaut | #[serde(default)] ou #[serde(default = "fn")] |
| Accepter plusieurs noms | #[serde(alias = "autre")] |
| Enum avec tag interne | #[serde(tag = "type")] |
| Enum sans tag | #[serde(untagged)] |
| Sérialiser → JSON String | serde_json::to_string(&val)? |
| Désérialiser ← JSON String | serde_json::from_str::<T>(json)? |
| JSON formaté | serde_json::to_string_pretty(&val)? |
| JSON dynamique | serde_json::Value + macro json!{} |
| TOML → struct | toml::from_str::<T>(&contenu)? |
| struct → TOML | toml::to_string_pretty(&val)? |
| Bound générique désér. | T: DeserializeOwned |
Règle : Dériez toujours
Serialize+Deserializeensemble sauf besoin explicite de l'un sans l'autre. Préférez#[serde(rename_all)]au niveau de la struct plutôt que desrenamechamp par champ.