A better Rust ATProto crate
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}