Skip to main content

grabcli_core/
store.rs

1//! Where a [`Session`] lives. The library never picks a path: the embedder
2//! passes an `Arc<dyn SessionStore>` to [`Context::new`](crate::Context::new)
3//! and the context loads the session once, then saves every change it makes
4//! (silent SSO refresh, hydra mint, GrabMart login, address changes) through
5//! it.
6//!
7//! The trait is async (via `async_trait`) because [`Context`](crate::Context)
8//! is async and embedders may back it with an async database client; calling
9//! a blocking store from inside the context would need a nested runtime. A
10//! file-backed store simply does its (small) blocking IO inside the future.
11//!
12//! The CLI's `FileSessionStore` (`session.json`, mode 0600) lives in the
13//! `grabcli` binary crate; [`MemorySessionStore`] is for tests and embedders
14//! that persist elsewhere.
15
16use std::sync::Mutex;
17use std::sync::atomic::{AtomicUsize, Ordering};
18
19use async_trait::async_trait;
20
21use crate::error::Result;
22use crate::session::Session;
23
24/// Loads and saves the one Grab session a [`Context`](crate::Context) uses.
25/// Implementations must never log cookie or token values.
26#[async_trait]
27pub trait SessionStore: Send + Sync {
28    /// The stored session, or `None` if there is none yet.
29    async fn load(&self) -> Result<Option<Session>>;
30    /// Replace the stored session.
31    async fn save(&self, session: &Session) -> Result<()>;
32}
33
34/// A session held in memory. Counts saves (handy in tests).
35#[derive(Default)]
36pub struct MemorySessionStore {
37    session: Mutex<Option<Session>>,
38    saves: AtomicUsize,
39}
40
41impl std::fmt::Debug for MemorySessionStore {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("MemorySessionStore")
44            .field("saves", &self.save_count())
45            .finish_non_exhaustive()
46    }
47}
48
49impl MemorySessionStore {
50    pub fn new(session: Option<Session>) -> Self {
51        Self {
52            session: Mutex::new(session),
53            saves: AtomicUsize::new(0),
54        }
55    }
56
57    /// The current session (a clone).
58    pub fn get(&self) -> Option<Session> {
59        self.session
60            .lock()
61            .unwrap_or_else(std::sync::PoisonError::into_inner)
62            .clone()
63    }
64
65    /// How many times [`SessionStore::save`] was called.
66    pub fn save_count(&self) -> usize {
67        self.saves.load(Ordering::SeqCst)
68    }
69}
70
71#[async_trait]
72impl SessionStore for MemorySessionStore {
73    async fn load(&self) -> Result<Option<Session>> {
74        Ok(self.get())
75    }
76
77    async fn save(&self, session: &Session) -> Result<()> {
78        *self
79            .session
80            .lock()
81            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(session.clone());
82        self.saves.fetch_add(1, Ordering::SeqCst);
83        Ok(())
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::sync::Arc;
91
92    #[tokio::test]
93    async fn memory_store_roundtrip() -> Result<()> {
94        let store: Arc<dyn SessionStore> = Arc::new(MemorySessionStore::default());
95        assert!(store.load().await?.is_none());
96        let s = Session {
97            web_app_version: Some("v1".into()),
98            ..Session::default()
99        };
100        store.save(&s).await?;
101        let back = store
102            .load()
103            .await?
104            .ok_or_else(|| crate::Error::invalid("none"))?;
105        assert_eq!(back.web_app_version.as_deref(), Some("v1"));
106        Ok(())
107    }
108}