05 — Étendre Rustwarden
Ce guide recense les points d'extension du framework sous forme de recettes autonomes. Chaque recette suit le format : objectif → étapes → exemple de code → pièges à éviter.
Quel point d'extension utiliser ?
flowchart TD
Q[Quel est mon besoin ?] --> A{Réagir à un événement\ndu framework ?}
A -->|oui| HOOK[Hook\nRustwardenHooks\n→ recette 1.1]
A -->|non| B{Intercepter ou\ntransformer des requêtes HTTP ?}
B -->|oui| MW[Middleware\n→ recette 3]
B -->|non| C{Nouvelle route API\nou page ?}
C -->|oui| ROUTE[Handler + route\n→ recette 2]
C -->|non| D{Nouvelle entité\nen base ?}
D -->|oui| MIG[Migration + entité SeaORM\n→ recettes 4 et 5]
D -->|non| E{Nouvelle logique métier\nréutilisable ?}
E -->|oui| SVC[Service\n→ recette 1]
E -->|non| F{Modifier l'interface HTML ?}
F -->|oui| TPL[Override templates\n→ recette 7]
F -->|non| G{Nouvelle langue ?}
G -->|oui| I18N[Fichier YAML locales\n→ recette 8]
G -->|non| MISC[Voir recettes OAuth, background tasks]
Recette 1 — Ajouter un service métier
Objectif : encapsuler de la logique métier réutilisable (par exemple : gestion de projets, facturation) en suivant le pattern des services du core.
Étapes
- Créer le fichier
crates/rustwarden-core/src/services/my_service.rs. - Définir une struct unitaire et des méthodes
asyncqui prennent&DatabaseConnection. - Re-exporter le module dans
crates/rustwarden-core/src/services/mod.rs. - Utiliser le service depuis un handler via
State<DatabaseConnection>.
Exemple
// crates/rustwarden-core/src/services/project_service.rs use crate::error::AppError; use sea_orm::DatabaseConnection; use uuid::Uuid; /// Service de gestion des projets. pub struct ProjectService; impl ProjectService { /// Crée un nouveau projet et retourne son identifiant. pub async fn create( db: &DatabaseConnection, owner_id: Uuid, name: &str, ) -> Result<Uuid, AppError> { // Insertion SeaORM (voir recette 5 pour le modèle) let project_id = Uuid::new_v4(); // project::ActiveModel { ... }.insert(db).await?; Ok(project_id) } /// Récupère tous les projets d'un utilisateur. pub async fn list_for_user( db: &DatabaseConnection, user_id: Uuid, ) -> Result<Vec<Uuid>, AppError> { // project::Entity::find() // .filter(project::Column::OwnerId.eq(user_id)) // .all(db).await? Ok(vec![]) } }
Enregistrement dans services/mod.rs :
/// Service de gestion des projets. pub mod project_service;
Pièges à éviter
- Ne pas stocker d'état mutable dans la struct du service. Les services sont sans état ; la
DatabaseConnectionest passée par référence à chaque appel. - Ne pas appeler
unwrap()ouexpect()— utiliser?etAppError. - Ne pas écrire de SQL brut — tout passe par SeaORM (règle
constitution.md).
Recette 2 — Ajouter une route API
Objectif : exposer un nouvel endpoint HTTP (REST ou HTML) dans l'application.
Étapes
- Créer le handler dans un module approprié (nouveau fichier ou dans
auth/handlers.rssi lié à l'auth). - Utiliser les extracteurs Axum standards :
State,Extension,Json,Path. - Retourner
Result<Json<MyResponse>, AppError>(API) ouResult<Html<String>, AppError>(HTML). - Enregistrer la route dans l'application via
.merge_routes(router)sur le builder.
Exemple — endpoint JSON
// src/handlers/project_handler.rs use axum::{ extract::{Path, State}, Extension, Json, }; use sea_orm::DatabaseConnection; use serde::Serialize; use uuid::Uuid; use crate::auth::extractors::AuthUser; use crate::error::AppError; use crate::services::project_service::ProjectService; #[derive(Serialize)] pub struct ProjectListResponse { pub projects: Vec<Uuid>, } pub async fn list_projects( State(db): State<DatabaseConnection>, Extension(auth_user): Extension<AuthUser>, ) -> Result<Json<ProjectListResponse>, AppError> { let projects = ProjectService::list_for_user(&db, auth_user.id).await?; Ok(Json(ProjectListResponse { projects })) }
Enregistrement du router dans main.rs ou dans la logique de montage :
use axum::routing::get; use axum::Router; let project_routes = Router::new() .route("/api/v1/projects", get(list_projects)); RustwardenApp::builder() .from_env() .merge_routes(project_routes) // <-- injecte les routes dans le routeur principal .hooks(MyHooks) .build() .await? .run() .await
Extracteurs disponibles dans les handlers
| Extracteur | Source | Usage |
|---|---|---|
State<DatabaseConnection> | Pool SeaORM partagé | Accès à la base de données |
Extension<AuthUser> | Middleware d'authentification | Utilisateur authentifié courant |
Json<T> | Corps de la requête | Désérialisation d'un payload JSON |
Path<T> | Segment d'URL | Paramètre de route (:id) |
axum_extra::extract::CookieJar | Cookies de la requête | Lecture des cookies HTTP |
Pièges à éviter
- Ne pas déclarer un endpoint sans implémenter son handler — pas de
todo!()ouStatusCode::NOT_IMPLEMENTEDen production (règleconstitution.md). - Les routes ajoutées via
merge_routessont sans le middleware d'authentification par défaut. Pour protéger une route HTML, appelerregister_protected_prefix("/mon-prefix")sur le builder.
Recette 3 — Ajouter un middleware
Objectif : intercepter toutes les requêtes (ou un sous-ensemble) pour ajouter un comportement transversal : logging, vérification d'en-tête, injection de contexte.
Étapes
- Écrire une fonction async
my_middleware(req: Request, next: Next) -> Response. - L'appliquer sur un
Routervia.layer(middleware::from_fn(my_middleware)). - Pour accéder à l'état applicatif, utiliser
middleware::from_fn_with_state(state, my_middleware).
Exemple — middleware de logging de requêtes
use axum::{ extract::Request, middleware::Next, response::Response, }; pub async fn request_logger(req: Request, next: Next) -> Response { let method = req.method().clone(); let uri = req.uri().clone(); tracing::info!("{} {}", method, uri); let response = next.run(req).await; tracing::info!("{} {} → {}", method, uri, response.status()); response }
Application sur un groupe de routes :
use axum::middleware; let protected_routes = Router::new() .route("/api/v1/projects", get(list_projects)) .layer(middleware::from_fn(request_logger)); RustwardenApp::builder() .merge_routes(protected_routes) // ...
Exemple — middleware avec accès à l'état
use axum::{extract::{Request, State}, middleware::Next, response::Response}; use sea_orm::DatabaseConnection; use std::sync::Arc; pub async fn rate_check_middleware( State(db): State<DatabaseConnection>, req: Request, next: Next, ) -> Response { // Vérification custom... next.run(req).await } // Enregistrement : let router = Router::new() .route("/api/v1/heavy", get(heavy_handler)) .layer(middleware::from_fn_with_state(db.clone(), rate_check_middleware));
Pièges à éviter
- Les middlewares appliqués via
.layer()s'exécutent de l'extérieur vers l'intérieur dans Axum. L'ordre d'empilement est inverse à l'ordre d'ajout. - Ne pas cloner la
DatabaseConnectionà chaque requête — la passer viaStateouExtensiondepuis le builder.
Recette 4 — Ajouter une migration SeaORM
Objectif : créer une nouvelle table ou modifier le schéma existant de façon versionnée et réversible.
Étapes
- Créer le fichier de migration dans
crates/rustwarden-core/src/migrations/. - Nommer le fichier selon la convention :
m{AAAAMMJJ}_{NNNNNN}_{description}.rs. - Implémenter
MigrationNameetMigrationTrait(méthodesupetdown). - Enregistrer la migration dans
crates/rustwarden-core/src/migrations/mod.rs. - Lancer
cargo run --bin migratepour appliquer.
Exemple — créer une table projects
// crates/rustwarden-core/src/migrations/m20260601_000010_create_projects.rs use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .create_table( Table::create() .table(Projects::Table) .if_not_exists() .col(ColumnDef::new(Projects::Id).uuid().not_null().primary_key()) .col(ColumnDef::new(Projects::Name).string().string_len(128).not_null()) .col(ColumnDef::new(Projects::OwnerId).uuid().not_null()) .col( ColumnDef::new(Projects::CreatedAt) .timestamp_with_time_zone() .not_null() .default(Expr::current_timestamp()), ) .foreign_key( ForeignKey::create() .name("fk_projects_owner_id") .from(Projects::Table, Projects::OwnerId) .to(Users::Table, Users::Id) .on_delete(ForeignKeyAction::Cascade), ) .to_owned(), ) .await } async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .drop_table(Table::drop().table(Projects::Table).to_owned()) .await } } #[derive(DeriveIden)] enum Projects { Table, Id, Name, OwnerId, CreatedAt, } #[derive(DeriveIden)] enum Users { Table, Id, }
Enregistrement dans migrations/mod.rs :
// mod.rs — liste des migrations dans l'ordre chronologique pub use sea_orm_migration::MigratorTrait; pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec<Box<dyn MigrationTrait>> { vec![ // ... migrations existantes ... Box::new(m20260601_000010_create_projects::Migration), ] } }
Exécution :
cargo run --bin migrate
Générer l'entité SeaORM depuis la table créée
Après avoir appliqué la migration, générer l'entité :
sea-orm-cli generate entity \ --database-url "$DATABASE_URL" \ --output-dir crates/rustwarden-core/src/models \ --tables projects
Pièges à éviter
- Toujours implémenter
downpour permettre les rollbacks, même si elle se contente deDROP TABLE. - Utiliser
if_not_exists()dansuppour que la migration soit idempotente en cas de re-run sur un schéma partiellement migré. - Ne jamais modifier une migration déjà appliquée en production — créer une nouvelle migration à la place.
Recette 5 — Ajouter une entité SeaORM
Objectif : créer ou modifier manuellement un modèle SeaORM correspondant à une table existante.
Étapes
- Créer
crates/rustwarden-core/src/models/project.rs. - Dériver
DeriveEntityModelet déclarer la structModelavec les champs. - Déclarer les relations via
DeriveRelation. - Re-exporter dans
crates/rustwarden-core/src/models/mod.rs.
Exemple
// crates/rustwarden-core/src/models/project.rs use chrono::{DateTime, Utc}; use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Entité projet — table `projects`. #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] #[sea_orm(table_name = "projects")] pub struct Model { #[sea_orm(primary_key)] pub id: Uuid, pub name: String, pub owner_id: Uuid, pub created_at: DateTime<Utc>, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm( belongs_to = "crate::models::user::Entity", from = "Column::OwnerId", to = "crate::models::user::Column::Id", on_delete = "Cascade" )] Owner, } impl Related<crate::models::user::Entity> for Entity { fn to() -> RelationDef { Relation::Owner.def() } } impl ActiveModelBehavior for ActiveModel {}
Re-export dans models/mod.rs :
/// Entité projet. pub mod project;
Pièges à éviter
- Le type Rust d'une colonne
uuidSeaORM doit êtreUuid(crateuuid), pasString. - Les colonnes
timestamp_with_time_zonemappent versDateTime<Utc>avec la cratechrono. Option<T>pour les colonnes nullable,Tpour les colonnesNOT NULL.
Recette 6 — Ajouter une permission
Objectif : déclarer une nouvelle capacité métier vérifiable via PermissionService.
Étapes
- Créer une migration qui insère la permission dans la table
permissions. - Optionnel : l'assigner immédiatement au rôle
admin. - Vérifier la permission dans le handler avec
PermissionService::get_permissions_for_user.
Exemple — migration d'insertion
// Dans la méthode up() d'une migration manager .get_connection() .execute_unprepared( r#" INSERT INTO permissions (id, name, description, category, created_at) VALUES (gen_random_uuid(), 'projects.delete', 'Supprimer des projets', 'projects', NOW()) ON CONFLICT (name) DO NOTHING; INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r, permissions p WHERE r.name = 'admin' AND p.name = 'projects.delete' ON CONFLICT DO NOTHING; "#, ) .await?;
Vérification dans un handler
use crate::services::permission_service::PermissionService; use crate::auth::extractors::AuthUser; use crate::error::AppError; pub async fn delete_project( State(db): State<DatabaseConnection>, Extension(auth_user): Extension<AuthUser>, Path(project_id): Path<Uuid>, ) -> Result<(), AppError> { let perms = PermissionService::get_permissions_for_user(&db, auth_user.id).await?; if !perms.contains(&"projects.delete".to_string()) { return Err(AppError::Forbidden); } // ... suppression Ok(()) }
Pièges à éviter
- Utiliser
ON CONFLICT (name) DO NOTHINGpour que la migration soit idempotente. - Ne pas coder en dur le nom de la permission dans plusieurs handlers — définir une constante :
const PERM_PROJECTS_DELETE: &str = "projects.delete";
Recette 7 — Personnaliser les templates Askama
Objectif : modifier ou remplacer les pages HTML rendues par rustwarden-ui (login, profil, panneau admin) sans toucher au code du crate UI.
Structure des templates existants
Les templates se trouvent dans crates/rustwarden-ui/templates/. Ils utilisent Askama 0.15 avec l'héritage de templates ({% extends "base.html" %}).
Méthode : répertoire de templates alternatif
Le builder expose locale_dir pour les traductions. Pour les templates Askama, la surcharge se fait en configurant Askama pour chercher dans un répertoire alternatif au moment de la compilation via askama.toml :
# askama.toml à la racine du projet consommateur [general] dirs = ["templates", "crates/rustwarden-ui/templates"]
Les templates dans le premier répertoire templates/ ont la priorité. Copier le template à surcharger et le modifier :
my-app/ templates/ auth/ login.html ← surcharge le template du crate UI
Exemple — surcharger la page de login
{# templates/auth/login.html #} {% extends "base.html" %} {% block title %}Connexion — Mon Application{% endblock %} {% block content %} <div class="my-custom-login-wrapper"> <img src="/static/logo.png" alt="Logo" class="logo" /> {# Inclure le formulaire original ou le réécrire #} <form method="POST" action="/login"> <!-- ... --> </form> </div> {% endblock %}
Pièges à éviter
- Askama compile les templates au moment du build Rust, pas à l'exécution. Un template mal formé est une erreur de compilation, pas une erreur runtime.
- Les variables disponibles dans un template sont celles de la struct Askama correspondante. Vérifier la struct
LoginTemplatedansrustwarden-uiavant de modifier le template. - Ne pas supprimer les blocs CSRF requis par le framework — les formulaires POST doivent inclure le token CSRF.
Recette 8 — Ajouter une langue (i18n)
Objectif : rendre l'interface disponible dans une nouvelle langue sans modifier le code Rust.
Comment fonctionne l'i18n
Le framework charge les fichiers YAML de traductions au démarrage depuis les répertoires configurés. Le chargement est dynamique (runtime), pas compilé. L'extracteur Locale détecte la langue depuis le cookie locale ou l'en-tête Accept-Language.
Les langues actuellement supportées par défaut : fr, en, de, es (défini dans app.rs:166).
Étapes
- Localiser un fichier de traductions existant dans le projet (chercher
locales/ou le répertoire configuré vialocale_dir). - Créer
locales/it.yaml(ou le répertoire correspondant) en copiantfr.yaml. - Traduire les valeurs.
- Ajouter
"it"à la liste des locales supportées dans le builder.
Exemple
# Copier le fichier de référence cp locales/fr.yaml locales/it.yaml
# locales/it.yaml auth: login: "Accedi" logout: "Disconnetti" username: "Nome utente" password: "Password" login_button: "Accedi" invalid_credentials: "Credenziali non valide" # ... traduire toutes les clés
Enregistrement dans le builder :
RustwardenApp::builder() .locale_dir("locales/") .supported_locales(vec!["fr".into(), "en".into(), "de".into(), "es".into(), "it".into()]) .default_locale("fr") // ...
Pièges à éviter
- Une clé manquante dans le fichier YAML fait tomber en fallback sur la locale par défaut, pas sur une erreur. Vérifier l'exhaustivité des clés en comparant avec
fr.yaml. - Les noms de fichiers doivent correspondre exactement aux codes de locale (
it.yamlpour"it", pasitalian.yaml). - Le rechargement des traductions n'est pas dynamique — un redémarrage du serveur est nécessaire après ajout d'un fichier.
Recette 9 — Implémenter un provider OAuth custom
Objectif : ajouter un fournisseur OAuth2/OIDC non supporté nativement (Google, GitHub, Microsoft, Discord le sont).
Structure existante
Les providers OAuth sont dans crates/rustwarden-core/src/services/oauth/. La feature Cargo oauth doit être activée.
Étapes
- Lire
crates/rustwarden-core/src/services/oauth/pour comprendre le pattern des providers existants. - Créer
oauth/my_provider.rsen suivant le même pattern (configuration, échange de code, récupération du profil utilisateur). - Implémenter la conversion du profil provider vers les champs
OAuthAccount(email, external_id, provider_name). - Enregistrer le provider dans
oauth/mod.rs.
Squelette d'un provider custom
// crates/rustwarden-core/src/services/oauth/my_provider.rs use crate::error::AppError; pub struct MyProviderConfig { pub client_id: String, pub client_secret: String, pub redirect_uri: String, } pub struct MyProviderProfile { pub id: String, pub email: String, pub name: String, } impl MyProviderConfig { /// Construit l'URL d'autorisation pour rediriger l'utilisateur. pub fn authorization_url(&self) -> String { format!( "https://my-provider.example.com/oauth/authorize\ ?client_id={}&redirect_uri={}&response_type=code&scope=email", self.client_id, self.redirect_uri ) } /// Échange le code d'autorisation contre un profil utilisateur. pub async fn exchange_code(&self, code: &str) -> Result<MyProviderProfile, AppError> { // 1. POST vers le token endpoint du provider // 2. GET vers le userinfo endpoint avec le bearer token // 3. Parser et retourner MyProviderProfile todo!() } }
Pièges à éviter
- Ne pas stocker le
client_secreten dur — le lire depuis une variable d'environnement viaconfig/oauth.rs. - Valider le
stateparameter pour éviter les attaques CSRF sur le callback OAuth. - Les secrets OAuth stockés en base (
oauth_accounts) sont chiffrés avecCryptoService(AES-256-GCM). Ne pas stocker de tokens en clair.
Recette 10 — Ajouter une background task
Objectif : exécuter une tâche périodique en arrière-plan (nettoyage, synchronisation, reporting).
Pattern existant
Le pattern est établi par EmailQueueProcessor :
pub struct EmailQueueProcessor { db: Arc<DatabaseConnection>, email_service: Arc<EmailService>, config: EmailConfig, } impl EmailQueueProcessor { pub fn start(self) { tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(30)); interval.tick().await; // Skip first tick loop { interval.tick().await; if let Err(e) = self.process_queue().await { tracing::error!("Email queue error: {}", e); } } }); } }
Exemple — tâche de nettoyage de projets expirés
// crates/rustwarden-core/src/tasks/project_cleanup.rs use crate::services::project_service::ProjectService; use sea_orm::DatabaseConnection; use std::sync::Arc; use tokio::time::Duration; /// Supprime les projets archivés depuis plus de 90 jours. pub struct ProjectCleanupTask { db: Arc<DatabaseConnection>, } impl ProjectCleanupTask { pub fn new(db: Arc<DatabaseConnection>) -> Self { Self { db } } /// Démarre la tâche en arrière-plan. /// S'exécute toutes les 24 heures. pub fn start(self) { tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(86_400)); interval.tick().await; // Skip first tick loop { interval.tick().await; match ProjectService::cleanup_archived(&self.db).await { Ok(count) => { if count > 0 { tracing::info!("ProjectCleanup: {} projets supprimés", count); } } Err(e) => tracing::error!("ProjectCleanup error: {}", e), } } }); } }
Démarrage depuis main.rs après la construction de l'application :
let app = RustwardenApp::builder() .from_env() .build() .await?; // Démarrer la tâche de fond let db = Arc::new(app.database().clone()); ProjectCleanupTask::new(db).start(); app.run().await
Pièges à éviter
- Appeler
interval.tick().awaitune première fois avant la boucle pour éviter une exécution immédiate au démarrage (le premier tick est instantané). - Ne pas
panic!dans une background task — utilisertracing::error!et continuer la boucle. - Passer la
DatabaseConnectionenveloppée dansArc(pas declone()du pool à chaque tick). - Si la tâche doit pouvoir être arrêtée proprement, utiliser un
tokio::sync::CancellationTokenpartagé avec le signal de shutdown du serveur.
Désactiver les background tasks en test
RustwardenApp::builder() .without_background_tasks() // désactive email queue, JWT cleanup, etc. .build() .await?
Les tâches custom démarrées manuellement (comme ProjectCleanupTask) ne sont pas concernées par ce flag — les conditionner explicitement en fonction de l'environnement.
GitRust