Rustango docs
← Premiers pas

Bien démarrer : construire un blog avec Rustango

Ce guide vous accompagne depuis un répertoire vide jusqu'à un blog déployé : des articles, une interface d'administration, une API JSON, une authentification JWT et des tests. De bout en bout.

Durée : ~45 minutes pour la visite complète, ~10 minutes si vous voulez juste la voir fonctionner.

Version exécutable : chaque étape ci-dessous est reproduite dans un exemple testé et compilable disponible dans crates/rustango/examples/getting_started_blog. Si une étape vous semble incorrecte, comparez-la à cet exemple.

Construire un blog avec Rustango : générer la migration, l'appliquer, démarrer le serveur, et interroger l'API JSON — tout depuis un seul binaire


Ce qu'il faut savoir d'abord

Deux questions bien distinctes, et la documentation ne répondait jusqu'ici qu'à la seconde.

Rust est supposé acquis. Pas un niveau expert, mais vous devez être à l'aise avec les structs, les traits, Result et ?, et connaître assez async/.await pour lire une fonction sans rien avoir à chercher. Si ce n'est pas encore votre cas, commencez par le Rust Book ; ce guide n'enseignera pas le langage qui se trouve en dessous.

L'expérience du back-end web n'est pas supposée acquise. Si vous n'avez jamais construit d'API web, commencez par Les bases des API web dans le glossaire. C'est une introduction de cinq minutes aux requêtes, aux routes, aux handlers et aux migrations, écrite exactement pour combler ce manque. Revenez ici ensuite.

Aucune expérience préalable d'un autre framework web n'est supposée acquise. Lorsque cette documentation fait une comparaison, c'est un aparté, jamais l'explication : si un parallèle ne vous évoque rien, passez-le, l'étape tient debout toute seule. Là où un terme joue un vrai rôle, le glossaire le définit en langage clair.

Ce qu'il faut installer

OutilPourquoiInstallation
Rust 1.88+Compilateurhttps://rustup.rs
Une base de donnéesCe guide utilise Postgresvoir Choisir une base de données ci-dessous
psql (optionnel)Inspecter la BDDbrew install libpq / apt install postgresql-client
rustc --version    # should print 1.88+

Choisir une base de données

Docker n'est pas obligatoire. Ce guide y a recours parce qu'une seule commande fournit un Postgres jetable, mais rien dans rustango n'en dépend. Choisissez la ligne qui correspond à votre machine — tout le reste est identique :

Vous voulezFaites ceciRemarques
Aucun serveur de base de donnéesLancer avec SQLite (ci-dessous)Rien à installer. Idéal pour apprendre.
Postgres sans DockerInstaller Postgres nativement et pointer DATABASE_URL sur localhostVoir Postgres natif.
Postgres avec Dockerdocker compose up -d dans le projet généréCe que suppose la suite de ce guide.
MySQL ou MariaDBGénérer avec --backend mysqlVoir MySQL.

Quel que soit votre choix, la seule chose qui change est DATABASE_URL. Voici la forme de chacune :

DATABASE_URL=postgres://user:password@localhost:5432/myblog_dev
DATABASE_URL=mysql://user:password@localhost:3306/myblog_dev
DATABASE_URL=sqlite://myblog_dev.db?mode=rwc

SQLite — zéro installation

Générez directement le projet pour SQLite : il n'y a rien à installer, rien à démarrer et rien à modifier ensuite.

cargo rustango new myblog --backend sqlite

Les .env.example, docker-compose.yml et paliers de configuration générés sont tous écrits pour SQLite, la base est un fichier créé par le premier cargo run -- migrate, et vous pouvez sauter entièrement l'étape 4.

Si vous avez déjà généré un projet Postgres et voulez en changer, chaque template garde les trois backends câblés — c'est donc un flag plus une URL :

cargo run --no-default-features --features sqlite
DATABASE_URL=sqlite://myblog_dev.db?mode=rwc

mode=rwc demande à SQLite de créer le fichier s'il n'existe pas. Tout ce que couvre ce guide — modèles, migrations, l'admin, l'ORM — fonctionne à l'identique ; seules les fonctionnalités propres à Postgres (opérateurs JSONB, multi-tenancy en mode schéma) ne s'appliquent pas.

Postgres natif (sans Docker)

Installez Postgres via votre gestionnaire de paquets (brew install postgresql@16, apt install postgresql, ou l'installateur Windows sur https://www.postgresql.org/download/windows/), puis créez le rôle et la base que la configuration générée attend :

createuser -s rustango          # ou : CREATE ROLE rustango LOGIN SUPERUSER PASSWORD 'rustango';
createdb myblog_dev -O rustango

Le .env.example généré pointe sur le nom du service Docker. Remplacez l'hôte par localhost :

# .env  —  `postgres` est le nom du service docker-compose ; en natif, localhost
DATABASE_URL=postgres://rustango:rustango@localhost:5432/myblog_dev

Sous Windows ? Le backend Hyper-V / WSL2 de Docker Desktop est une cause fréquente d'échecs au démarrage. S'il vous résiste, prenez la voie SQLite ci-dessus pour apprendre le framework et revenez à Docker au moment du déploiement — c'est à cela que sert vraiment la configuration conteneurisée.

Postgres tourne déjà en local ? Alors le port 5432 est déjà pris, et le conteneur perd silencieusement la course. Votre application se connecte au serveur local, qui ne contient aucune de vos tables. L'erreur renvoyée est illisible, parce qu'un serveur non anglophone envoie son message dans son propre encodage. Arrêtez le service local, ou déplacez le conteneur sur un autre port.

MySQL

Générez le projet avec --backend mysql : les .env.example, docker-compose.yml et paliers de configuration générés sont alors tous écrits pour MySQL :

cargo rustango new myblog --backend mysql

L'exécuter nativement plutôt que dans le conteneur demande une base et un utilisateur :

CREATE DATABASE myblog_dev CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'rustango'@'localhost' IDENTIFIED BY 'rustango';
GRANT ALL PRIVILEGES ON myblog_dev.* TO 'rustango'@'localhost';
DATABASE_URL=mysql://rustango:rustango@localhost:3306/myblog_dev

utf8mb4 mérite d'être choisi délibérément : l'ancien utf8 de MySQL tient sur trois octets et ne peut pas stocker un emoji, ce qui ressurgit bien plus tard sous la forme d'une écriture qui échoue sur une seule ligne. MariaDB fonctionne avec le même pilote et le même schéma d'URL.


Étape 1 : installer le générateur de squelette

Le générateur de squelette (scaffolder) crée pour vous des squelettes de projet et d'application, comme rails new.

cargo install cargo-rustango

Ceci ajoute globalement la sous-commande cargo rustango .... Vérifiez qu'elle est bien disponible :

cargo rustango --help

La version du générateur de squelette est celle que votre projet épingle : installer le plus récent vous donne le rustango le plus récent. Pour générer un projet sur une version plus ancienne, installez plutôt ce générateur-là (cargo install cargo-rustango --version 0.57.11) — voir Échafaudage.


Étape 2 : créer le projet

Ceci génère un nouveau projet, l'équivalent chez Rustango de rails new ou composer create-project.

cd ~/projects                                 # wherever you keep code
cargo rustango new myblog                     # default = fullstack template
cd myblog

Lancez cargo rustango new sans aucun argument et il demande le template, le backend et les fonctionnalités supplémentaires, puis affiche la ligne de commande équivalente avant de créer quoi que ce soit — voir Échafaudage.

Voici ce qui a été généré :

myblog/
├── Cargo.toml                  # rustango + axum + sqlx + tokio
├── .env.example                # template for DATABASE_URL etc.
├── .gitignore
├── docker-compose.yml          # Postgres in a container
├── README.md                   # project-specific
├── config/                     # tiered settings (default + dev/staging/prod)
├── migrations/                 # empty — `cargo run -- makemigrations` populates
└── src/
    ├── main.rs                 # entry point: `Cli::new().api(urls::api()).run()`
    ├── models.rs               # every #[derive(Model)] lives here
    ├── views.rs                # axum request handlers
    └── urls.rs                 # agrégateur de routes `pub fn api()`

Il n'y a qu'un seul binaire : cargo run démarre le serveur HTTP, et chaque verbe d'administration (migrate, makemigrations, startapp, check, …) passe par ce même binaire via cargo run -- <verb>. Il n'y a pas de binaire manage séparé.

Cargo.toml est le manifeste de dépendances (comme un composer.json ou un Gemfile). Ouvrez-le et vérifiez que rustango figure bien dans [dependencies].

Vérifiez le bloc [features] — choisissez un backend de base de données. #[derive(Model)] conditionne (via cfg) ses implémentations générées de FromRow / LoadRelated aux features de votre crate (un cfg à l'intérieur d'une macro derive se résout par rapport à la crate de destination, pas celle de Rustango), donc une feature de backend doit être activée ici, sinon le premier modèle ne compilera pas. Un squelette actuel inclut :

[features]
default  = ["postgres"]            # the backend `cargo run` uses
postgres = ["rustango/postgres"]
sqlite   = ["rustango/sqlite"]
mysql    = ["rustango/mysql"]

Si votre Cargo.toml généré n'a aucun bloc [features] (un cargo-rustango plus ancien), ajoutez celui ci-dessus à la main — cela résout toujours le problème. Sans cela, la compilation échoue avec « the trait bound …: MaybePgFromRow is not satisfied » ainsi qu'un révélateur warning: unexpected cfg condition value: postgres.


Étape 3 : configurer votre environnement

La configuration se trouve dans un fichier .env. Copiez le modèle :

cp .env.example .env

Le fichier .env généré est prêt à l'emploi avec Docker. Comme nous allons exécuter cargo sur la machine hôte (et non dans le conteneur de développement), changez l'hôte de la base de données de postgres à localhost :

DATABASE_URL=postgres://rustango:rustango@localhost:5432/myblog_dev
RUSTANGO_BIND=0.0.0.0:8080
RUSTANGO_APEX_DOMAIN=localhost

Les identifiants, le port et le nom de la base de données (myblog_dev) correspondent déjà au service Postgres du docker-compose.yml, donc vous n'avez pas besoin d'y toucher.

RUSTANGO_SESSION_SECRET signe les sessions et les jetons. Il est laissé en commentaire dans le .env.example généré, et pour le développement vous pouvez le laisser ainsi : le premier démarrage génère une clé dans ./var/ et la réutilise, si bien qu'un redémarrage ne vous déconnecte pas.

Pour la production, définissez-en une vraie — 32 octets de base64, depuis votre gestionnaire de secrets ou :

openssl rand -base64 32     # paste output as RUSTANGO_SESSION_SECRET value

Une valeur qui n'est pas 32 octets de base64 ne peut pas être utilisée. Le serveur le signale au démarrage et se rabat sur la clé générée plutôt que d'échouer — surveillez donc cet avertissement si vous en avez défini une et que les sessions se comportent comme si ce n'était pas le cas.


Étape 4 : démarrer la base de données

Vous utilisez SQLite ? Passez cette étape — il n'y a aucun serveur à démarrer. Vérifiez que .env contient DATABASE_URL=sqlite://myblog_dev.db?mode=rwc et ajoutez --no-default-features --features sqlite à chaque cargo run ci-dessous.

Vous utilisez un Postgres natif ? Il tourne déjà comme service ; vérifiez simplement que psql "$DATABASE_URL" -c "SELECT version();" aboutit, puis passez à la suite.

Le projet inclut un docker-compose.yml qui exécute Postgres dans un conteneur, afin que vous n'ayez pas à installer une base de données à la main. Nous allons exécuter l'application elle-même avec cargo sur l'hôte, donc démarrez uniquement le service postgres en arrière-plan (le fichier compose définit aussi un conteneur de développement rust optionnel qui occuperait sinon le port 8080) :

docker compose up -d postgres

Vérifiez qu'il fonctionne :

docker compose ps
psql "$DATABASE_URL" -c "SELECT version();"   # should print Postgres version

Étape 5 : exécuter les migrations intégrées

Les migrations créent les tables de votre base de données, la même idée que php artisan migrate ou rails db:migrate. Exécutez-les une fois pour mettre en place les propres tables du framework :

cargo run -- migrate

La première compilation prend ~2 minutes (Rust compile tout depuis les sources). Un projet neuf n'expose encore aucun fichier de migration, donc vous verrez nothing to migrate (already up to date) — migrate met néanmoins en place la table de journal d'audit du framework afin que les modèles audités fonctionnent dès que vous les ajouterez. Vous générerez votre première vraie migration à l'étape 9.

Vérifiez l'état des migrations :

cargo run -- showmigrations

Sur un projet neuf, ceci affiche (no migrations in ./migrations). Une fois que vous aurez créé un modèle et exécuté makemigrations (étape 9), chaque migration appliquée affichera un [X] ici.


Étape 6 : premier démarrage

Démarrez le serveur pour vérifier que tout est correctement branché.

cargo run

Vous verrez :

listening on http://0.0.0.0:8080

Ouvrez http://localhost:8080 dans votre navigateur. Le squelette fournit un gestionnaire racine simple (views::index) qui vous accueille avec Hello from Rustango! et un lien vers l'administration — ce qui confirme que Rustango fonctionne. (Les projets qui ne définissent pas leur propre route / reçoivent à la place une page d'accueil intégrée, via Cli::with_welcome().)

Appuyez sur Ctrl-C pour arrêter.


Étape 7 : créer une application

Une « application » est un module fonctionnel autonome. Votre application blog contiendra le modèle Post, ses routes et ses gabarits (templates).

cargo run -- startapp blog

Ceci écrit :

src/blog/
├── mod.rs
├── models.rs              # a starter model named after the app (you'll replace it)
├── views.rs               # axum handlers
├── urls.rs                # blog-specific routes (pub fn api())
└── tests.rs               # in-process router + inventory smoke tests

startapp branche le nouveau module pour vous : il déclare mod blog; dans src/main.rs et insère une ligne .merge(crate::blog::urls::api()) dans l'agrégateur api() de src/urls.rs, si bien que les routes du blog s'intègrent automatiquement à l'application. Aucun enregistrement manuel de module n'est nécessaire.


Étape 8 : définir un modèle

Un modèle est une table de base de données décrite comme une structure Rust — une classe façon Active Record en Rust. Ouvrez src/blog/models.rs et définissez votre Post. (Pour la référence complète — chaque type de champ, les clés primaires personnalisées et tous les attributs — voir le guide des modèles.)

use rustango::{Auto, Model};
use chrono::{DateTime, Utc};

#[derive(Model, Clone, Debug)]
#[rustango(
    table = "posts",
    display = "title",
    admin(
        list_display  = "id, title, status, published_at",
        search_fields = "title, body",
        list_filter   = "status, author_id",
        ordering      = "-published_at",
    ),
    audit(track = "title, body, status"),
    index("status, published_at"),
)]
pub struct Post {
    #[rustango(primary_key)]
    pub id: Auto<i64>,

    #[rustango(max_length = 200)]
    pub title: String,

    pub body: String,

    #[rustango(max_length = 20, default = "'draft'")]
    pub status: String,                  // draft | published

    pub author_id: i64,

    #[rustango(auto_now_add)]
    pub published_at: Auto<DateTime<Utc>>,

    #[rustango(soft_delete)]
    pub deleted_at: Option<DateTime<Utc>>,
}

Quelques points Rust à noter :

  • #[derive(Model, ...)] est une macro derive : elle génère automatiquement du code pour la structure, à la manière d'un décorateur de classe ou d'une classe de base dans d'autres frameworks. Dériver Model est ce qui donne à la structure ses méthodes de requête.
  • Auto<i64> marque un champ que la base de données remplit pour vous (un entier i64 auto-incrémenté), comme une clé primaire automatique.
  • Option<...> signifie « cette valeur peut être absente ». Option<DateTime<Utc>> est un horodatage qui peut être nul, donc deleted_at est vide jusqu'à ce que la ligne soit supprimée en douceur (soft-delete).
  • Les attributs #[rustango(...)] configurent chaque champ (longueur maximale, valeurs par défaut, index) et le bloc admin(...) définit les colonnes et filtres de l'interface d'administration.

Étape 9 : créer et appliquer la migration

Transformons maintenant ce modèle en une véritable table. Générez d'abord la migration à partir de votre modèle :

cargo run -- makemigrations

Vous verrez quelque chose comme :

wrote ./migrations/0001_create_item_and_posts_and_rustango_admin_users_etc.json
    + CreateTable("item")
    + CreateTable("posts")
    + CreateTable("rustango_admin_users")
    + CreateTable("rustango_content_types")
    + CreateIndex { table: "posts", columns: ["status", "published_at"], ... }

Cette première migration crée vos modèles — posts, ainsi que le modèle de démarrage item fourni par le squelette dans src/models.rs — en même temps que les propres tables d'administration et de types de contenu du framework. Ouvrez le fichier JSON si vous le souhaitez : il contient les opérations ainsi qu'un instantané complet du schéma.

Appliquez-la à la base de données :

cargo run -- migrate

Vérifiez que la table existe :

psql "$DATABASE_URL" -c "\d posts"

Étape 10 : essayer l'ORM

Lisons et écrivons des lignes depuis le code. L'ORM vous permet de manipuler les lignes de la base de données comme des structures Rust plutôt que du SQL brut.

Modifiez temporairement src/main.rs pour exécuter un rapide test de création-et-lecture avant de démarrer le serveur. Remplacez le corps du Cli par un test ad hoc de l'ORM (conservez le #[rustango::main] du générateur de squelette ainsi que les déclarations mod en haut du fichier) :

mod blog;
mod models;
mod urls;
mod views;

use crate::blog::models::Post;
use rustango::sql::{FetcherPool, Pool};
use rustango::{Auto, Model};

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    let pool = Pool::connect(&std::env::var("DATABASE_URL")?).await?;

    // CREATE
    let mut p = Post {
        id: Auto::default(),
        title: "First post".into(),
        body: "Hello, world.".into(),
        status: "draft".into(),
        author_id: 1,
        published_at: Auto::default(),
        deleted_at: None,
    };
    p.save_pool(&pool).await?;
    println!("created post id = {}", p.id.get().copied().unwrap());

    // READ
    let posts = Post::objects().fetch(&pool).await?;
    for post in &posts {
        println!("- {}", post.title);
    }

    Ok(())
}

Ce qui se passe ici, en termes simples :

  • pool est le pool de connexions à la base de données partagé. Vous passez une référence à celui-ci (&pool) dans les appels de requête plutôt que d'ouvrir une nouvelle connexion chaque fois.
  • Les appels à la base de données sont asynchrones, donc chacun se termine par .await — cela met en pause jusqu'à ce que le résultat revienne, puis continue. Le ? après un .await signifie « si ceci a échoué, arrête et renvoie l'erreur ».
  • main renvoie un Result, le type succès-ou-erreur de Rust, ce qui explique pourquoi ? et le Ok(()) final fonctionnent.
  • Pour enregistrer une ligne, appelez .save_pool(&pool) sur celle-ci. Pour lire des lignes, construisez une requête avec Post::objects() et exécutez-la avec .fetch(&pool) — sans filtre, cela renvoie toutes les lignes de la table.
  • .fetch(…) provient du trait FetcherPool, c'est pourquoi les imports l'incluent. Sans cette ligne, la méthode n'existe pas et le compilateur vous le signale sans expliquer pourquoi.
  • Ce sont là les appels multi-backend, et tout ce qui précède compile sans modification sur les trois bases de données. Il existe aussi .save(&pool) et .fetch_on(&pool), qui prennent un sqlx::PgPool propre au pilote et n'existent que lorsque la feature postgres est activée. Préférez la paire multi-backend, sauf si vous visez délibérément une seule base de données. Voir le guide de l'ORM.

Exécutez-le :

cargo run

Vous devriez voir l'identifiant de votre nouvel article ainsi que les lignes lues en retour. Restaurez src/main.rs à sa forme de serveur générée par le squelette une fois que vous avez confirmé que cela fonctionne — l'étape suivante s'appuie sur cette forme.


Étape 11 : activer l'administration automatique

Rustango fournit une interface d'administration générée pour vos modèles — un back-office prêt à parcourir et modifier vos données. Sa mise en place tient en deux petites étapes : un utilitaire qui transforme un pool en routeur d'administration, et un seul appel .nest(...) pour le monter.

Ajoutez vous-même cet utilitaire dans src/urls.rs — le générateur de squelette ne le crée pas, car rien de ce qu'il génère ne l'appellerait. Le admin_prefix doit correspondre au chemin sous lequel vous l'imbriquerez à l'étape suivante (/admin) afin que les propres liens et actions de formulaire de l'administration se résolvent correctement :

use rustango::admin;
use rustango::sql::Pool;

pub fn admin_router(pool: Pool) -> Router {
    admin::Builder::new(pool)
        .title("Myblog Admin")
        .admin_prefix("/admin") // must match the `.nest("/admin", …)` below
        .build()
}

Builder::new accepte le pool de n'importe quel backend : cet utilitaire ne nomme donc aucun pilote et fonctionne sur les trois.

Ensuite, connectez un pool dans src/main.rs et imbriquez l'administration dans le routeur de l'API avant de la remettre au Cli. Conservez la ligne mod blog; de l'étape 7 — c'est elle qui enregistre votre modèle Post auprès de l'administration :

mod blog;
mod models;
mod urls;
mod views;

#[rustango::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let _ = dotenvy::dotenv();
    let pool = rustango::sql::Pool::connect(&std::env::var("DATABASE_URL")?).await?;

    let api = urls::api().nest("/admin", urls::admin_router(pool));

    rustango::manage::Cli::new()
        .api(api)
        .with_health() // /health + /ready endpoints
        .run()
        .await
}

Cli::new()...run() est le même dispatcheur unifié généré par le squelette — il continue de servir chaque cargo run -- <verb> ; vous n'avez fait qu'enrichir le routeur qu'il sert au moment de runserver.

Exécutez-le :

cargo run

Ouvrez http://localhost:8080/admin (sans barre oblique finale). Vous verrez l'accueil de l'administration avec un lien posts. Cliquez sur celui-ci pour voir votre article brouillon dans la liste, cliquez sur l'article pour ouvrir son formulaire d'édition, puis enregistrez. L'onglet de piste d'audit enregistre chaque écriture.


Étape 12 : construire l'API JSON

Un ViewSet expose un modèle comme une API REST avec des points de terminaison de liste, création, récupération, mise à jour et suppression — à partir d'une seule déclaration, sans écrire les routes à la main.

12a. Générer le ViewSet

Générez le fichier, puis renseignez les champs et comportements à exposer :

cargo run -- make:viewset PostViewSet --model Post

Modifiez src/post_view_set.rs :

use rustango::ViewSet;
use crate::blog::models::Post;

#[derive(ViewSet)]
#[viewset(
    model         = Post,
    fields        = "id, title, body, status, author_id, published_at",
    filter_fields = "author_id, status",
    search_fields = "title, body",
    ordering      = "-published_at",
    page_size     = 20,
)]
pub struct PostViewSet;

Enregistrez le nouveau module en ajoutant mod post_view_set; avec les autres déclarations mod en haut de src/main.rs.

12b. Monter les routes

Attachez les routes du ViewSet au routeur de l'application (la version Rustango d'un fichier urls.py ou d'un routes/api.php). Le routeur du ViewSet a besoin du pool de base de données, construisez-le donc dans src/main.rs, là où vit le pool, et fusionnez-le dans l'agrégateur urls::api() :

let api = urls::api()
    .nest("/admin", urls::admin_router(pool.clone()))
    .merge(crate::post_view_set::PostViewSet::router("/api/posts", pool));

rustango::manage::Cli::new()
    .api(api)
    .with_health()
    .run()
    .await

(urls::api() est l'agrégateur généré par le générateur de squelette ; manage startapp fusionne les routes de toute sous-application de la même façon.)

12c. Essayer les points de terminaison

Démarrez le serveur :

cargo run

Dans un autre terminal, interrogez l'API avec curl :

curl http://localhost:8080/api/posts                                    # list
curl -X POST http://localhost:8080/api/posts \
     -H "content-type: application/json" \
     -d '{"title":"From API","body":"Yo","status":"published","author_id":1}'
curl http://localhost:8080/api/posts/1                                   # retrieve
curl "http://localhost:8080/api/posts?search=API&ordering=-id"            # search + sort
curl "http://localhost:8080/api/posts?status__ne=draft"                   # lookup operator

Étape 13 : façonner la sortie avec un Serializer

Par défaut, le ViewSet renvoie tous les champs du modèle. Un Serializer vous permet de contrôler la forme de la réponse : masquer des champs internes, les renommer, ou en marquer certains en lecture seule. C'est le contrat entre vos modèles et le JSON que sert votre API.

cargo run -- make:serializer PostSerializer --model Post

Modifiez src/post_serializer.rs :

use rustango::{Auto, Serializer};
use crate::blog::models::Post;

#[derive(Serializer, serde::Deserialize, Default)]
#[serializer(model = Post)]
pub struct PostSerializer {
    pub id: Auto<i64>,
    pub title: String,

    #[serializer(source = "body")]                      // rename in API
    pub content: String,

    pub author_id: i64,                                 // writable: NOT NULL with no default

    #[serializer(read_only)]                            // include in GET, ignore in POST/PUT
    pub published_at: Auto<chrono::DateTime<chrono::Utc>>,
}

Dès qu'un serializer est attaché, ses champs constituent toute la surface d'écriture : tout ce qu'un client envoie et qui n'est pas listé ici est écarté avant l'INSERT. C'est pourquoi author_id y figure. Il est NOT NULL et sans valeur par défaut sur le modèle, donc l'omettre fait échouer toute création sur la contrainte NOT NULL. status peut rester en dehors, car le modèle lui donne default = "'draft'".

Le type de chaque champ du serializer reflète le champ correspondant du modèle, donc id et published_at conservent leur enveloppe Auto<…> héritée du modèle (un Auto<i64> se sérialise toujours en un simple entier JSON). Enregistrez ensuite le module en ajoutant mod post_serializer; avec les autres déclarations mod dans src/main.rs.

Branchez le serializer dans le ViewSet avec l'attribut serializer — les réponses de liste, de récupération et de création sont alors rendues via celui-ci (la projection de champs fields est alors contournée au profit de la forme du serializer) :

#[derive(ViewSet)]
#[viewset(
    model = Post,
    serializer = crate::post_serializer::PostSerializer,
    ordering = "-published_at",
)]
pub struct PostViewSet;

Ceci fonctionne à l'identique sur PostgreSQL, MySQL et SQLite. Les redéfinitions method / read_only / source / write_only s'appliquent toutes à la réponse, et les corps de requête sont eux aussi validés via le serializer : create / update exécutent sa validate() (par champ et inter-champs), renvoyant un 400 avec une carte d'erreurs par champ ({field: [messages]}) en cas d'échec, et les champs en lecture seule / calculés qu'un client tenterait de poster sont ignorés. (Remarque : les champs de serializer nested / many nécessitent que les lignes liées soient chargées via select_related ; sinon ils s'affichent avec leur valeur par défaut.) Voir le guide des ViewSets pour le comportement complet en entrée et en sortie.


Étape 14 : ajouter l'authentification JWT

Les JWT sont des jetons signés que vous remettez à un client après la connexion et que vous vérifiez à chaque requête, un schéma courant pour l'authentification d'API. Le module rustango::jwt de Rustango les émet et les vérifie (HS256), et il est actif par défaut — aucune feature supplémentaire à activer.

14a. Émettre un jeton à la connexion

Il s'agit de fragments, et non de fichiers autonomes : celui-ci se place à l'intérieur de votre gestionnaire de connexion dans src/views.rs, aux côtés des gestionnaires de l'étape 6.

Intégrez l'identifiant de l'utilisateur (le « sujet » du jeton) et toute revendication (claim) personnalisée, comme les rôles, dans un jeton signé, puis remettez-le au client :

use rustango::jwt::{encode, Claims};
use std::time::Duration;

// Derive the signing key from your session secret.
let secret = std::env::var("RUSTANGO_SESSION_SECRET")?.into_bytes();

let mut claims = Claims::new(user_id.to_string());   // subject = user id
claims.set("roles", vec!["editor"]);
let token = encode(&claims.ttl(Duration::from_secs(900)), &secret)?;

// Send `token` to the client (e.g. in the login response body).

14b. Vérifier le jeton à chaque requête

Ce code se place là où vous protégez une route — dans un gestionnaire protégé de src/views.rs, ou dans un extracteur axum / une couche middleware::from_fn si vous souhaitez l'appliquer à toute une sous-arborescence.

Décodez le jeton — ceci vérifie la signature et l'expiration — puis relisez les revendications (claims). S'il est absent ou invalide, rejetez la requête comme non autorisée :

use rustango::jwt::decode;

let claims = decode(&access_token, &secret)
    .map_err(|_| StatusCode::UNAUTHORIZED)?;

let user_id = claims.subject().ok_or(StatusCode::UNAUTHORIZED)?;
let roles: Vec<String> = claims.get("roles").unwrap_or_default();

14c. Cycle de vie accès + rafraîchissement

rustango::jwt émet des jetons uniques sans état. Pour le schéma complet — des jetons d'accès de courte durée, un jeton de rafraîchissement de longue durée dans un cookie HttpOnly, la rotation, et une liste noire de JTI pour la révocation — activez la feature tenancy et utilisez rustango::tenancy::jwt_lifecycle::JwtLifecycle, dont les méthodes issue_pair_with / verify_access / refresh gèrent la paire pour vous.


Étape 15 : ajouter le middleware de sécurité

Le middleware englobe chaque requête pour y ajouter un comportement transversal. Ce code se place dans src/main.rs : il remplace la ligne let api = ... de l'étape 11, afin que le routeur soit entièrement assemblé avant d'atteindre le Cli. Ici, vous empilez les identifiants de requête, la journalisation des accès, la limitation de débit, le CORS et les en-têtes de sécurité en une seule chaîne. Chaque .method(...) ajoute une couche ; l'ordre des appels détermine l'ordre de la pile. Voir le guide du middleware pour le catalogue complet des couches et les règles d'ordonnancement.

use rustango::security_headers::{SecurityHeadersLayer, SecurityHeadersRouterExt, CspBuilder};
use rustango::cors::{CorsLayer, CorsRouterExt};
use rustango::rate_limit::{RateLimitLayer, RateLimitRouterExt};
use rustango::access_log::{AccessLogLayer, AccessLogRouterExt};
use rustango::request_id::{RequestIdLayer, RequestIdRouterExt};
use rustango::health::health_router;
use std::time::Duration;

let app = urls::api()
    .nest("/admin", urls::admin_router(pool.clone()))
    .merge(crate::post_view_set::PostViewSet::router("/api/posts", pool.clone()))
    .merge(health_router(pool.clone()))                        // /health, /ready
    .request_id(RequestIdLayer::default())
    .access_log(AccessLogLayer::default())                      // PII-redacted
    .rate_limit(RateLimitLayer::per_ip(60, Duration::from_secs(60)))
    .cors(CorsLayer::new()
        .allow_origins(vec!["https://app.example.com"])
        .allow_methods(vec!["GET", "POST", "PUT", "PATCH", "DELETE"]))
    .security_headers(
        SecurityHeadersLayer::strict()
            .csp(CspBuilder::strict_starter().build()),
    );

Remettez le app fini au Cli exactement comme avant — rustango::manage::Cli::new().api(app).with_welcome().run().await — et chaque requête passe désormais par la pile complète de middleware.


Étape 16 : écrire des tests

Rustango inclut un client de test qui pilote votre routeur en process, ce qui vous permet de faire des assertions sur de vraies réponses HTTP sans démarrer de serveur ni toucher au réseau. Générez un fichier de test :

cargo run -- make:test PostSmoke      # generates tests/post_smoke.rs

Les générateurs make:* prennent un nom en PascalCase ; PostSmoke devient le fichier en snake_case tests/post_smoke.rs.

Modifiez tests/post_smoke.rs. Les tests d'intégration vivent dans une crate séparée, donc ils construisent le routeur testé directement à partir du ViewSet (le même appel router(...) que celui monté à l'étape 12b) :

use rustango::test_client::TestClient;
use myblog::post_view_set::PostViewSet;
use rustango::sql::Pool;
use serde_json::json;

async fn app() -> axum::Router {
    // Un test dans `tests/` est une crate séparée et n'exécute jamais
    // `main`, donc rien n'a chargé `.env` pour lui. Sans cette ligne,
    // `DATABASE_URL` n'est pas définie et les deux tests paniquent avant
    // d'atteindre la base de données.
    let _ = dotenvy::dotenv();

    let pool = Pool::connect(&std::env::var("DATABASE_URL").unwrap()).await.unwrap();
    PostViewSet::router("/api/posts", pool)
}

#[tokio::test]
async fn list_posts_returns_200() {
    let client = TestClient::new(app().await);
    let response = client.get("/api/posts").send().await;
    assert_eq!(response.status, 200);
    let v = response.json_value();
    assert!(v["results"].is_array());
}

#[tokio::test]
async fn create_post_returns_the_new_object() {
    let client = TestClient::new(app().await);
    let response = client.post("/api/posts")
        // Envoyez les champs du serializer. `status` est omis parce que le
        // serializer ne le liste pas : il serait de toute façon écarté —
        // c'est le `default = "'draft'"` du modèle qui le remplit.
        .json(&json!({
            "title": "Test",
            "content": "x",
            "author_id": 1,
        }))
        .send().await;
    assert_eq!(response.status, 201);
    let v: serde_json::Value = response.json();
    assert_eq!(v["title"], "Test");
}

Trois détails de cet extrait sont faciles à rater, et chacun produit un échec différent :

  • dotenvy::dotenv() — si vous l'omettez, les deux tests échouent, avant même qu'une requête ne soit émise.
  • content, et non body — le serializer accepte ici l'un ou l'autre, puisque source = "body" laisse le nom du modèle continuer de fonctionner en entrée, mais content est le nom que votre API publie réellement.
  • author_id — si vous l'omettez, seul le test de création échoue, avec une violation de contrainte NOT NULL remontée par la base de données. Le test de liste passe toujours, car une table vide est une page vide valide.

Pool est le pool multi-backend, et router accepte le pool de n'importe quel backend : ce fichier compile donc tel quel sur PostgreSQL, MySQL et SQLite.

Attention : les tests d'intégration dans tests/ ne peuvent faire use myblog::… que si la crate expose une cible de bibliothèque. Un squelette neuf n'est composé que d'un binaire (src/main.rs, sans src/lib.rs), donc ajoutez une simple ligne src/lib.rs qui réexporte les modules que vous voulez tester — pub mod models; pub mod post_view_set; pub mod urls; — et conservez les lignes mod …; correspondantes dans src/main.rs. (Si vous préférez ne pas ajouter de cible de bibliothèque, construisez plutôt le routeur entièrement en ligne dans le test, de la façon dont make:test génère sa fonction app().)

Exécutez les tests :

cargo test --test post_smoke

Étape 17 : exécuter la vérification système

Avant de déployer, exécutez le vérificateur intégré. Il signale les erreurs de configuration courantes (comme un RUSTANGO_SESSION_SECRET trop faible ou une base de données inaccessible) avant qu'elles ne se manifestent en production.

cargo run -- check --deploy

Dans votre environnement de développement local, vous verrez quelque chose comme :

running rustango system check (deploy mode)...
  [info]    6 models registered via inventory
  [info]    database reachable
  [info]    1 migration(s) on disk
  [info]    RUSTANGO_SESSION_SECRET length OK
  [info]    config tier resolved to `dev`
  [warning] RUSTANGO_ENV is unset — set to `prod` so config loaders pick the right tier
  [warning] DATABASE_URL points at localhost / 127.0.0.1 — verify this is intended in production
  [warning] RUSTANGO_APEX_DOMAIN is unset / `localhost` — set it for tenancy projects

(Le nombre exact de modèles/migrations dépend de votre projet.) Ces trois avertissements sont ceux attendus en environnement de développement. Dans une configuration de production — RUSTANGO_ENV=prod, un DATABASE_URL de base de données géré, un domaine apex défini — ils disparaissent et vous verrez all checks passed. Corrigez tout avertissement ou erreur restant avant de déployer en production.


Étape 18 : déployer en production

La façon de déployer dépend de votre plateforme (Fly, Railway, Kubernetes, ECS nu, et ainsi de suite). Les étapes côté framework sont les mêmes partout ; l'option --release construit un binaire optimisé :

# 1. Set production env
export RUSTANGO_ENV=prod
export DATABASE_URL=postgres://prod-host/myblog
export RUSTANGO_SESSION_SECRET=$(openssl rand -base64 32)

# 2. Run migrations
cargo run --release -- migrate

# 3. Audit
cargo run --release -- check --deploy

# 4. Build binary
cargo build --release

# 5. Run with a process supervisor (systemd / docker / k8s)
./target/release/myblog

Assurez-vous que votre proxy inverse :

  • Termine le HTTPS
  • Transmet X-Forwarded-For pour des IP précises dans AccessLogLayer
  • Transmet X-Forwarded-Host, X-Forwarded-Proto
  • Utilise axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()) afin que ConnectInfo soit renseigné pour la limitation de débit et le filtrage par IP

Où aller ensuite

SujetDoc
Version exécutable de ce guideexamples/getting_started_blog
Chaque sous-commande managedocs/manage.md
Recueil de recettes ORM (filtres avancés, agrégations, M2M, suppression douce)docs/orm.md
Middleware (le catalogue complet des couches + ordonnancement)docs/middleware.md
Benchmarks de performance (vs Go)docs/benchmarks.md
Conventions d'API (nommage, patrons de construction, feature gates)docs/api-conventions.md
Fonctionnalités de sécurité en détaildocs/security.md
Multi-tenancyREADME — section Multi-tenancy
Documentation de l'APIhttps://docs.rs/rustango

Si vous rencontrez quelque chose qui ne fonctionne pas ou qui n'est pas clair, ouvrez un ticket.