lmrc-http-common 0.3.16

Common HTTP utilities and patterns for LMRC Stack applications
Documentation
//! Session management types and utilities

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Session information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
    /// Session token/ID
    pub token: String,
    /// User ID associated with this session
    pub user_id: String,
    /// User email
    pub email: String,
    /// Session creation time
    pub created_at: DateTime<Utc>,
    /// Session expiration time
    pub expires_at: DateTime<Utc>,
    /// Optional user metadata
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<serde_json::Value>,
}

impl SessionInfo {
    /// Create a new session
    pub fn new(user_id: impl Into<String>, email: impl Into<String>, expires_at: DateTime<Utc>) -> Self {
        Self {
            token: Uuid::new_v4().to_string(),
            user_id: user_id.into(),
            email: email.into(),
            created_at: Utc::now(),
            expires_at,
            metadata: None,
        }
    }

    /// Create with specific token
    pub fn with_token(
        token: impl Into<String>,
        user_id: impl Into<String>,
        email: impl Into<String>,
        expires_at: DateTime<Utc>,
    ) -> Self {
        Self {
            token: token.into(),
            user_id: user_id.into(),
            email: email.into(),
            created_at: Utc::now(),
            expires_at,
            metadata: None,
        }
    }

    /// Add metadata to session
    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
        self.metadata = Some(metadata);
        self
    }

    /// Check if session is expired
    pub fn is_expired(&self) -> bool {
        Utc::now() > self.expires_at
    }

    /// Get remaining session time in seconds
    pub fn remaining_seconds(&self) -> i64 {
        (self.expires_at - Utc::now()).num_seconds().max(0)
    }
}

/// Trait for session storage backends
#[async_trait::async_trait]
pub trait SessionStore: Send + Sync {
    /// Store a new session
    async fn create(&self, session: &SessionInfo) -> Result<(), SessionStoreError>;

    /// Get a session by token
    async fn get(&self, token: &str) -> Result<Option<SessionInfo>, SessionStoreError>;

    /// Update an existing session
    async fn update(&self, session: &SessionInfo) -> Result<(), SessionStoreError>;

    /// Delete a session by token
    async fn delete(&self, token: &str) -> Result<(), SessionStoreError>;

    /// Delete all sessions for a user
    async fn delete_user_sessions(&self, user_id: &str) -> Result<(), SessionStoreError>;

    /// Clean up expired sessions
    async fn cleanup_expired(&self) -> Result<u64, SessionStoreError>;
}

/// Session storage errors
#[derive(Debug, thiserror::Error)]
pub enum SessionStoreError {
    #[error("Session not found")]
    NotFound,

    #[error("Session expired")]
    Expired,

    #[error("Database error: {0}")]
    Database(String),

    #[error("Serialization error: {0}")]
    Serialization(String),

    #[error("Internal error: {0}")]
    Internal(String),
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::Duration;

    #[test]
    fn test_session_creation() {
        let expires_at = Utc::now() + Duration::hours(1);
        let session = SessionInfo::new("user_123", "user@example.com", expires_at);

        assert_eq!(session.user_id, "user_123");
        assert_eq!(session.email, "user@example.com");
        assert!(!session.is_expired());
    }

    #[test]
    fn test_session_expiration() {
        let expires_at = Utc::now() - Duration::hours(1);
        let session = SessionInfo::new("user_123", "user@example.com", expires_at);

        assert!(session.is_expired());
        assert_eq!(session.remaining_seconds(), 0);
    }

    #[test]
    fn test_session_with_metadata() {
        let expires_at = Utc::now() + Duration::hours(1);
        let session = SessionInfo::new("user_123", "user@example.com", expires_at)
            .with_metadata(serde_json::json!({"role": "admin"}));

        assert!(session.metadata.is_some());
    }
}