Skip to main content

jacquard_common/
session.rs

1//! Generic session storage traits and utilities.
2
3use alloc::boxed::Box;
4#[cfg(feature = "std")]
5use alloc::string::ToString;
6use alloc::sync::Arc;
7use core::error::Error as StdError;
8#[cfg(feature = "std")]
9use core::fmt::Display;
10use core::future::Future;
11use core::hash::Hash;
12use hashbrown::HashMap;
13#[cfg(feature = "std")]
14use miette::Diagnostic;
15use serde::Serialize;
16use serde::de::DeserializeOwned;
17use serde_json::Value;
18
19#[cfg(feature = "std")]
20use std::path::{Path, PathBuf};
21
22// Use tokio's RwLock with std, maitake-sync's async RwLock for no_std
23#[cfg(not(feature = "std"))]
24use maitake_sync::RwLock;
25#[cfg(feature = "std")]
26use tokio::sync::RwLock;
27
28/// Errors emitted by session stores.
29#[derive(Debug, thiserror::Error)]
30#[cfg_attr(feature = "std", derive(Diagnostic))]
31#[non_exhaustive]
32pub enum SessionStoreError {
33    /// Filesystem or I/O error
34    #[cfg(feature = "std")]
35    #[error("I/O error: {0}")]
36    #[cfg_attr(feature = "std", diagnostic(code(jacquard::session_store::io)))]
37    Io(#[from] std::io::Error),
38    /// Serialization error (e.g., JSON)
39    #[error("serialization error: {0}")]
40    #[cfg_attr(feature = "std", diagnostic(code(jacquard::session_store::serde)))]
41    Serde(#[from] serde_json::Error),
42    /// Any other error from a backend implementation
43    #[error(transparent)]
44    #[cfg_attr(feature = "std", diagnostic(code(jacquard::session_store::other)))]
45    Other(#[from] Box<dyn StdError + Send + Sync>),
46}
47
48/// Pluggable storage for arbitrary session records.
49#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
50pub trait SessionStore<K, T>: Send + Sync
51where
52    K: Eq + Hash,
53    T: Clone,
54{
55    /// Get the current session if present.
56    fn get(&self, key: &K) -> impl Future<Output = Option<T>>;
57    /// Persist the given session.
58    fn set(&self, key: K, session: T) -> impl Future<Output = Result<(), SessionStoreError>>;
59    /// Delete the given session.
60    fn del(&self, key: &K) -> impl Future<Output = Result<(), SessionStoreError>>;
61}
62
63/// In-memory session store suitable for short-lived sessions and tests.
64#[derive(Clone)]
65pub struct MemorySessionStore<K, T>(Arc<RwLock<HashMap<K, T>>>);
66
67impl<K, T> Default for MemorySessionStore<K, T> {
68    fn default() -> Self {
69        Self(Arc::new(RwLock::new(HashMap::new())))
70    }
71}
72
73impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T>
74where
75    K: Eq + Hash + Send + Sync,
76    T: Clone + Send + Sync,
77{
78    async fn get(&self, key: &K) -> Option<T> {
79        self.0.read().await.get(key).cloned()
80    }
81    async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
82        self.0.write().await.insert(key, session);
83        Ok(())
84    }
85    async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
86        self.0.write().await.remove(key);
87        Ok(())
88    }
89}
90
91/// File-backed token store using a JSON file.
92///
93/// NOT secure, only suitable for development.
94///
95/// Example
96/// ```ignore
97/// use jacquard::client::{AtClient, FileTokenStore};
98/// let base = jacquard_common::deps::fluent_uri::Uri::parse("https://bsky.social").unwrap().to_owned();
99/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
100/// let client = AtClient::new(reqwest::Client::new(), base, store);
101/// ```
102#[cfg(feature = "std")]
103#[derive(Clone, Debug)]
104pub struct FileTokenStore {
105    /// Path to the JSON file.
106    pub path: PathBuf,
107}
108
109#[cfg(feature = "std")]
110impl FileTokenStore {
111    /// Create a new file token store at the given path.
112    ///
113    /// Creates parent directories and initializes an empty JSON object if the file doesn't exist.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if:
118    /// - Parent directories cannot be created
119    /// - The file cannot be written
120    pub fn try_new(path: impl AsRef<Path>) -> Result<Self, SessionStoreError> {
121        let path = path.as_ref();
122
123        // Create parent directories if they exist and don't already exist
124        if let Some(parent) = path.parent() {
125            if !parent.as_os_str().is_empty() && !parent.exists() {
126                std::fs::create_dir_all(parent)?;
127            }
128        }
129
130        // Initialize empty JSON object if file doesn't exist
131        if !path.exists() {
132            std::fs::write(path, b"{}")?;
133        }
134
135        Ok(Self {
136            path: path.to_path_buf(),
137        })
138    }
139
140    /// Create a new file token store at the given path.
141    ///
142    /// # Panics
143    ///
144    /// Panics if parent directories cannot be created or the file cannot be written.
145    /// Prefer [`try_new`](Self::try_new) for fallible construction.
146    pub fn new(path: impl AsRef<Path>) -> Self {
147        Self::try_new(path).expect("failed to initialize FileTokenStore")
148    }
149}
150
151#[cfg(feature = "std")]
152impl<K: Eq + Hash + Display + Send + Sync, T: Clone + Serialize + DeserializeOwned + Send + Sync>
153    SessionStore<K, T> for FileTokenStore
154{
155    /// Get the current session if present.
156    async fn get(&self, key: &K) -> Option<T> {
157        let file = std::fs::read_to_string(&self.path).ok()?;
158        let store: Value = serde_json::from_str(&file).ok()?;
159
160        let session = store.get(key.to_string())?;
161        serde_json::from_value(session.clone()).ok()
162    }
163    /// Persist the given session.
164    async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
165        let file = std::fs::read_to_string(&self.path)?;
166        let mut store: Value = serde_json::from_str(&file)?;
167        let key_string = key.to_string();
168        if let Some(store) = store.as_object_mut() {
169            store.insert(key_string, serde_json::to_value(session.clone())?);
170
171            std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
172            Ok(())
173        } else {
174            Err(SessionStoreError::Other("invalid store".into()))
175        }
176    }
177    /// Delete the given session.
178    async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
179        let file = std::fs::read_to_string(&self.path)?;
180        let mut store: Value = serde_json::from_str(&file)?;
181        let key_string = key.to_string();
182        if let Some(store) = store.as_object_mut() {
183            store.remove(&key_string);
184
185            std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
186            Ok(())
187        } else {
188            Err(SessionStoreError::Other("invalid store".into()))
189        }
190    }
191}