Encodez/décodez du TOML avec toml-rs !

Une bilbiothèque dédiée au format TOML.

En passant par la création, la mise en cache d’un squelette dédié aux contrôles et les tests d’intégrités des entrées, la gestion des fichiers de configuration a toujours été1, plus ou moins, fastidieuse en fonction de l’écosystème avec lequel nous travaillons.

Cependant, il semblerait que toml-rs fasse exception et facilite plutôt les choses, puisqu’elle vient directement s’appuyer sur le typage statique du compilateur pour effectuer automatiquement ces tests pour nous.

Si ça vous intéresse toujours, passons à la suite !


  1. Selon moi. 

Dépendances requises

Avant toute chose, assurez-vous d’avoir déclaré dans votre Cargo.toml:

  • La bibliothèque toml, bien entendu;
  • serde_derive, qui est une crate contenant les outils destinés à l’implémentation automatique des structures hôtes1;
  • serde.
1
2
3
4
[dependencies]
toml = "0.4"
serde_derive = "1.0.37"
serde = "1.0.37"

  1. Les structures dont on souhaite encoder ou décoder les données. 

Apparition du format

TOML est un langage dédié à la représentation des données et vient s’ajouter aux formats "humainement intelligibles" tels que JSON et YAML. Voici un exemple de fichier toml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# This is a TOML document.

title = "TOML Example"

[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00-08:00 # First class dates

[database]
server = "192.168.1.1"
ports = [ 8001, 8001, 8002 ]
connection_max = 5000
enabled = true

[servers]

  # Indentation (tabs and/or spaces) is allowed but not required
  [servers.alpha]
  ip = "10.0.0.1"
  dc = "eqdc10"

  [servers.beta]
  ip = "10.0.0.2"
  dc = "eqdc10"

[clients]
data = [ ["gamma", "delta"], [1, 2] ]

# Line breaks are OK when inside arrays
hosts = [
  "alpha",
  "omega"
]

Il fut créé et publié par Tom Preston-Werner, le 23 février 2013.

Bien que les spécifications rédigées par ce dernier sont encore jugées immatures, TOML dispose aujourd’hui d’une place importante au sein de l’écosystème Rust et des composants officiels.

Et pour toml-rs ?

toml-rs est donc une implémentation des spécifications dont je parlais dans la précédente section. Cette bibliothèque est destinée au décodage (dé-serialisation) ainsi qu’à l’encodage (sérialisation) de fichiers .toml, tout simplement.

Pour proposer cette solution, son auteur s’est basé sur le framework serde, spécialisé dans la sérialisation/dé-sérialisation des objets. Vous remarquerez très fréquemment l’utilisation de Serialize et Deserialize qui sont des traits provenant de ce fameux framework. Ils sont dérivés (entendez ici implémentés automatiquement) sur les structures à sérialiser ou celles qui recevront le résultat d’une dé-sérialisation.

Décodage

Pour le décodage, toml-rs vous propose la fonction toml::de::from_str et, pour rester dans un cas d’utilisation plutôt simple, je me servirai d’un des exemples de la documentation. Je vous invite à prêter une attention particulière aux quelques commentaires ajoutés dans l’exemple.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#[macro_use]
extern crate serde_derive;
extern crate toml;

// Le scope racine de votre fichier toml. Contiendra les tables
// s'il y en a.
#[derive(Deserialize)]
struct Config {
    title: String,
    owner: Owner,
}
// Une table, non optionnelle, présente dans le scope racine.
#[derive(Deserialize)]
struct Owner {
    name: String,
}

fn main() {
    let config: Config = toml::from_str(r#"
        title = 'TOML Example'

        [owner]
        name = 'Lisa'
    "#).unwrap();

    assert_eq!(config.title, "TOML Example");
    assert_eq!(config.owner.name, "Lisa");
}

Les arguments optionnels

Comme on peut le constater, le fichier de configuration est représenté par la structure Config. Chaque champ de cette dernière peut être soit une variable contenue dans le scope racine, soit une table contenant elle-même d’autres champs (variables). Il est alors possible de rendre ces tables optionnelles.

En reprenant l’exemple précédent, si nous voulions rendre la table Owner optionnelle, il suffirait de la déclarer comme telle dans le code. Ce qui donnerait quelque chose comme ceci:

1
2
3
4
5
6
7
#[derive(Deserialize)]
// Le scope racine de votre fichier toml. Contiendra les tables
// s'il y en a.
struct Config {
    title: String,
    owner: Option<Owner>,
}

Suite à cette modification, rien ne nous empêcherait d’écrire le toml suivant:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fn main() {
    let config: Config = toml::from_str(r#"
        title = 'TOML Example'
    "#).unwrap(); // Nous pouvons nous permettre de ne pas
    // déclarer la table `[owner]`, puisque désormais optionnelle.
    assert_eq!(config.title, "TOML Example");
    // Cette assertion est également modifiée.
    // `config.owner` étant wrappé dans un conteneur
    // nous pouvons tester si la table existe ou non.
    assert_eq!(config.owner, None);
}

J’insiste sur le fait que les champs peuvent également être optionnels. Nous aurions très bien pu déclarer Owner.name comme un champ optionnel, de la même manière que la table Owner elle-même.

1
2
3
4
#[derive(Deserialize)]
struct Owner {
   name: Option<String>,
}

Mais ça n’aurait que très peu d’intérêt, en l’occurrence.

Décoder à partir d’un fichier

Vous avez sans doute remarqué que toml fait abstraction de la provenance des ressources que ses outils vont traiter (i.e. les fonctions dédiées au parsing n’acceptent rien d’autres que des chaînes de caractères et des octets). Les deux fonctions proposées sont:

  1. &str, par le biais de toml::de::from_str;
  2. &[u8], par le biais de toml::de::from_slice.

Dans les deux cas, si vous souhaitez récupérer du toml depuis un fichier (ou un stream quelconque), il faudra se débrouiller.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
let meat = {
    let mut file_path: path::PathBuf = path::PathBuf::new();
    // On admettra ici que votre fichier est à la racine du projet.
    file_path.push(env::current_dir().unwrap().as_path());
    file_path.push("my_file.toml");
    let mut configuration_file: fs::File = fs::OpenOptions::new()
        .read(true)
        .open(file_path.as_path())
        .expect("Cannot open the configuration file");

    // tip: Si vous souhaitez récupérer le contenu de votre fichier
    // sous forme d'octets, il suffit de remplacer la ligne 16 par celle-ci:
    // `let mut raw_content: Vec<u8>: vec![];`

    let mut stringified_content: String = String::new();

    // Vous devrez également modifier la méthode utilisée ici par:
    // `configuration_file.read_to_end(&mut raw_content)`
    match configuration_file.read_to_string(&mut stringified_content) {
        Ok(bytes) => println!("{} bytes has been appended to buffer.", bytes),
        Err(error) => panic!(
            "
            The data in this stream is not valid UTF-8.\n
            See error: '{}'
            ",
            error
        ),
    }
    stringified_content
};

// Remplacez ici `from_str` par `from_slice`
// et modifiez votre paramètre en conséquence.
let config_content: Config = toml::from_str(meat.as_str())
    .expect("Something went wrong");

Par ailleurs, vous pouvez consulter l’annexe pour y retrouver une fonction se chargeant d’effectuer ces traitements pour vous.

Encodage

L’encodage se fait tout aussi simplement que le décodage grâce à toml::to_string. Si l’on reprend les structures précédentes, cela donnerait…

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Attention à bien dériver le trait `Serialize`.
#[derive(Debug, Serialize)]
struct Config {
    title: String,
    owner: Owner,
}
// Attention à bien dériver le trait `Serialize` ici aussi.
#[derive(Debug, Serialize)]
struct Owner {
    name: String,
}
1
2
3
4
5
6
7
8
9
let meat = Config {
    title: "Here's my configuration title!".to_owned(),
    owner: Owner {
        name: "Songbird".to_owned(),
    },
};

let config_content: String = toml::to_string(&meat)
    .expect("Something went wrong");

…c’est tout ! Sauf si vous voulez écrire le dump dans un de vos fichiers, encore une fois.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
let meat = Config {
    title: "Here's my configuration title!".to_owned(),
    owner: Owner {
        name: "Songbird".to_owned(),
    },
};

let config_content: String = toml::to_string(&meat)
    .expect("Something went wrong");

let mut file_path: path::PathBuf = path::PathBuf::new();
file_path.push(env::current_dir().unwrap().as_path());
file_path.push("a_file.toml");

let mut configuration_file: fs::File = fs::OpenOptions::new()
    .write(true)
    .create(true)
    .open(file_path.as_path())
    .expect("Cannot open the configuration file");

// On récupère ce que `toml` vient de produire.
let bytes: &[u8] = &config_content.into_bytes();

configuration_file
.write_all(bytes)
.expect("An error was occurred");

Les tables imbriquées

Dans des scénarios plus "complexes", vous pourriez avoir besoin d’imbriquer des tables, comme le stipulent les spécifications du langage. Rien de plus simple !

Mettons:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
extern crate toml;
#[macro_use]
extern crate serde_derive;

#[derive(Debug, Serialize)]
struct ConfigFile {
    owner: Owner,
    // Le fichier ne peut contenir qu'une seule table `[team]`.
    team: Option<Team>,
}

#[derive(Debug, Serialize)]
struct Owner {
    // Champ obligatoire.
    name: String,
    // Champ obligatoire.
    mail: String,
}

#[derive(Debug, Serialize)]
struct Team {
    // La table `[team]` peut disposer de
    // plusieurs sous-tables `[[team.members]]`.
    members: Vec<Member>,
}

#[derive(Debug, Serialize)]
struct Member {
    // Champ obligatoire.
    name: String,
    // Champ obligatoire.
    job: String,
    // Champ optionnel.
    mail: Option<String>,
}

let meat = ConfigFile {
    owner: Owner {
        name: "Songbird".to_owned(),
        mail: "johndoe@johndoe.com".to_owned(),
    },
    team: Some(Team {
        members: vec![
            Member {
                name: "Alice".to_owned(),
                job: "Tech leader".to_owned(),
                mail: Some("resp@abc.xyz".to_owned()),
            },
            Member {
                name: "Bob".to_owned(),
                job: "Developer".to_owned(),
                mail: None,
            },
            Member {
                name: "John".to_owned(),
                job: "Community Manager".to_owned(),
                mail: None,
            },
        ],
    }),
};

Cette fois-ci, je vous épargne le code pour écrire tout ceci dans un fichier, passons au résultat:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[owner]
name = "Songbird"
mail = "johndoe@johnedoe.com"
[[team.members]]
name = "Alice"
job = "Tech leader"
mail = "resp@abc.xyz"

[[team.members]]
name = "Bob"
job = "Developer"

[[team.members]]
name = "John"
job = "Community Manager"

La macro toml!

Toutefois, on notera rapidement quelque chose: l’écriture commence à être plutôt lourde. En réponse à cela, la bibliothèque dispose d’une macro modestement nommée toml!. Ce qui nous donnera, après modification:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#[macro_use]
extern crate toml;
#[macro_use]
extern crate serde_derive;

// Certains de ces imports sont à supprimer si
// vous n'effectuez aucune opération d'entrée/sortie.
use std::{convert, env, fs, io, io::prelude::*, path};

let meat = toml! {
    [owner]
    name = "Songbird"
    mail = "johndoe@johnedoe.com"
    [[team.members]]
    name = "Alice"
    job = "Tech leader"
    mail = "resp@abc.xyz"

    [[team.members]]
    name = "Bob"
    job = "Developer"

    [[team.members]]
    name = "John"
    job = "Community Manager"
};

Le dump sera strictement le même. :)

Bien qu’il existe une manière plus simple de rédiger du toml, les structures précédemment déclarées ne deviennent pas inutiles. Elles vous permettront de vous assurer de l’intégrité de vos fichiers dans le cas d’une éventuelle modification effectuée par un utilisateur.

Annexe

J’ai écrit deux fonctions, plutôt basiques, permettant de rendre l’écriture (ou la lecture) des fichiers toml moins rébarbative. Grâce à std::convert::AsRef, elles supportent tous les types ayant implémenté le trait pour la structure std::path::Path.

Ces fonctions n’ont pas d’autres prétentions que de servir d’exemples ou d’outils "de départ" pour tester de petites choses de votre côté.

read_toml_file(path, buffer)

Lit le fichier path, stocke le contenu du fichier dans le tampon buffer et renvoie une instance de la structure censée représenter votre fichier toml. Concernant les erreurs vous ne pourrez rattraper que celles lancées par toml::from_str. C’est le point noir de cette fonction, vous ne pourrez vous en servir que pour empêcher l’exécution d’une partie, plus importante, de votre programme (par exemple dans le cas où vous ne souhaitez pas que l’utilisateur final se serve de votre programme, sans avoir créé un fichier de configuration au préalable). C’est à modifier en fonction des besoins.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn read_toml_file<'a, 'de, P: ?Sized, T>(path: &'a P, buffer: &'de mut String) -> Result<T, Error>
where
    P: convert::AsRef<path::Path>,
    T: Deserialize<'de>,
{
    let mut configuration_file: fs::File = fs::OpenOptions::new()
        .read(true)
        .open(path)
        .expect("Cannot read the configuration file");
    match configuration_file.read_to_string(buffer) {
        Ok(bytes) => {
            println!("{} bytes has been appended to buffer.", bytes);
            toml::from_str(buffer.as_str())
        }
        Err(error) => panic!(
            "
                The data in this stream is not valid UTF-8.\n
                See error: '{}'
                ",
            error
        ),
    }
}

Exemple

1
2
3
4
5
6
7
8
let mut file_path: path::PathBuf = path::PathBuf::new();

file_path.push(env::current_dir().unwrap().as_path());
file_path.push("my_file.toml");
let mut buffer = String::new();
let config_content: Config = read_toml_file(&file_path, &mut buffer)
    .expect("Something went wrong");
println!("Your config object: {:#?}", config_content);

Résultat

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
246 bytes has been appended to buffer.
Your config object: Config {
    projects: [
        ProjectConfig {
            project_home: "my/home/",
            project_root: "my_directory",
            subroots: Some(
                [
                    SubrootConfig {
                        path: "my/path/file"
                    },
                    SubrootConfig {
                        path: "my/path/file"
                    }
                ]
            )
        },
        ProjectConfig {
            project_home: "another/home/",
            project_root: "another_directory",
            subroots: None
        }
    ]
}

Les structures ne sont pas identiques à celles présentées tout au long du billet, mais le principe est le même.

create_toml_file(path, content)

Écrit le contenu content dans le chemin path soumis. content est transféré à l’appel de cette fonction.

Rien d’extraordinaire pour celle-ci, elle aurait très bien pu s’appeler create_file puisqu’elle nous épargne simplement les lignes de préparation des accès et de l’écriture. Toutes les erreurs lancées par cette fonction sont gérables, en revanche.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
fn create_toml_file<'a, P: ?Sized>(path: &'a P, content: String) -> io::Result<()>
where
    P: convert::AsRef<path::Path>,
{
    let mut toml_file: fs::File = fs::OpenOptions::new()
        .write(true)
        .create(true)
        .open(path)?;

    let bytes: &[u8] = &content.into_bytes();

    toml_file.write_all(bytes)
}

Exemple

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
let meat: toml::Value = toml! {
    [owner]
    name = "Songbird"
    mail = "johndoe@johnedoe.com"
    [[team.members]]
    name = "Alice"
    job = "Tech leader"
    mail = "resp@abc.xyz"

    [[team.members]]
    name = "Bob"
    job = "Developer"

    [[team.members]]
    name = "John"
    job = "Community Manager"
};

let config_content: String = toml::to_string(&meat)
    .expect("Something went wrong");
let mut file_path: path::PathBuf = path::PathBuf::new();

file_path.push(env::current_dir().unwrap().as_path());
file_path.push("a_file.toml");

assert!(create_toml_file(&file_path, config_content).is_ok());

Il me semble que nous en ayons terminé !

Globalement, la seule chose à retenir est d’utiliser systématiquement1 la macro toml! pour écrire le squelette initial du fichier et déclarer les structures respectives à chaque table uniquement pour la lecture et la vérification des entrées. Amusez-vous bien ! :)

Les logos Rust et Cargo (aux formats matriciel et vectoriel) sont la propriété de Mozilla et sont distribués selon les termes de la licence Creative Commons Attribution (CC-BY).


  1. Sauf si vous avez une bonne raison de ne pas le faire. 

14 commentaires

Par ailleurs, s’il y a des programmeurs Rust avertis dans les parages, je suis ouvert aux retours. :)

Songbird

Je me permet du coup de faire une petite remarque sur ta gestion des erreurs. :)

Plusieurs fois tu as un pattern de Result.map_err(|e| panic!("Message: {}", e).unwrap() ; tu as Result.expect pour ça.

Aussi, si au lieu de panicer, tu veux propager les erreurs proprement, il vaut mieux retourner des Result<T, Err> pour tes fonctions (ce que tu avais l’air de faire pour read_toml_file), et d’ensuite utiliser l’opérateur ? pour unwrap le résultat. Ça aura comme effet de return de la fonction avec l’erreur s’il y en a une. Pour une gestion des erreurs simplifiée, je te conseille d’ailleurs le crate failure

+2 -0

Salut Sandhose,

Effectivement, j’ai une gestion des erreurs un peu pourrie, il faut que je révise les différents cas de gestion parce que ça pêche un peu.

Par contre, concernant le renvoi de Result<T, Error> pour la fonction read_toml_file, c’est un peu plus problématique.

1
2
3
let mut configuration_file: fs::File = fs::OpenOptions::new()
    .read(true)
    .open(path)?;

Ce qui donne:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
error[E0277]: the trait bound `toml::de::Error: std::convert::From<std::io::Error>` is not satisfied
   --> src\main.rs:159:44
    |
159 |       let mut configuration_file: fs::File = fs::OpenOptions::new()
    |  ____________________________________________^
160 | |         .read(true)
161 | |         .open(path)?;
    | |____________________^ the trait `std::convert::From<std::io::Error>` is not implemented for `toml::de::Error`
    |
    = note: required by `std::convert::From::from`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.

Le Error que tu remarques dans la signature n’est pas std::io::Error mais toml::de::Error qui n’implémente pas std::convert::From, ce qui pose un problème lors du renvoi. Même en utilisant la généricité ou en faisant une allocation dynamique d’un objet implémentant std::error::Error, ça ne fonctionne pas.

edit: Il faudrait que j’implémente les traits chargés de la conversion pour en faire quelque chose.

+0 -0

edit: Il faudrait que j’implémente les traits chargés de la conversion pour en faire quelque chose.

C’est exactement ce que fait error_chain de manière triviale, pour reciter le message d’un VDD. ;)

exemple (très) rapide:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
mod errors {
    error_chain! {
        foreign_links {
            Toml(::toml::de::Error);
            Io(::io::Error);
            ParseInt(::std::num::ParseIntError);
            Image(::image::ImageError);
        }
    }
}

// Et maintenant, le type Result sera celui d'error_chain, qui aura 
// implémenté les From des erreurs qu'on a mis au dessus
use errors::Result; 
+0 -0

Je m’oppose à l’utilisation de error-chain ou de toute crate "aidant" à la gestion des erreurs. On peut faire beaucoup plus simple (et sans macro ni overhead bien chiant) :

1
2
3
fn some_fn(...) -> Result<T, String> {
    let f = File::open("file").map_err(|e| format!("error while opening file: {}", e)?;
}
+0 -0

Salut imperio,

On peut faire beaucoup plus simple (et sans macro ni overhead bien chiant) :

Seulement, l’exemple que tu proposes ne règle pas le souci lors du renvoi de plusieurs types de Result. Si on a, admettons, 3 types/signatures de Result différents, on devrait quand même se passer de ce que semble proposer error-chain ?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// pour illustrer:
fn foo(...) -> Result<T, A>  {
    if (...) {
        return // => Result<T, B>
    }

    if (...) {
        return // => Result<T, C>
    }

    return // => Result<T, A>
}
// Ici, on aura toujours un problème.

Je m’oppose à l’utilisation de error-chain ou de toute crate "aidant" à la gestion des erreurs. On peut faire beaucoup plus simple (et sans macro ni overhead bien chiant) :

1
2
3
fn some_fn(...) -> Result<T, String> {
    let f = File::open("file").map_err(|e| format!("error while opening file: {}", e)?;
}
imperio

Utiliser error_chain pour une seule fonction et deux types d’erreurs, clairement c’est overkill. Mais la crate apporte beaucoup plus de choses utiles que ça pour le développement et la gestion d’erreur. Sauf si tu as vraiment besoin de la moindre perf et que tu peux te payer le luxe de réécrire toi même ta gestion d’erreur, error-chain m’a l’air d’être parfaitement désigné pour avoir des erreurs correctes tout en restant productif et concentré sur la fonctionnalité qu’on développe.

Ravi de te voir participer ici et merci pour ton tutoriel au passage !

Le problème avec error-chain est plutôt que les gens s’habituent à son utilisation et ça finit généralement par leur péter au visage à cause des macros. Si le projet grossit en taille, il y a de forte chances pour qu’on en vienne à atteindre la "recursion limit". Et là je te raconte pas le bordel.

Mais en plus de ça, ça réduit les performances globales de ton programme, en effet. Vraiment, je ne vois que des défauts et finalement bien peu d’intérêts. Si ça t’embête de faire une gestion "à la main" de tes erreurs, voilà la macro de tes rêves ( ;) ) :

1
2
3
4
5
macro_rules! error {
    ($t:expr, $msg:expr) => {
        $t.map_err(|e| format!("{}: {}", $msg, e))
    }
}

Et après tu peux tout simplement l’utiliser comme ça :

1
2
3
4
fn foo() -> Result<(), String> {
    let _ = error!(std::fs::File::open("test"), "Couldn't open file")?;
    Ok(())
}

Ça m’inquiète vraiment que les gens apprécient autant ce genre de crates alors qu’ils n’en tireront finalement aucun bénéfice… ><

EDIT (j’en profite pour épiloguer un peu héhé) : si on se force à faire la gestion d’erreur correctement au fur et à mesure de l’écriture du programme en retournant l’erreur à chaque fois, finalement il n’y a rien de particulier à faire. Tout ce qu’il reste à faire en soi c’est afficher l’erreur (s’il y en a une dans une…) dans le main :

1
2
3
4
5
fn main() {
    if let Err(e) = foo() {
        eprintln!("error: {}", e);
    }
}

Ça dépend donc vraiment de la façon de penser le programme. Si tu le fais comme ça, tu iras sans doute plus vite qu’avec error-chain et tu n’auras aucun "effet de bord" indésirable.

+0 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte