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 :
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 :
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.