1//! Generic session storage traits and utilities.
2
3use async_trait::async_trait;
4use miette::Diagnostic;
5use serde::Serialize;
6use serde::de::DeserializeOwned;
7use serde_json::Value;
8use std::collections::HashMap;
9use std::error::Error as StdError;
10use std::fmt::Display;
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#[async_trait]
35pub trait SessionStore<K, T>: Send + Sync
36where
37 K: Eq + Hash,
38 T: Clone,
39{
40 /// Get the current session if present.
41 async fn get(&self, key: &K) -> Option<T>;
42 /// Persist the given session.
43 async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError>;
44 /// Delete the given session.
45 async fn del(&self, key: &K) -> 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
58#[async_trait]
59impl<K, T> SessionStore<K, T> for MemorySessionStore<K, T>
60where
61 K: Eq + Hash + Send + Sync,
62 T: Clone + Send + Sync + 'static,
63{
64 async fn get(&self, key: &K) -> Option<T> {
65 self.0.read().await.get(key).cloned()
66 }
67 async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
68 self.0.write().await.insert(key, session);
69 Ok(())
70 }
71 async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
72 self.0.write().await.remove(key);
73 Ok(())
74 }
75}
76
77/// File-backed token store using a JSON file.
78///
79/// NOT secure, only suitable for development.
80///
81/// Example
82/// ```ignore
83/// use jacquard::client::{AtClient, FileTokenStore};
84/// let base = url::Url::parse("https://bsky.social").unwrap();
85/// let store = FileTokenStore::new("/tmp/jacquard-session.json");
86/// let client = AtClient::new(reqwest::Client::new(), base, store);
87/// ```
88#[derive(Clone, Debug)]
89pub struct FileTokenStore {
90 /// Path to the JSON file.
91 pub path: PathBuf,
92}
93
94impl FileTokenStore {
95 /// Create a new file token store at the given path.
96 pub fn new(path: impl AsRef<Path>) -> Self {
97 std::fs::create_dir_all(path.as_ref().parent().unwrap()).unwrap();
98 std::fs::write(path.as_ref(), b"{}").unwrap();
99
100 Self {
101 path: path.as_ref().to_path_buf(),
102 }
103 }
104}
105
106#[async_trait::async_trait]
107impl<
108 K: Eq + Hash + Display + Send + Sync + 'static,
109 T: Clone + Serialize + DeserializeOwned + Send + Sync + 'static,
110> SessionStore<K, T> for FileTokenStore
111{
112 /// Get the current session if present.
113 async fn get(&self, key: &K) -> Option<T> {
114 let file = std::fs::read_to_string(&self.path).ok()?;
115 let store: Value = serde_json::from_str(&file).ok()?;
116
117 let session = store.get(key.to_string())?;
118 serde_json::from_value(session.clone()).ok()
119 }
120 /// Persist the given session.
121 async fn set(&self, key: K, session: T) -> Result<(), SessionStoreError> {
122 let file = std::fs::read_to_string(&self.path)?;
123 let mut store: Value = serde_json::from_str(&file)?;
124 let key_string = key.to_string();
125 if let Some(store) = store.as_object_mut() {
126 store.insert(key_string, serde_json::to_value(session.clone())?);
127
128 std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
129 Ok(())
130 } else {
131 Err(SessionStoreError::Other("invalid store".into()))
132 }
133 }
134 /// Delete the given session.
135 async fn del(&self, key: &K) -> Result<(), SessionStoreError> {
136 let file = std::fs::read_to_string(&self.path)?;
137 let mut store: Value = serde_json::from_str(&file)?;
138 let key_string = key.to_string();
139 if let Some(store) = store.as_object_mut() {
140 store.remove(&key_string);
141
142 std::fs::write(&self.path, serde_json::to_string_pretty(&store)?)?;
143 Ok(())
144 } else {
145 Err(SessionStoreError::Other("invalid store".into()))
146 }
147 }
148}