jacquard_common/
session.rs

1//! Generic session storage traits and utilities.
2
3use miette::Diagnostic;
4use serde::Serialize;
5use serde::de::DeserializeOwned;
6use serde_json::Value;
7use std::collections::HashMap;
8use std::error::Error as StdError;
9use std::fmt::Display;
10use std::future::Future;
11use std::hash::Hash;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use tokio::sync::RwLock;
15
16/// Errors emitted by session stores.
17#[derive(Debug, thiserror::Error, Diagnostic)]
18pub enum SessionStoreError {
19    /// Filesystem or I/O error
20    #[error("I/O error: {0}")]
21    #[diagnostic(code(jacquard::session_store::io))]
22    Io(#[from] std::io::Error),
23    /// Serialization error (e.g., JSON)
24    #[error("serialization error: {0}")]
25    #[diagnostic(code(jacquard::session_store::serde))]
26    Serde(#[from] serde_json::Error),
27    /// Any other error from a backend implementation
28    #[error(transparent)]
29    #[diagnostic(code(jacquard::session_store::other))]
30    Other(#[from] Box<dyn StdError + Send + Sync>),
31}
32
33/// Pluggable storage for arbitrary session records.
34#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]
35pub trait SessionStore<K, T>: Send + Sync
36where
37    K: Eq + Hash,
38    T: Clone,
39{
40    /// Get the current session if present.
41    fn get(&self, key: &K) -> impl Future<Output = Option<T>>;
42    /// Persist the given session.
43    fn set(&self, key: K, session: T) -> impl Future<Output = Result<(), SessionStoreError>>;
44    /// Delete the given session.
45    fn del(&self, key: &K) -> impl Future<Output = Result<(), SessionStoreError>>;
46}
47
48/// In-memory session store suitable for short-lived sessions and tests.
49#[derive(Clone)]
50pub struct MemorySessionStore<K, T>(Arc<RwLock<HashMap<K, T>>>);
51
52impl<K, T> Default for MemorySessionStore<K, T> {
53    fn default() -> Self {
54        Self(Arc::new(RwLock::new(HashMap::new())))
55    }
56}
57
58impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T>
59where
60    K: Eq + Hash + Send + Sync,
61    T: Clone + Send + Sync + 'static,
62{
63    async fn get(&self, key: &K) -> Option<T> {
64        self.0.read().await.get(key).cloned()
65    }
66    async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
67        self.0.write().await.insert(key, session);
68        Ok(())
69    }
70    async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
71        self.0.write().await.remove(key);
72        Ok(())
73    }
74}
75
76/// File-backed token store using a JSON file.
77///
78/// NOT secure, only suitable for development.
79///
80/// Example
81/// ```ignore
82/// use jacquard::client::{AtClient, FileTokenStore};
83/// let base = url::Url::parse("https://bsky.social").unwrap();
84/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
85/// let client = AtClient::new(reqwest::Client::new(), base, store);
86/// ```
87#[derive(Clone, Debug)]
88pub struct FileTokenStore {
89    /// Path to the JSON file.
90    pub path: PathBuf,
91}
92
93impl FileTokenStore {
94    /// Create a new file token store at the given path.
95    pub fn new(path: impl AsRef<Path>) -> Self {
96        std::fs::create_dir_all(path.as_ref().parent().unwrap()).unwrap();
97        std::fs::write(path.as_ref(), b"{}").unwrap();
98
99        Self {
100            path: path.as_ref().to_path_buf(),
101        }
102    }
103}
104
105impl<
106    K: Eq + Hash + Display + Send + Sync + 'static,
107    T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
108> SessionStore<K, T> for FileTokenStore
109{
110    /// Get the current session if present.
111    async fn get(&self, key: &K) -> Option<T> {
112        let file = std::fs::read_to_string(&self.path).ok()?;
113        let store: Value = serde_json::from_str(&file).ok()?;
114
115        let session = store.get(key.to_string())?;
116        serde_json::from_value(session.clone()).ok()
117    }
118    /// Persist the given session.
119    async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
120        let file = std::fs::read_to_string(&self.path)?;
121        let mut store: Value = serde_json::from_str(&file)?;
122        let key_string = key.to_string();
123        if let Some(store) = store.as_object_mut() {
124            store.insert(key_string, serde_json::to_value(session.clone())?);
125
126            std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
127            Ok(())
128        } else {
129            Err(SessionStoreError::Other("invalid store".into()))
130        }
131    }
132    /// Delete the given session.
133    async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
134        let file = std::fs::read_to_string(&self.path)?;
135        let mut store: Value = serde_json::from_str(&file)?;
136        let key_string = key.to_string();
137        if let Some(store) = store.as_object_mut() {
138            store.remove(&key_string);
139
140            std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
141            Ok(())
142        } else {
143            Err(SessionStoreError::Other("invalid store".into()))
144        }
145    }
146}