//! Appels de l'interface vers le serveur hors routes REST classiques. //! //! - **Actions** — `POST /api/actions/{nom}`, corps = tableau JSON des arguments. //! Remplacent les « server actions » Next.js : l'interface garde les mêmes //! fonctions (`getUsers()`, `saveSnapshot(…)`…), devenues de simples appels //! HTTP (cf. `src/lib/rpc.ts`). Une erreur renvoie `{ error }` avec un statut //! 4xx/5xx, que le client relève comme une exception. //! - **Pages** — `GET /api/pages/{nom}?…` : les données qu'une page serveur //! Next.js calculait avant rendu (tableau de bord, hit-parade…), paramètres //! d'URL compris. //! //! Chaque module déclare les siennes via `actions()` / `pages()`. use crate::auth::{session, Claims, UtilisateurCourant}; use crate::error::{AppError, AppResult}; use axum::extract::{Path, Query}; use axum::http::{header, HeaderMap}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use futures::future::BoxFuture; use serde::de::DeserializeOwned; use serde::Serialize; use serde_json::Value; use std::collections::HashMap; use std::sync::LazyLock; /// Contexte d'un appel : jeton de session, paramètres d'URL, arguments. #[derive(Clone, Default)] pub struct Ctx { pub token: Option, pub query: HashMap, pub args: Vec, } impl Ctx { /// Utilisateur connecté (401 sinon). pub async fn session(&self) -> AppResult { crate::auth::require_session(self.token.as_ref()).await } /// Administrateur connecté (401/403 sinon). pub async fn admin(&self) -> AppResult { crate::auth::require_admin(self.token.as_ref()).await } /// Argument n° `i` désérialisé (`null` s'il est absent). pub fn arg(&self, i: usize) -> AppResult { let v = self.args.get(i).cloned().unwrap_or(Value::Null); serde_json::from_value(v).map_err(|e| AppError::bad_request(format!("Argument {i} invalide : {e}"))) } /// Argument brut n° `i`. pub fn raw(&self, i: usize) -> Value { self.args.get(i).cloned().unwrap_or(Value::Null) } /// Paramètre d'URL (page), vide → `None`. pub fn q(&self, name: &str) -> Option<&str> { self.query.get(name).map(|s| s.as_str()).filter(|s| !s.is_empty()) } } pub type Handler = fn(Ctx) -> BoxFuture<'static, AppResult>; /// Sérialise un résultat en JSON. pub fn json(v: T) -> AppResult { serde_json::to_value(v).map_err(|e| AppError::internal(e.to_string())) } /// Transforme une fonction `async fn(Ctx) -> AppResult` en [`Handler`]. #[macro_export] macro_rules! handler { ($f:path) => { (|c: $crate::rpc::Ctx| -> ::futures::future::BoxFuture<'static, $crate::error::AppResult<::serde_json::Value>> { Box::pin($f(c)) }) as $crate::rpc::Handler }; } static ACTIONS: LazyLock> = LazyLock::new(|| { let mut m = HashMap::new(); for (name, h) in crate::modules_actions() { if m.insert(name, h).is_some() { panic!("action en double : {name}"); } } m }); static PAGES: LazyLock> = LazyLock::new(|| { let mut m = HashMap::new(); for (name, h) in crate::modules_pages() { if m.insert(name, h).is_some() { panic!("page en double : {name}"); } } m }); fn no_store(v: Value) -> Response { ([(header::CACHE_CONTROL, "no-store")], Json(v)).into_response() } async fn call_action(Path(name): Path, headers: HeaderMap, body: axum::body::Bytes) -> AppResult { let h = ACTIONS.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Action inconnue : {name}")))?; let body: Option = if body.iter().all(|b| b.is_ascii_whitespace()) { None } else { Some(serde_json::from_slice(&body).map_err(|e| AppError::bad_request(format!("Corps JSON invalide : {e}")))?) }; let args = match body { Some(Value::Array(a)) => a, Some(Value::Null) | None => vec![], Some(other) => vec![other], }; let ctx = Ctx { token: session::from_headers(&headers), query: HashMap::new(), args }; Ok(no_store(h(ctx).await?)) } async fn call_page(Path(name): Path, headers: HeaderMap, Query(query): Query>) -> AppResult { let h = PAGES.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Page inconnue : {name}")))?; let ctx = Ctx { token: session::from_headers(&headers), query, args: vec![] }; Ok(no_store(h(ctx).await?)) } pub fn router() -> Router { // Force la construction des registres au démarrage (doublons détectés tôt). LazyLock::force(&ACTIONS); LazyLock::force(&PAGES); Router::new() .route("/api/actions/{name}", post(call_action)) .route("/api/pages/{name}", get(call_page)) }