Pattern d’extension PMOServer (pmoserver_ext)

Vue d’ensemble

Le pattern pmoserver_ext permet d’étendre les fonctionnalités du serveur HTTP pmoserver de manière modulaire et découplée. Chaque crate spécialisée peut ajouter ses propres routes HTTP sans que pmoserver ne dépende de ces crates.

Principe : Définir un trait d’extension que pmoserver::Server implémente via une feature Cargo.

Anatomie d’une extension

1. Structure du module

Créer un module pmoserver_ext.rs dans la crate :

// pmoXXX/src/pmoserver_ext.rs

#[cfg(feature = "pmoserver")]
use crate::{/* types internes de la crate */};
#[cfg(feature = "pmoserver")]
use async_trait::async_trait;
#[cfg(feature = "pmoserver")]
use axum::{Router, routing::get, Json, extract::{State, Path}};
#[cfg(feature = "pmoserver")]
use std::sync::Arc;

Déclarer le module dans lib.rs :

// pmoXXX/src/lib.rs
#[cfg(feature = "pmoserver")]
pub mod pmoserver_ext;

#[cfg(feature = "pmoserver")]
pub use pmoserver_ext::XXXExt;

Ajouter la feature dans Cargo.toml :

[features]
pmoserver = ["dep:axum", "dep:async-trait"]

[dependencies]
axum = { version = "0.8", optional = true }
async-trait = { version = "0.1", optional = true }
pmoserver = { path = "../pmoserver" }

2. Définir le trait d’extension

Convention de nommage : {Domaine}Ext avec méthodes préfixées init_*

/// Trait pour étendre pmoserver avec les fonctionnalités XXX
#[cfg(feature = "pmoserver")]
#[async_trait]
pub trait XXXExt {
    /// Initialise l'extension XXX et enregistre les routes HTTP
    ///
    /// # Arguments
    /// * `param1` - Description du paramètre
    ///
    /// # Returns
    /// Instance partagée de la ressource créée
    ///
    /// # Exemple
    /// ```ignore
    /// use pmoserver::ServerBuilder;
    /// use pmoXXX::XXXExt;
    ///
    /// let mut server = ServerBuilder::new(...).build();
    /// let resource = server.init_xxx(param1).await?;
    /// ```
    async fn init_xxx(&mut self, param1: String) -> anyhow::Result<Arc<Resource>>;
}

3. Implémenter le trait

Implémenter le trait pour pmoserver::Server :

#[cfg(feature = "pmoserver")]
#[async_trait]
impl XXXExt for pmoserver::Server {
    async fn init_xxx(&mut self, param1: String) -> anyhow::Result<Arc<Resource>> {
        // 1. Créer la ressource interne
        let resource = Arc::new(Resource::new(param1)?);
        
        // 2. Créer l'état partagé pour les handlers
        let state = XxxState::new(resource.clone());
        
        // 3. Créer le router avec les routes
        let router = create_xxx_router(state);
        
        // 4. Enregistrer le router sur le serveur
        self.add_router("/api/xxx", router).await;
        
        // 5. Retourner la ressource pour usage ultérieur
        Ok(resource)
    }
}

4. État partagé (State)

Créer une structure d’état cloneable pour les handlers :

/// État partagé pour les handlers XXX
#[derive(Clone)]
pub struct XxxState {
    resource: Arc<Resource>,
}

impl XxxState {
    pub fn new(resource: Arc<Resource>) -> Self {
        Self { resource }
    }
}

5. Créer le router

Définir les routes et handlers :

/// Crée le router pour l'API XXX
fn create_xxx_router(state: XxxState) -> Router {
    Router::new()
        .route("/items", get(list_items).post(create_item))
        .route("/items/{id}", get(get_item).delete(delete_item))
        .with_state(state)
}

// Handlers
async fn list_items(
    State(state): State<XxxState>
) -> Json<Vec<ItemSummary>> {
    let items = state.resource.list_items();
    Json(items)
}

async fn get_item(
    State(state): State<XxxState>,
    Path(id): Path<String>,
) -> Result<Json<Item>, StatusCode> {
    state.resource.get_item(&id)
        .ok_or(StatusCode::NOT_FOUND)
        .map(Json)
}

Méthodes disponibles du serveur

pmoserver::Server expose ces méthodes pour enregistrer des routes :

Méthode Usage
add_handler(path, handler) Ajoute un handler simple sans état
add_handler_with_state(path, handler, state) Ajoute un handler avec état partagé
add_router(path, router) Monte un sous-router Axum
add_openapi(router, doc, tag) Enregistre une API avec documentation OpenAPI
add_spa::<W>(path) Sert une Single Page Application (RustEmbed)
base_url() Récupère l’URL de base du serveur

Documentation OpenAPI avec utoipa

La documentation OpenAPI est essentielle pour une extension pmoserver. Elle génère automatiquement une interface Swagger UI et documente les endpoints de l’API.

Configuration de base

Ajouter utoipa dans Cargo.toml :

[dependencies]
utoipa = { version = "5", features = ["axum_extras"] }
serde = { version = "1", features = ["derive"] }

1. Définir les schémas de données

Annoter les structures de réponse/requête avec #[derive(ToSchema)] :

use serde::{Serialize, Deserialize};
use utoipa::ToSchema;

/// Information sur un item
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ItemInfo {
    /// ID unique de l'item
    #[schema(example = "item-123")]
    pub id: String,
    
    /// Nom de l'item
    #[schema(example = "Mon Item")]
    pub name: String,
    
    /// Description optionnelle
    #[schema(example = "Une description détaillée")]
    pub description: Option<String>,
    
    /// Timestamp de création (millisecondes)
    #[schema(example = 1234567890)]
    pub created_at: u64,
}

/// Liste d'items
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ItemList {
    /// Nombre total d'items
    pub total: usize,
    
    /// Items de la page courante
    pub items: Vec<ItemInfo>,
}

/// Requête de création d'item
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateItemRequest {
    /// Nom de l'item à créer
    #[schema(example = "Nouvel Item")]
    pub name: String,
    
    /// Description optionnelle
    pub description: Option<String>,
}

/// Réponse d'erreur standard
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ErrorResponse {
    /// Message d'erreur
    #[schema(example = "Item not found")]
    pub error: String,
}

Points clés : - #[schema(example = "...")] : Fournit des exemples pour la doc Swagger - Documenter chaque champ avec /// pour apparaître dans l’API - Utiliser Option<T> pour les champs optionnels

2. Annoter les handlers

Utiliser #[utoipa::path(...)] pour documenter chaque endpoint :

/// GET /items - Liste tous les items
#[utoipa::path(
    get,
    path = "/items",
    params(
        ("limit" = Option<u32>, Query, description = "Nombre max d'items à retourner"),
        ("offset" = Option<u32>, Query, description = "Offset pour la pagination")
    ),
    responses(
        (status = 200, description = "Liste des items", body = ItemList),
        (status = 500, description = "Erreur serveur", body = ErrorResponse)
    ),
    tag = "items"
)]
async fn list_items(
    State(state): State<XxxState>,
    Query(params): Query<ListParams>,
) -> Result<Json<ItemList>, (StatusCode, Json<ErrorResponse>)> {
    let items = state.resource.list_items(params.limit, params.offset)
        .map_err(|e| (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ErrorResponse { error: e.to_string() })
        ))?;
    
    Ok(Json(ItemList {
        total: items.len(),
        items,
    }))
}

/// GET /items/{id} - Récupère un item spécifique
#[utoipa::path(
    get,
    path = "/items/{id}",
    params(
        ("id" = String, Path, description = "ID unique de l'item")
    ),
    responses(
        (status = 200, description = "Item trouvé", body = ItemInfo),
        (status = 404, description = "Item non trouvé", body = ErrorResponse),
        (status = 500, description = "Erreur serveur", body = ErrorResponse)
    ),
    tag = "items"
)]
async fn get_item(
    State(state): State<XxxState>,
    Path(id): Path<String>,
) -> Result<Json<ItemInfo>, (StatusCode, Json<ErrorResponse>)> {
    state.resource.get_item(&id)
        .ok_or_else(|| (
            StatusCode::NOT_FOUND,
            Json(ErrorResponse {
                error: format!("Item {} not found", id)
            })
        ))
        .map(Json)
}

/// POST /items - Crée un nouvel item
#[utoipa::path(
    post,
    path = "/items",
    request_body = CreateItemRequest,
    responses(
        (status = 201, description = "Item créé", body = ItemInfo),
        (status = 400, description = "Requête invalide", body = ErrorResponse),
        (status = 500, description = "Erreur serveur", body = ErrorResponse)
    ),
    tag = "items"
)]
async fn create_item(
    State(state): State<XxxState>,
    Json(req): Json<CreateItemRequest>,
) -> Result<(StatusCode, Json<ItemInfo>), (StatusCode, Json<ErrorResponse>)> {
    let item = state.resource.create_item(req.name, req.description)
        .map_err(|e| (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ErrorResponse { error: e.to_string() })
        ))?;
    
    Ok((StatusCode::CREATED, Json(item)))
}

/// DELETE /items/{id} - Supprime un item
#[utoipa::path(
    delete,
    path = "/items/{id}",
    params(
        ("id" = String, Path, description = "ID unique de l'item")
    ),
    responses(
        (status = 204, description = "Item supprimé"),
        (status = 404, description = "Item non trouvé", body = ErrorResponse),
        (status = 500, description = "Erreur serveur", body = ErrorResponse)
    ),
    tag = "items"
)]
async fn delete_item(
    State(state): State<XxxState>,
    Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
    state.resource.delete_item(&id)
        .map_err(|e| (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(ErrorResponse { error: e.to_string() })
        ))?;
    
    Ok(StatusCode::NO_CONTENT)
}

Structure de #[utoipa::path] : - Méthode HTTP : get, post, put, delete, patch - path : Chemin de l’endpoint (doit correspondre au router) - params : Paramètres Path ou Query avec description - request_body : Type du body pour POST/PUT - responses : Liste des réponses possibles avec codes HTTP - tag : Groupe d’endpoints dans Swagger UI

3. Créer la structure OpenAPI

Définir une structure avec #[derive(OpenApi)] :

use utoipa::OpenApi;

/// Documentation OpenAPI pour l'API XXX
#[derive(OpenApi)]
#[openapi(
    info(
        title = "XXX API",
        version = "1.0.0",
        description = r#"
# API REST pour XXX

Cette API permet de gérer les items XXX avec les fonctionnalités suivantes :

## Fonctionnalités

- **CRUD complet** : Création, lecture, mise à jour et suppression d'items
- **Pagination** : Support de limit/offset pour les listes
- **Filtrage** : Recherche par critères multiples
- **Validation** : Vérification automatique des données

## Exemples d'utilisation

### Lister les items

GET /api/xxx/items?limit=10&offset=0


### Créer un item

POST /api/xxx/items Content-Type: application/json

{ “name”: “Mon Item”, “description”: “Description détaillée” }


### Récupérer un item

GET /api/xxx/items/item-123


### Supprimer un item

DELETE /api/xxx/items/item-123

        "#
    ),
    paths(
        list_items,
        get_item,
        create_item,
        delete_item,
    ),
    components(schemas(
        ItemInfo,
        ItemList,
        CreateItemRequest,
        ErrorResponse,
    )),
    tags(
        (name = "items", description = "Opérations sur les items")
    )
)]
pub struct ApiDoc;

Sections importantes : - info : Titre, version et description Markdown de l’API - paths : Liste des fonctions handler annotées - components(schemas(...)) : Liste des structures ToSchema - tags : Organisation des endpoints en groupes

4. Enregistrer l’API avec OpenAPI

Dans l’implémentation du trait d’extension :

#[async_trait]
impl XxxExt for pmoserver::Server {
    async fn init_xxx(&mut self) -> anyhow::Result<Arc<Resource>> {
        let resource = Arc::new(Resource::new()?);
        let state = XxxState { resource: resource.clone() };
        
        // Créer le router avec les routes
        let router = Router::new()
            .route("/items", get(list_items).post(create_item))
            .route("/items/{id}", get(get_item).delete(delete_item))
            .with_state(state);
        
        // Enregistrer avec OpenAPI (génère aussi /swagger-ui/xxx)
        let openapi = ApiDoc::openapi();
        self.add_openapi(router, openapi, "xxx").await;
        
        Ok(resource)
    }
}

Ce que fait add_openapi : - Monte le router sur /api/{tag}/ - Génère la spec OpenAPI JSON sur /api/{tag}/openapi.json - Crée une UI Swagger sur /swagger-ui/{tag}/

5. Exemple complet : Radio Paradise

Extrait de pmoparadise/src/pmoserver_ext.rs:93-315

/// Information sur un morceau
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SongInfo {
    /// Index dans le block
    pub index: usize,
    /// Artiste
    pub artist: String,
    /// Titre
    pub title: String,
    /// Album
    pub album: String,
    /// Année
    pub year: Option<u32>,
    /// Temps écoulé depuis le début du block (ms)
    pub elapsed_ms: u64,
    /// Durée du morceau (ms)
    pub duration_ms: u64,
    /// URL de la pochette
    pub cover_url: Option<String>,
}

/// Réponse pour l'URL de streaming
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct StreamUrlResponse {
    /// Event ID du block
    #[schema(example = 1234567)]
    pub event: u64,
    /// URL de streaming FLAC
    #[schema(example = "https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac")]
    pub stream_url: String,
    /// Durée totale (ms)
    #[schema(example = 900000)]
    pub length_ms: u64,
}

/// GET /stream-url/{event_id} - Récupère l'URL de streaming
#[utoipa::path(
    get,
    path = "/stream-url/{event_id}",
    params(
        ("event_id" = u64, Path, description = "Event ID du block"),
        ("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
    ),
    responses(
        (status = 200, description = "URL de streaming", body = StreamUrlResponse),
        (status = 500, description = "Erreur serveur")
    ),
    tag = "Radio Paradise"
)]
async fn get_stream_url(
    State(state): State<RadioParadiseState>,
    Path(event_id): Path<u64>,
    Query(params): Query<ParadiseQuery>,
) -> Result<Json<StreamUrlResponse>, StatusCode> {
    let client = state.client_for_params(&params).await?;
    let block = client.get_block(Some(event_id)).await.map_err(|e| {
        tracing::error!("Failed to fetch block {}: {}", event_id, e);
        StatusCode::INTERNAL_SERVER_ERROR
    })?;

    Ok(Json(StreamUrlResponse {
        event: block.event,
        stream_url: block.url,
        length_ms: block.length,
    }))
}

#[derive(OpenApi)]
#[openapi(
    info(
        title = "Radio Paradise API",
        version = "1.0.0",
        description = "API REST pour accéder aux métadonnées Radio Paradise"
    ),
    paths(
        get_now_playing,
        get_current_block,
        get_stream_url,
    ),
    components(schemas(
        SongInfo,
        StreamUrlResponse,
    )),
    tags(
        (name = "Radio Paradise", description = "Endpoints Radio Paradise")
    )
)]
pub struct RadioParadiseApiDoc;

Résultat : Interface Swagger

Après avoir appelé init_xxx(), l’API est accessible :

  • API JSON : http://localhost:8080/api/xxx/
  • Spec OpenAPI : http://localhost:8080/api/xxx/openapi.json
  • Swagger UI : http://localhost:8080/swagger-ui/xxx/

L’interface Swagger permet : - Parcourir tous les endpoints avec leur documentation - Tester les requêtes directement depuis le navigateur - Voir les schémas de données avec exemples - Consulter les codes de réponse HTTP possibles

Patterns courants

Pattern 1 : Extension simple avec router

Exemple : pmoparadise (pmoparadise/src/pmoserver_ext.rs:367-392)

#[async_trait]
impl RadioParadiseExt for pmoserver::Server {
    async fn init_radioparadise(&mut self) -> anyhow::Result<State> {
        let state = RadioParadiseState::new().await?;
        
        // Créer le router API
        let api_router = create_api_router(state.clone());
        
        // Enregistrer avec OpenAPI
        self.add_openapi(api_router, ApiDoc::openapi(), "radioparadise")
            .await;
        
        Ok(state)
    }
}

Pattern 2 : Extension avec cache et fichiers

Exemple : pmoaudiocache (pmoaudiocache/src/lib.rs:225-260)

#[async_trait]
impl AudioCacheExt for pmoserver::Server {
    async fn init_audio_cache(
        &mut self,
        cache_dir: &str,
        limit: usize,
    ) -> anyhow::Result<Arc<Cache>> {
        let cache = Arc::new(new_cache(cache_dir, limit)?);
        
        // Router pour servir les fichiers FLAC
        let file_router = create_file_router(cache.clone(), "audio/flac");
        self.add_router("/", file_router).await;
        
        // API REST
        let api_router = Router::new()
            .route("/", get(list).post(add))
            .route("/{pk}", get(get_info).delete(delete))
            .with_state(cache.clone());
        
        self.add_openapi(api_router, ApiDoc::openapi(), "audio").await;
        
        Ok(cache)
    }
}

Pattern 3 : Extension avec routes dynamiques

Exemple : pmomediaserver (pmomediaserver/src/paradise_streaming.rs:70-148)

#[async_trait]
impl ParadiseStreamingExt for pmoserver::Server {
    async fn init_paradise_streaming(&mut self) -> Result<Arc<Manager>> {
        // 1. Récupérer/créer les ressources partagées
        let audio_cache = get_or_init_audio_cache(self).await?;
        let manager = Arc::new(Manager::new(audio_cache).await?);
        
        // 2. Créer l'état partagé
        let state = Arc::new(StreamingState { manager: manager.clone() });
        
        // 3. Enregistrer les routes pour chaque canal
        for descriptor in ALL_CHANNELS.iter() {
            let slug = descriptor.slug;
            
            // Route streaming FLAC
            let path = format!("/stream/{}/flac", slug);
            self.add_handler_with_state(
                &path,
                move |State(s): State<Arc<StreamingState>>| async move {
                    stream_flac(s.manager.clone(), descriptor.id).await
                },
                state.clone(),
            ).await;
            
            // Route streaming OGG
            let path = format!("/stream/{}/ogg", slug);
            self.add_handler_with_state(
                &path,
                move |State(s): State<Arc<StreamingState>>| async move {
                    stream_ogg(s.manager.clone(), descriptor.id).await
                },
                state.clone(),
            ).await;
        }
        
        Ok(manager)
    }
}

Gestion des opérations longues

Utiliser spawn_blocking pour le code synchrone

Pour éviter de bloquer le runtime Tokio avec du code synchrone :

async fn list_renderers(
    State(state): State<ControlPointState>
) -> Json<Vec<Summary>> {
    let control_point = state.control_point.clone();
    
    let summaries = tokio::task::spawn_blocking(move || {
        let renderers = control_point.list_music_renderers();
        renderers.into_iter()
            .map(|r| Summary::from(&r))
            .collect()
    })
    .await
    .unwrap_or_default();
    
    Json(summaries)
}

Ajouter des timeouts pour les opérations réseau

const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);

async fn play_renderer(
    State(state): State<ControlPointState>,
    Path(id): Path<String>,
) -> Result<Json<Response>, (StatusCode, Json<Error>)> {
    let renderer = state.get_renderer(&id)
        .ok_or((StatusCode::NOT_FOUND, Json(Error::not_found())))?;
    
    let play_task = tokio::task::spawn_blocking(move || renderer.play());
    
    time::timeout(COMMAND_TIMEOUT, play_task)
        .await
        .map_err(|_| (
            StatusCode::GATEWAY_TIMEOUT,
            Json(Error::timeout())
        ))?
        .map_err(|e| (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(Error::internal(e))
        ))??;
    
    Ok(Json(Response::success()))
}

Utiliser spawn pour les tâches en arrière-plan

Pour les opérations qui ne nécessitent pas d’attendre le résultat :

async fn trigger_action(
    State(state): State<XxxState>,
    Json(req): Json<Request>,
) -> Json<Response> {
    // Valider la requête
    state.validate(&req)?;
    
    // Lancer l'action en arrière-plan
    let state_clone = state.clone();
    tokio::task::spawn(async move {
        match state_clone.perform_action(req).await {
            Ok(_) => debug!("Action completed"),
            Err(e) => warn!("Action failed: {}", e),
        }
    });
    
    // Retourner immédiatement
    Json(Response::accepted())
}

Checklist d’implémentation

Configuration de base

Définition du trait

Documentation OpenAPI

Handlers et routes

Performance et robustesse

Exemple complet minimal

// pmoexample/src/pmoserver_ext.rs

#[cfg(feature = "pmoserver")]
use async_trait::async_trait;
#[cfg(feature = "pmoserver")]
use axum::{Router, routing::get, Json, extract::State};
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use crate::ExampleResource;

#[cfg(feature = "pmoserver")]
#[derive(Clone)]
pub struct ExampleState {
    resource: Arc<ExampleResource>,
}

#[cfg(feature = "pmoserver")]
#[async_trait]
pub trait ExampleExt {
    async fn init_example(&mut self) -> anyhow::Result<Arc<ExampleResource>>;
}

#[cfg(feature = "pmoserver")]
#[async_trait]
impl ExampleExt for pmoserver::Server {
    async fn init_example(&mut self) -> anyhow::Result<Arc<ExampleResource>> {
        let resource = Arc::new(ExampleResource::new());
        let state = ExampleState { resource: resource.clone() };
        
        let router = Router::new()
            .route("/items", get(list_items))
            .with_state(state);
        
        self.add_router("/api/example", router).await;
        
        Ok(resource)
    }
}

#[cfg(feature = "pmoserver")]
async fn list_items(State(state): State<ExampleState>) -> Json<Vec<String>> {
    let items = state.resource.list();
    Json(items)
}

Références

Exemples dans le codebase

Crate Fichier Pattern
pmoparadise src/pmoserver_ext.rs:367-392 Extension simple avec OpenAPI
pmoaudiocache src/lib.rs:225-260 Extension avec cache et fichiers
pmomediaserver src/paradise_streaming.rs:70-148 Extension avec routes dynamiques
pmocontrol src/pmoserver_ext.rs:68-92 Handlers avec spawn_blocking
pmoapp src/lib.rs:145-165 Extension SPA avec RustEmbed

Dépendances communes

  • axum : Framework HTTP (Router, handlers, extracteurs)
  • async-trait : Support des traits async
  • tokio : Runtime async (spawn, spawn_blocking, timeout)
  • anyhow : Gestion d’erreurs pour init
  • tracing : Logging structuré
  • utoipa : Documentation OpenAPI/Swagger
  • serde : Sérialisation JSON