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
- Lecture de
reference/services-api-interne.mdpour comprendre les patterns existants. - Migration de base de données créée si nécessaire (voir
ajouter-migration-db.md).
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 fnassocié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.
GitRust