05-extending-rustwarden.md 24245 octets

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

  1. Créer le fichier crates/rustwarden-core/src/services/my_service.rs.
  2. Définir une struct unitaire et des méthodes async qui prennent &DatabaseConnection.
  3. Re-exporter le module dans crates/rustwarden-core/src/services/mod.rs.
  4. 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 DatabaseConnection est passée par référence à chaque appel.
  • Ne pas appeler unwrap() ou expect() — utiliser ? et AppError.
  • 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

  1. Créer le handler dans un module approprié (nouveau fichier ou dans auth/handlers.rs si lié à l'auth).
  2. Utiliser les extracteurs Axum standards : State, Extension, Json, Path.
  3. Retourner Result<Json<MyResponse>, AppError> (API) ou Result<Html<String>, AppError> (HTML).
  4. 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

ExtracteurSourceUsage
State<DatabaseConnection>Pool SeaORM partagéAccès à la base de données
Extension<AuthUser>Middleware d'authentificationUtilisateur authentifié courant
Json<T>Corps de la requêteDésérialisation d'un payload JSON
Path<T>Segment d'URLParamètre de route (:id)
axum_extra::extract::CookieJarCookies de la requêteLecture des cookies HTTP

Pièges à éviter

  • Ne pas déclarer un endpoint sans implémenter son handler — pas de todo!() ou StatusCode::NOT_IMPLEMENTED en production (règle constitution.md).
  • Les routes ajoutées via merge_routes sont sans le middleware d'authentification par défaut. Pour protéger une route HTML, appeler register_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

  1. Écrire une fonction async my_middleware(req: Request, next: Next) -> Response.
  2. L'appliquer sur un Router via .layer(middleware::from_fn(my_middleware)).
  3. 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 via State ou Extension depuis 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

  1. Créer le fichier de migration dans crates/rustwarden-core/src/migrations/.
  2. Nommer le fichier selon la convention : m{AAAAMMJJ}_{NNNNNN}_{description}.rs.
  3. Implémenter MigrationName et MigrationTrait (méthodes up et down).
  4. Enregistrer la migration dans crates/rustwarden-core/src/migrations/mod.rs.
  5. Lancer cargo run --bin migrate pour 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 down pour permettre les rollbacks, même si elle se contente de DROP TABLE.
  • Utiliser if_not_exists() dans up pour 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

  1. Créer crates/rustwarden-core/src/models/project.rs.
  2. Dériver DeriveEntityModel et déclarer la struct Model avec les champs.
  3. Déclarer les relations via DeriveRelation.
  4. 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 uuid SeaORM doit être Uuid (crate uuid), pas String.
  • Les colonnes timestamp_with_time_zone mappent vers DateTime<Utc> avec la crate chrono.
  • Option<T> pour les colonnes nullable, T pour les colonnes NOT NULL.

Recette 6 — Ajouter une permission

Objectif : déclarer une nouvelle capacité métier vérifiable via PermissionService.

Étapes

  1. Créer une migration qui insère la permission dans la table permissions.
  2. Optionnel : l'assigner immédiatement au rôle admin.
  3. 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 NOTHING pour 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 LoginTemplate dans rustwarden-ui avant 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

  1. Localiser un fichier de traductions existant dans le projet (chercher locales/ ou le répertoire configuré via locale_dir).
  2. Créer locales/it.yaml (ou le répertoire correspondant) en copiant fr.yaml.
  3. Traduire les valeurs.
  4. 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.yaml pour "it", pas italian.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

  1. Lire crates/rustwarden-core/src/services/oauth/ pour comprendre le pattern des providers existants.
  2. Créer oauth/my_provider.rs en suivant le même pattern (configuration, échange de code, récupération du profil utilisateur).
  3. Implémenter la conversion du profil provider vers les champs OAuthAccount (email, external_id, provider_name).
  4. 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_secret en dur — le lire depuis une variable d'environnement via config/oauth.rs.
  • Valider le state parameter pour éviter les attaques CSRF sur le callback OAuth.
  • Les secrets OAuth stockés en base (oauth_accounts) sont chiffrés avec CryptoService (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().await une 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 — utiliser tracing::error! et continuer la boucle.
  • Passer la DatabaseConnection enveloppée dans Arc (pas de clone() du pool à chaque tick).
  • Si la tâche doit pouvoir être arrêtée proprement, utiliser un tokio::sync::CancellationToken partagé 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.