arcature 2026.2.1

Arcature application framework: a high-level Application facade over the certified Arcature subsystems, with the low-level Axum/Tower escape hatch preserved.
Documentation
//! `Flash` — one-time session messages (A9).
//!
//! Flash messages are stored in the session for one request: the handler
//! writes a message (e.g. "Profile updated"), redirects, and the next
//! request reads and clears the flash data. This is the standard PRG
//! (Post-Redirect-Get) flash pattern.
//!
//! `Flash` is a genuine Axum `FromRequestParts` extractor. On extraction,
//! it reads the flash messages from the session and clears them. The
//! handler can write new messages via `flash.success()`, `flash.error()`,
//! etc. — these persist in the session and are read by the next request's
//! `Flash` extractor.
//!
//! # Example
//!
//! ```ignore
//! async fn store(input: Validated<CreateLink>, flash: Flash) -> Result<Redirect> {
//!     // ... create the link ...
//!     flash.success("Link created").await?;
//!     redirect!(route::links::index())
//! }
//!
//! async fn index(flash: Flash) -> Result<Page<LinksPage>> {
//!     let messages = flash.messages();
//!     // pass messages to the view
//! }
//! ```

use std::convert::Infallible;

use axum::extract::FromRequestParts;
use serde::{Deserialize, Serialize};

/// One-time session messages.
pub struct Flash {
    session: crate::auth::tower_sessions::Session,
    messages: Vec<FlashMessage>,
}

/// A single flash message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlashMessage {
    /// The severity level.
    pub level: FlashLevel,
    /// The message text.
    pub message: String,
}

/// The severity level of a flash message.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FlashLevel {
    /// A success message (green).
    Success,
    /// An error message (red).
    Error,
    /// A warning message (yellow).
    Warning,
    /// An informational message (blue).
    Info,
}

/// The session key under which flash messages are stored.
const FLASH_KEY: &str = "_flash";

impl Flash {
    /// Get the flash messages read from the session (already cleared).
    pub fn messages(&self) -> &[FlashMessage] {
        &self.messages
    }

    /// True if there are no flash messages.
    pub fn is_empty(&self) -> bool {
        self.messages.is_empty()
    }

    /// Add a success flash message. Persists in the session for the next
    /// request.
    pub async fn success(&self, message: &str) -> Result<(), FlashError> {
        self.add(FlashLevel::Success, message).await
    }

    /// Add an error flash message.
    pub async fn error(&self, message: &str) -> Result<(), FlashError> {
        self.add(FlashLevel::Error, message).await
    }

    /// Add a warning flash message.
    pub async fn warning(&self, message: &str) -> Result<(), FlashError> {
        self.add(FlashLevel::Warning, message).await
    }

    /// Add an info flash message.
    pub async fn info(&self, message: &str) -> Result<(), FlashError> {
        self.add(FlashLevel::Info, message).await
    }

    async fn add(&self, level: FlashLevel, message: &str) -> Result<(), FlashError> {
        let mut messages: Vec<FlashMessage> = self
            .session
            .get(FLASH_KEY)
            .await
            .map_err(|e| FlashError::Session(e.to_string()))?
            .unwrap_or_default();
        messages.push(FlashMessage {
            level,
            message: message.to_string(),
        });
        self.session
            .insert(FLASH_KEY, &messages)
            .await
            .map_err(|e| FlashError::Session(e.to_string()))?;
        Ok(())
    }
}

impl<S> FromRequestParts<S> for Flash
where
    S: Send + Sync,
{
    type Rejection = Infallible;

    async fn from_request_parts(
        parts: &mut axum::http::request::Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        let session = crate::auth::tower_sessions::Session::from_request_parts(parts, state)
            .await
            .map_err(|_| unreachable!("Session extraction is infallible"))?;

        // Read and clear flash messages. If the session read fails, start
        // with an empty flash — the handler can still write new messages.
        let messages: Vec<FlashMessage> = session
            .get(FLASH_KEY)
            .await
            .map_err(|e| FlashError::Session(e.to_string()))
            .unwrap_or(None)
            .unwrap_or_default();

        // Clear the flash from the session. If this fails, the old messages
        // may persist to the next request — not ideal but not a crash.
        let _ = session.remove::<Vec<FlashMessage>>(FLASH_KEY).await;

        Ok(Flash { session, messages })
    }
}

/// A typed error from flash operations.
#[derive(Debug)]
pub enum FlashError {
    /// A session operation failed.
    Session(String),
}

impl std::fmt::Display for FlashError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Session(msg) => write!(f, "session error: {msg}"),
        }
    }
}

impl std::error::Error for FlashError {}