Infrastructure - Couche Infrastructure (Adapters)

La couche Infrastructure implémente les détails techniques : accès base de données, exposition HTTP, gestion fichiers.

Principe : Infrastructure dépend de Application et Domain. Implémente les ports (traits) définis par Application.

Structure

infrastructure/
├── database/                    # Adapter PostgreSQL (SQLx)
│   ├── mod.rs
│   ├── pool.rs                  # Connection pool
│   ├── seed.rs                  # Seed données test
│   └── repositories/            # Implémentations repositories
│       ├── user_repository_impl.rs
│       ├── organization_repository_impl.rs
│       ├── building_repository_impl.rs
│       ├── unit_repository_impl.rs
│       ├── owner_repository_impl.rs
│       ├── expense_repository_impl.rs
│       ├── meeting_repository_impl.rs
│       └── document_repository_impl.rs
└── web/                         # Adapter HTTP (Actix-web)
    ├── mod.rs
    ├── app_state.rs             # État partagé application
    ├── routes.rs                # Configuration routes
    ├── middleware/              # Middleware HTTP
    │   ├── auth.rs              # JWT authentication
    │   └── cors.rs              # CORS policy
    └── handlers/                # Handlers HTTP
        ├── auth_handlers.rs
        ├── seed_handlers.rs
        ├── building_handlers.rs
        ├── unit_handlers.rs
        ├── owner_handlers.rs
        ├── expense_handlers.rs
        ├── meeting_handlers.rs
        ├── document_handlers.rs
        └── health.rs

Database (Adapter PostgreSQL)

SQLx : ORM avec vérification compile-time des requêtes.

Connection Pool

use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;

pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
    PgPoolOptions::new()
        .max_connections(10)
        .connect(database_url)
        .await
}

Configuration :

  • Max connections : 10 (performance optimale pour charge typique)

  • Timeout : 30s par défaut

  • Health check : Ping automatique connexions

Repository Implementation

Pattern : Implémentation du trait port.

pub struct PostgresBuildingRepository {
    pool: PgPool,
}

impl PostgresBuildingRepository {
    pub fn new(pool: PgPool) -> Self {
        Self { pool }
    }
}

#[async_trait]
impl BuildingRepository for PostgresBuildingRepository {
    async fn create(&self, building: &Building) -> Result<Building, String> {
        sqlx::query_as!(
            Building,
            r#"
            INSERT INTO buildings (
                id, name, address, city, postal_code, country,
                total_units, construction_year, organization_id,
                created_at, updated_at
            )
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
            RETURNING *
            "#,
            building.id,
            building.name,
            building.address,
            building.city,
            building.postal_code,
            building.country,
            building.total_units,
            building.construction_year,
            building.organization_id,
            building.created_at,
            building.updated_at
        )
        .fetch_one(&self.pool)
        .await
        .map_err(|e| e.to_string())
    }

    async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String> {
        sqlx::query_as!(
            Building,
            r#"
            SELECT * FROM buildings WHERE id = $1
            "#,
            id
        )
        .fetch_optional(&self.pool)
        .await
        .map_err(|e| e.to_string())
    }

    async fn find_all_paginated(
        &self,
        organization_id: Uuid,
        page: i64,
        per_page: i64
    ) -> Result<PageResponse<Building>, String> {
        let offset = (page - 1) * per_page;

        // Compter total
        let total_items: i64 = sqlx::query_scalar!(
            "SELECT COUNT(*) FROM buildings WHERE organization_id = $1",
            organization_id
        )
        .fetch_one(&self.pool)
        .await
        .map_err(|e| e.to_string())?
        .unwrap_or(0);

        // Récupérer données paginées
        let buildings = sqlx::query_as!(
            Building,
            r#"
            SELECT * FROM buildings
            WHERE organization_id = $1
            ORDER BY created_at DESC
            LIMIT $2 OFFSET $3
            "#,
            organization_id,
            per_page,
            offset
        )
        .fetch_all(&self.pool)
        .await
        .map_err(|e| e.to_string())?;

        let total_pages = (total_items + per_page - 1) / per_page;

        Ok(PageResponse {
            data: buildings,
            pagination: PaginationMeta {
                current_page: page,
                per_page,
                total_items,
                total_pages,
                has_next: page < total_pages,
                has_previous: page > 1,
            },
        })
    }

    async fn update(&self, building: &Building) -> Result<Building, String> {
        sqlx::query_as!(
            Building,
            r#"
            UPDATE buildings
            SET name = $2, address = $3, city = $4, postal_code = $5,
                updated_at = $6
            WHERE id = $1
            RETURNING *
            "#,
            building.id,
            building.name,
            building.address,
            building.city,
            building.postal_code,
            building.updated_at
        )
        .fetch_one(&self.pool)
        .await
        .map_err(|e| e.to_string())
    }

    async fn delete(&self, id: Uuid) -> Result<(), String> {
        sqlx::query!("DELETE FROM buildings WHERE id = $1", id)
            .execute(&self.pool)
            .await
            .map_err(|e| e.to_string())?;

        Ok(())
    }
}

Avantages SQLx :

  • ✅ Vérification compile-time (sqlx::query_as!)

  • ✅ Type-safe (mapping automatique Rust ↔ PostgreSQL)

  • ✅ Performance (prepared statements)

  • ✅ Prévention SQL injection (parameterized queries)

Seed Data

Génération données de test pour développement.

pub async fn seed_database(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
    // Créer organisations
    let org = create_organization(pool, "Test Syndic").await?;

    // Créer users
    let user = create_user(pool, org.id, "admin@test.com", UserRole::Syndic).await?;

    // Créer buildings
    for i in 1..=5 {
        let building = create_building(
            pool,
            org.id,
            &format!("Résidence Test {}", i)
        ).await?;

        // Créer units pour chaque building
        for j in 1..=10 {
            create_unit(pool, building.id, &format!("A-{}", j)).await?;
        }
    }

    Ok(())
}

Web (Adapter HTTP)

Actix-web : Framework web performant et type-safe.

App State

État partagé entre handlers (pool DB, use cases).

pub struct AppState {
    pub building_use_cases: Arc<BuildingUseCases>,
    pub unit_use_cases: Arc<UnitUseCases>,
    pub owner_use_cases: Arc<OwnerUseCases>,
    pub expense_use_cases: Arc<ExpenseUseCases>,
    pub auth_use_cases: Arc<AuthUseCases>,
    pub jwt_secret: String,
}

Routes Configuration

pub fn configure_routes(cfg: &mut web::ServiceConfig) {
    cfg
        .service(
            web::scope("/api/v1")
                // Health
                .route("/health", web::get().to(health_check))

                // Auth
                .service(
                    web::scope("/auth")
                        .route("/login", web::post().to(login))
                        .route("/refresh", web::post().to(refresh_token))
                        .route("/me", web::get().to(get_current_user))
                )

                // Buildings (protected)
                .service(
                    web::scope("/buildings")
                        .wrap(AuthMiddleware)  // JWT required
                        .route("", web::get().to(list_buildings))
                        .route("", web::post().to(create_building))
                        .route("/{id}", web::get().to(get_building))
                        .route("/{id}", web::put().to(update_building))
                        .route("/{id}", web::delete().to(delete_building))
                        .route("/{id}/units", web::get().to(list_building_units))
                        .route("/{id}/expenses", web::get().to(list_building_expenses))
                )

                // Units
                .service(
                    web::scope("/units")
                        .wrap(AuthMiddleware)
                        .route("", web::get().to(list_units))
                        .route("", web::post().to(create_unit))
                        .route("/{id}", web::get().to(get_unit))
                        .route("/{id}", web::put().to(update_unit))
                        .route("/{id}", web::delete().to(delete_unit))
                        .route("/{id}/assign-owner/{owner_id}", web::put().to(assign_owner))
                )

                // Owners, Expenses, Meetings, Documents...
        );
}

HTTP Handlers

Traitement requêtes HTTP et réponses.

#[derive(Deserialize)]
pub struct PaginationParams {
    pub page: Option<i64>,
    pub per_page: Option<i64>,
}

pub async fn list_buildings(
    state: web::Data<AppState>,
    query: web::Query<PaginationParams>,
    user: AuthenticatedUser,  // Extrait du JWT middleware
) -> Result<HttpResponse, actix_web::Error> {
    let page = query.page.unwrap_or(1);
    let per_page = query.per_page.unwrap_or(20).min(100);  // Max 100

    let result = state.building_use_cases
        .list_buildings_paginated(user.organization_id, page, per_page)
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::Ok().json(result))
}

pub async fn create_building(
    state: web::Data<AppState>,
    dto: web::Json<BuildingDto>,
    user: AuthenticatedUser,
) -> Result<HttpResponse, actix_web::Error> {
    // Vérifier permissions (role = syndic)
    if user.role != UserRole::Syndic {
        return Err(actix_web::error::ErrorForbidden("Insufficient permissions"));
    }

    let result = state.building_use_cases
        .create_building(user.organization_id, dto.into_inner())
        .await
        .map_err(|e| actix_web::error::ErrorBadRequest(e))?;

    Ok(HttpResponse::Created().json(result))
}

pub async fn get_building(
    state: web::Data<AppState>,
    path: web::Path<Uuid>,
    user: AuthenticatedUser,
) -> Result<HttpResponse, actix_web::Error> {
    let building_id = path.into_inner();

    let result = state.building_use_cases
        .get_building(building_id)
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    match result {
        Some(building) => Ok(HttpResponse::Ok().json(building)),
        None => Err(actix_web::error::ErrorNotFound("Building not found")),
    }
}

pub async fn update_building(
    state: web::Data<AppState>,
    path: web::Path<Uuid>,
    dto: web::Json<BuildingDto>,
    user: AuthenticatedUser,
) -> Result<HttpResponse, actix_web::Error> {
    let building_id = path.into_inner();

    let result = state.building_use_cases
        .update_building(building_id, dto.into_inner())
        .await
        .map_err(|e| actix_web::error::ErrorBadRequest(e))?;

    Ok(HttpResponse::Ok().json(result))
}

pub async fn delete_building(
    state: web::Data<AppState>,
    path: web::Path<Uuid>,
    user: AuthenticatedUser,
) -> Result<HttpResponse, actix_web::Error> {
    let building_id = path.into_inner();

    state.building_use_cases
        .delete_building(building_id)
        .await
        .map_err(|e| actix_web::error::ErrorInternalServerError(e))?;

    Ok(HttpResponse::NoContent().finish())
}

Middleware

Auth Middleware (JWT)

pub struct AuthMiddleware;

impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
    S::Future: 'static,
    B: 'static,
{
    type Response = ServiceResponse<B>;
    type Error = Error;
    type Transform = AuthMiddlewareService<S>;
    type InitError = ();
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ready(Ok(AuthMiddlewareService { service }))
    }
}

pub async fn extract_jwt_claims(req: &HttpRequest) -> Result<Claims, actix_web::Error> {
    let auth_header = req.headers()
        .get("Authorization")
        .ok_or_else(|| actix_web::error::ErrorUnauthorized("Missing Authorization header"))?
        .to_str()
        .map_err(|_| actix_web::error::ErrorUnauthorized("Invalid Authorization header"))?;

    if !auth_header.starts_with("Bearer ") {
        return Err(actix_web::error::ErrorUnauthorized("Invalid Authorization format"));
    }

    let token = &auth_header[7..];

    decode_jwt_token(token, &jwt_secret)
        .map_err(|_| actix_web::error::ErrorUnauthorized("Invalid or expired token"))
}

CORS Middleware

use actix_cors::Cors;
use actix_web::http::header;

pub fn configure_cors() -> Cors {
    Cors::default()
        .allowed_origin("https://koprogo.com")
        .allowed_origin("http://localhost:3000")  // Dev
        .allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
        .allowed_headers(vec![
            header::AUTHORIZATION,
            header::ACCEPT,
            header::CONTENT_TYPE,
            HeaderName::from_static("accept-language"),
        ])
        .max_age(3600)
}

Error Handling

Conversion erreurs métier → HTTP status.

impl From<AppError> for actix_web::Error {
    fn from(error: AppError) -> Self {
        match error {
            AppError::NotFound(msg) => actix_web::error::ErrorNotFound(msg),
            AppError::ValidationError(msg) => actix_web::error::ErrorBadRequest(msg),
            AppError::Unauthorized(msg) => actix_web::error::ErrorUnauthorized(msg),
            AppError::InternalError(msg) => actix_web::error::ErrorInternalServerError(msg),
        }
    }
}

Tests Infrastructure

Tests d’Intégration avec Testcontainers :

#[tokio::test]
async fn test_create_building_endpoint() {
    // Démarrer PostgreSQL testcontainer
    let postgres = PostgresContainer::default();
    let pool = create_test_pool(&postgres).await;

    // Run migrations
    sqlx::migrate!().run(&pool).await.unwrap();

    // Créer app
    let app = test::init_service(
        App::new()
            .app_data(web::Data::new(AppState { ... }))
            .configure(configure_routes)
    ).await;

    // Test POST /buildings
    let req = test::TestRequest::post()
        .uri("/api/v1/buildings")
        .set_json(&building_dto)
        .insert_header(("Authorization", format!("Bearer {}", token)))
        .to_request();

    let resp = test::call_service(&app, req).await;
    assert_eq!(resp.status(), 201);
}

Dépendances

[dependencies]
# Web framework
actix-web = "4.9"
actix-cors = "0.7"

# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }

# Async
tokio = { version = "1.41", features = ["full"] }
async-trait = "0.1"

# Tests
testcontainers = "0.23"

Références