A better Rust ATProto crate
at main 191 lines 6.3 kB view raw
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 = url::Url::parse("https://bsky.social").unwrap(); 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}