Ajouter un service métier dans gitrust-core

Ce guide couvre la création d'un nouveau service dans crates/gitrust-core/src/services/, en respectant les conventions du projet.

Pré-requis

Convention générale

Tous les services gitrust-core suivent ce patron uniforme :

Handler (axum) → Service (logique métier) → SeaORM (base de données)

Règles structurelles :

  • Les services sont des structs sans état (pas de champs, uniquement des méthodes async fn associées).
  • Le premier paramètre de chaque méthode est toujours db: &DatabaseConnection.
  • Le type de retour est toujours Result<T, GitrustError>.
  • Aucun unwrap() / expect() / panic!() en production.
  • Aucun SQL brut — tout passe par SeaORM.

Créer le fichier du service

Créez crates/gitrust-core/src/services/mon_service.rs :

use sea_orm::{
    ActiveModelTrait, ActiveValue::Set, DatabaseConnection, EntityTrait,
};
use uuid::Uuid;

use crate::{
    dto::mon_dto::{CreateMonInput, MonOutput},
    error::GitrustError,
    models::mon_model,
};

pub struct MonService;

impl MonService {
    /// Crée un nouvel élément.
    ///
    /// # Erreurs
    /// - `GitrustError::Validation` si le nom est vide ou trop long.
    /// - `GitrustError::Conflict` si un élément avec le même nom existe déjà.
    /// - `GitrustError::Database` pour toute erreur SeaORM.
    pub async fn create(
        db: &DatabaseConnection,
        owner_id: Uuid,
        input: CreateMonInput,
    ) -> Result<mon_model::Model, GitrustError> {
        // 1. Validation aux frontières
        if input.name.is_empty() || input.name.len() > 64 {
            return Err(GitrustError::Validation(
                "Le nom doit comporter entre 1 et 64 caractères".into(),
            ));
        }

        // 2. Vérification unicité
        let existing = mon_model::Entity::find()
            .filter(mon_model::Column::OwnerId.eq(owner_id))
            .filter(mon_model::Column::Name.eq(&input.name))
            .one(db)
            .await
            .map_err(GitrustError::Database)?;

        if existing.is_some() {
            return Err(GitrustError::Conflict(format!(
                "Un élément nommé '{}' existe déjà",
                input.name
            )));
        }

        // 3. Insertion
        let now = chrono::Utc::now();
        let model = mon_model::ActiveModel {
            id: Set(Uuid::new_v4()),
            owner_id: Set(owner_id),
            name: Set(input.name),
            description: Set(input.description),
            created_at: Set(now),
            updated_at: Set(now),
        };

        model.insert(db).await.map_err(GitrustError::Database)
    }

    /// Liste les éléments d'un propriétaire.
    pub async fn list_by_owner(
        db: &DatabaseConnection,
        owner_id: Uuid,
    ) -> Result<Vec<mon_model::Model>, GitrustError> {
        mon_model::Entity::find()
            .filter(mon_model::Column::OwnerId.eq(owner_id))
            .order_by_asc(mon_model::Column::Name)
            .all(db)
            .await
            .map_err(GitrustError::Database)
    }

    /// Trouve un élément par ID et vérifie l'appartenance.
    ///
    /// # Erreurs
    /// - `GitrustError::NotFound` si l'ID est inconnu.
    /// - `GitrustError::Forbidden` si `owner_id` ne correspond pas (anti-IDOR).
    pub async fn find_by_id(
        db: &DatabaseConnection,
        id: Uuid,
        owner_id: Uuid,
    ) -> Result<mon_model::Model, GitrustError> {
        let model = mon_model::Entity::find_by_id(id)
            .one(db)
            .await
            .map_err(GitrustError::Database)?
            .ok_or_else(|| GitrustError::NotFound("Élément introuvable".into()))?;

        // Anti-IDOR : vérification d'ownership systématique
        if model.owner_id != owner_id {
            return Err(GitrustError::Forbidden);
        }

        Ok(model)
    }

    /// Supprime un élément (vérifie l'ownership).
    pub async fn delete(
        db: &DatabaseConnection,
        id: Uuid,
        owner_id: Uuid,
    ) -> Result<(), GitrustError> {
        let model = Self::find_by_id(db, id, owner_id).await?;
        mon_model::Entity::delete_by_id(model.id)
            .exec(db)
            .await
            .map_err(GitrustError::Database)?;
        Ok(())
    }
}

Déclarer le module dans services/mod.rs

Ouvrez crates/gitrust-core/src/services/mod.rs et ajoutez :

pub mod mon_service;

Injecter le service dans un handler via State

Les services gitrust ne sont pas des structs instanciées — leurs méthodes sont associées (pas de self). Vous n'avez pas besoin de les injecter dans le State d'Axum. Il suffit d'appeler la méthode statique depuis le handler :

use gitrust_core::services::mon_service::MonService;

pub async fn list_handler(
    State(db): State<DatabaseConnection>,
    user: AuthUser,
) -> Result<impl IntoResponse, AppError> {
    let items = MonService::list_by_owner(&db, user.user_id).await?;
    Ok(MonListTemplate { items, username: user.username, is_admin: user.has_role("admin"), current_path: "/mon-chemin".into() })
}

Écrire les tests unitaires

Gitrust impose des tests sur vraie DB (pas de mocks) pour la couche persistance. Utilisez une base SQLite in-memory pour les tests unitaires rapides, ou testcontainers PostgreSQL pour les tests qui dépendent de comportements PG spécifiques (contraintes, index partiels, etc.).

#[cfg(test)]
mod tests {
    use super::*;
    use sea_orm::{Database, DatabaseConnection};

    async fn setup_db() -> DatabaseConnection {
        let db = Database::connect("sqlite::memory:").await.unwrap();
        // Appliquer les migrations nécessaires
        crate::migrations::run_migrations(&db).await.unwrap();
        db
    }

    #[tokio::test]
    async fn create_inserts_model() {
        let db = setup_db().await;
        let owner = Uuid::new_v4();

        let result = MonService::create(
            &db,
            owner,
            CreateMonInput {
                name: "mon-element".into(),
                description: None,
            },
        )
        .await;

        assert!(result.is_ok());
        let model = result.unwrap();
        assert_eq!(model.name, "mon-element");
        assert_eq!(model.owner_id, owner);
    }

    #[tokio::test]
    async fn create_rejects_empty_name() {
        let db = setup_db().await;
        let err = MonService::create(
            &db,
            Uuid::new_v4(),
            CreateMonInput { name: "".into(), description: None },
        )
        .await
        .unwrap_err();

        assert!(matches!(err, GitrustError::Validation(_)));
    }

    #[tokio::test]
    async fn find_by_id_rejects_wrong_owner() {
        let db = setup_db().await;
        let owner = Uuid::new_v4();
        let other = Uuid::new_v4();

        let model = MonService::create(
            &db,
            owner,
            CreateMonInput { name: "priv".into(), description: None },
        )
        .await
        .unwrap();

        // Un autre utilisateur ne doit pas accéder à cet élément
        let err = MonService::find_by_id(&db, model.id, other).await.unwrap_err();
        assert!(matches!(err, GitrustError::Forbidden));
    }
}

Gestion des erreurs — variantes GitrustError

Variante Code HTTP Quand l'utiliser
GitrustError::NotFound(msg) 404 Ressource introuvable par ID
GitrustError::Validation(msg) 400 Input utilisateur invalide
GitrustError::Forbidden 403 Ownership non vérifiée (anti-IDOR)
GitrustError::Conflict(msg) 409 Contrainte d'unicité violée
GitrustError::Database(err) 500 Erreur SeaORM remontée telle quelle

N'utilisez jamais GitrustError::Database pour une erreur de validation — la distinction est importante pour les messages d'erreur utilisateur.

Voir aussi