From c952d8a7de6e257e8144c11d78339d61895ff121 Mon Sep 17 00:00:00 2001 From: Chrislearn Young Date: Fri, 2 Jan 2026 19:01:52 +0800 Subject: [PATCH 1/4] First custom version (#1) - Use tokio for test and doc - Use postcard for bincode is deprecated. --- Cargo.toml | 45 ++++----- README.md | 49 ++-------- src/cookie_store.rs | 25 ++--- src/lib.rs | 19 +--- src/memory_store.rs | 43 +++++---- src/session.rs | 212 +++++++++++++++++++++++++------------------ src/session_store.rs | 2 +- tests/test.rs | 1 - 8 files changed, 194 insertions(+), 202 deletions(-) delete mode 100644 tests/test.rs diff --git a/Cargo.toml b/Cargo.toml index 7e9442a..a0bcc0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,40 +1,35 @@ [package] -name = "async-session" -version = "3.0.0" +name = "saysion" +version = "0.1.0" license = "MIT OR Apache-2.0" -repository = "https://github.com/http-rs/async-session" -documentation = "https://docs.rs/async-session" +repository = "https://github.com/salvo-rs/saysion" +documentation = "https://docs.rs/saysion" description = "Async session support with pluggable stores" readme = "README.md" -edition = "2021" +edition = "2024" +rust-version = "1.89" keywords = [] categories = [] authors = [ "Yoshua Wuyts ", - "Jacob Rothstein " + "Jacob Rothstein ", + "chrislearn " ] [dependencies] async-trait = "0.1.59" -rand = "0.8.5" -base64 = "0.20.0" -sha2 = "0.10.6" +rand = "0.9.2" +base64 = "0.22.1" +sha2 = "0.10.9" hmac = "0.12.1" +serde = { version = "1.0.150", features = ["derive", "rc"] } serde_json = "1.0.89" -bincode = "1.3.3" -anyhow = "1.0.66" -blake3 = "1.3.3" -async-lock = "2.6.0" -log = "0.4.17" +postcard = { version = "1.1.3", default-features = false, features = ["use-std"] } +anyhow = "1.0.100" +blake3 = "1.8.2" +async-lock = "3.4.2" +time = { version = "0.3.17", features = ["serde"] } +tracing = "0.1.44" -[dependencies.serde] -version = "1.0.150" -features = ["rc", "derive"] - -[dependencies.time] -version = "0.3.17" -features = ["serde"] - -[dev-dependencies.async-std] -version = "1.12.0" -features = ["attributes"] +[dev-dependencies] +tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread", "time"] } \ No newline at end of file diff --git a/README.md b/README.md index 50bad23..6c023fe 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,30 @@ -

async-session

+

saysion

- Async session support with pluggable middleware + Session support with pluggable middleware

-
- - - Crates.io version - - - - Download - - - - docs.rs docs - -
- -## Available session stores - -* [async-sqlx-session](https://crates.io/crates/async-sqlx-session) postgres, mysql & sqlite -* [async-redis-session](https://crates.io/crates/async-redis-session) -* [async-mongodb-session](https://crates.io/crates/async-mongodb-session) -* [async-session-r2d2](https://crates.io/crates/async-session-r2d2) - sqlite only -## Framework implementations - -* [`tide::sessions`](https://docs.rs/tide/latest/tide/sessions/index.html) -* [warp-sessions](https://docs.rs/warp-sessions/latest/warp_sessions/) -* [trillium-sessions](https://docs.trillium.rs/trillium_sessions) -* [axum-sessions](https://docs.rs/axum_sessions) -* [salvo-sessions](https://docs.rs/salvo_extra/latest/salvo_extra/session/index.html) +*Fork from: https://github.com/http-rs/async-session* ## Safety This crate uses ``#![deny(unsafe_code)]`` to ensure everything is implemented in @@ -64,13 +34,6 @@ This crate uses ``#![deny(unsafe_code)]`` to ensure everything is implemented in Want to join us? Check out our ["Contributing" guide][contributing] and take a look at some of these issues: -- [Issues labeled "good first issue"][good-first-issue] -- [Issues labeled "help wanted"][help-wanted] - -[contributing]: https://github.com/http-rs/async-session/blob/main/.github/CONTRIBUTING.md -[good-first-issue]: https://github.com/http-rs/async-session/labels/good%20first%20issue -[help-wanted]: https://github.com/http-rs/async-session/labels/help%20wanted - ## Acknowledgements This work is based on the work initiated by diff --git a/src/cookie_store.rs b/src/cookie_store.rs index ff6eaa5..64dea63 100644 --- a/src/cookie_store.rs +++ b/src/cookie_store.rs @@ -1,4 +1,5 @@ -use crate::{async_trait, Result, Session, SessionStore}; +use crate::{Result, Session, SessionStore, async_trait}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; /// A session store that serializes the entire session into a Cookie. /// @@ -32,14 +33,14 @@ impl CookieStore { #[async_trait] impl SessionStore for CookieStore { async fn load_session(&self, cookie_value: String) -> Result> { - let serialized = base64::decode(cookie_value)?; - let session: Session = bincode::deserialize(&serialized)?; + let serialized = BASE64_STANDARD.decode(cookie_value)?; + let session: Session = postcard::from_bytes(&serialized)?; Ok(session.validate()) } async fn store_session(&self, session: Session) -> Result> { - let serialized = bincode::serialize(&session)?; - Ok(Some(base64::encode(serialized))) + let serialized = postcard::to_stdvec(&session)?; + Ok(Some(BASE64_STANDARD.encode(serialized))) } async fn destroy_session(&self, _session: Session) -> Result { @@ -54,9 +55,9 @@ impl SessionStore for CookieStore { #[cfg(test)] mod tests { use super::*; - use async_std::task; use std::time::Duration; - #[async_std::test] + + #[tokio::test] async fn creating_a_new_session_with_no_expiry() -> Result { let store = CookieStore::new(); let mut session = Session::new(); @@ -71,7 +72,7 @@ mod tests { Ok(()) } - #[async_std::test] + #[tokio::test] async fn updating_a_session() -> Result { let store = CookieStore::new(); let mut session = Session::new(); @@ -89,7 +90,7 @@ mod tests { Ok(()) } - #[async_std::test] + #[tokio::test] async fn updating_a_session_extending_expiry() -> Result { let store = CookieStore::new(); let mut session = Session::new(); @@ -107,13 +108,13 @@ mod tests { let session = store.load_session(cookie_value.clone()).await?.unwrap(); assert_eq!(session.expiry().unwrap(), &new_expires); - task::sleep(Duration::from_secs(3)).await; + tokio::time::sleep(Duration::from_secs(3)).await; assert_eq!(None, store.load_session(cookie_value).await?); Ok(()) } - #[async_std::test] + #[tokio::test] async fn creating_a_new_session_with_expiry() -> Result { let store = CookieStore::new(); let mut session = Session::new(); @@ -129,7 +130,7 @@ mod tests { assert!(!loaded_session.is_expired()); - task::sleep(Duration::from_secs(3)).await; + tokio::time::sleep(Duration::from_secs(3)).await; assert_eq!(None, store.load_session(cookie_value).await?); Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 4c1d222..168fb19 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,10 +8,10 @@ //! # Example //! //! ``` -//! use async_session::{Session, SessionStore, MemoryStore}; +//! use saysion::{Session, SessionStore, MemoryStore}; //! -//! # fn main() -> async_session::Result { -//! # async_std::task::block_on(async { +//! # #[tokio::main] +//! # async fn main() -> saysion::Result { //! # //! // Init a new session store we can persist sessions to. //! let mut store = MemoryStore::new(); @@ -29,7 +29,7 @@ //! assert_eq!(session.get::("user_id").unwrap(), 1); //! assert!(!session.data_changed()); //! # -//! # Ok(()) }) } +//! # Ok(()) } //! ``` // #![forbid(unsafe_code, future_incompatible)] @@ -47,6 +47,7 @@ )] pub use anyhow::Error; +pub use async_trait::async_trait; /// An anyhow::Result with default return type of () pub type Result = std::result::Result; @@ -59,13 +60,3 @@ pub use cookie_store::CookieStore; pub use memory_store::MemoryStore; pub use session::Session; pub use session_store::SessionStore; - -pub use async_trait::async_trait; -pub use base64; -pub use blake3; -pub use hmac; -pub use log; -pub use serde; -pub use serde_json; -pub use sha2; -pub use time; diff --git a/src/memory_store.rs b/src/memory_store.rs index ce5fd10..791ba84 100644 --- a/src/memory_store.rs +++ b/src/memory_store.rs @@ -1,7 +1,9 @@ -use crate::{async_trait, log, Result, Session, SessionStore}; -use async_lock::RwLock; use std::{collections::HashMap, sync::Arc}; +use async_lock::RwLock; + +use crate::{Result, Session, SessionStore, async_trait}; + /// # in-memory session store /// Because there is no external /// persistance, this session store is ephemeral and will be cleared @@ -35,7 +37,7 @@ pub struct MemoryStore { impl SessionStore for MemoryStore { async fn load_session(&self, cookie_value: String) -> Result> { let id = Session::id_from_cookie_value(&cookie_value)?; - log::trace!("loading session by id `{}`", id); + tracing::debug!("loading session by id `{}`", id); Ok(self .inner .read() @@ -46,7 +48,7 @@ impl SessionStore for MemoryStore { } async fn store_session(&self, session: Session) -> Result> { - log::trace!("storing session by id `{}`", session.id()); + tracing::debug!("storing session by id `{}`", session.id()); self.inner .write() .await @@ -57,13 +59,13 @@ impl SessionStore for MemoryStore { } async fn destroy_session(&self, session: Session) -> Result { - log::trace!("destroying session by id `{}`", session.id()); + tracing::debug!("destroying session by id `{}`", session.id()); self.inner.write().await.remove(session.id()); Ok(()) } async fn clear_store(&self) -> Result { - log::trace!("clearing memory store"); + tracing::debug!("clearing memory store"); self.inner.write().await.clear(); Ok(()) } @@ -79,7 +81,7 @@ impl MemoryStore { /// intermittent basis if this store is run for long enough that /// memory accumulation is a concern pub async fn cleanup(&self) -> Result { - log::trace!("cleaning up memory store..."); + tracing::debug!("cleaning up memory store..."); let ids_to_delete: Vec<_> = self .inner .read() @@ -94,7 +96,7 @@ impl MemoryStore { }) .collect(); - log::trace!("found {} expired sessions", ids_to_delete.len()); + tracing::debug!("found {} expired sessions", ids_to_delete.len()); for id in ids_to_delete { self.inner.write().await.remove(&id); } @@ -104,13 +106,14 @@ impl MemoryStore { /// returns the number of elements in the memory store /// # Example /// ```rust - /// # use async_session::{MemoryStore, Session, SessionStore}; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::{MemoryStore, Session, SessionStore}; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut store = MemoryStore::new(); /// assert_eq!(store.count().await, 0); /// store.store_session(Session::new()).await?; /// assert_eq!(store.count().await, 1); - /// # Ok(()) }) } + /// # Ok(()) } /// ``` pub async fn count(&self) -> usize { let data = self.inner.read().await; @@ -121,9 +124,9 @@ impl MemoryStore { #[cfg(test)] mod tests { use super::*; - use async_std::task; use std::time::Duration; - #[async_std::test] + + #[tokio::test] async fn creating_a_new_session_with_no_expiry() -> Result { let store = MemoryStore::new(); let mut session = Session::new(); @@ -138,7 +141,7 @@ mod tests { Ok(()) } - #[async_std::test] + #[tokio::test] async fn updating_a_session() -> Result { let store = MemoryStore::new(); let mut session = Session::new(); @@ -156,7 +159,7 @@ mod tests { Ok(()) } - #[async_std::test] + #[tokio::test] async fn updating_a_session_extending_expiry() -> Result { let store = MemoryStore::new(); let mut session = Session::new(); @@ -174,13 +177,13 @@ mod tests { let session = store.load_session(cookie_value.clone()).await?.unwrap(); assert_eq!(session.expiry().unwrap(), &new_expires); - task::sleep(Duration::from_secs(3)).await; + tokio::time::sleep(Duration::from_secs(3)).await; assert_eq!(None, store.load_session(cookie_value).await?); Ok(()) } - #[async_std::test] + #[tokio::test] async fn creating_a_new_session_with_expiry() -> Result { let store = MemoryStore::new(); let mut session = Session::new(); @@ -196,13 +199,13 @@ mod tests { assert!(!loaded_session.is_expired()); - task::sleep(Duration::from_secs(3)).await; + tokio::time::sleep(Duration::from_secs(3)).await; assert_eq!(None, store.load_session(cookie_value).await?); Ok(()) } - #[async_std::test] + #[tokio::test] async fn destroying_a_single_session() -> Result { let store = MemoryStore::new(); for _ in 0..3i8 { @@ -221,7 +224,7 @@ mod tests { Ok(()) } - #[async_std::test] + #[tokio::test] async fn clearing_the_whole_store() -> Result { let store = MemoryStore::new(); for _ in 0..3i8 { diff --git a/src/session.rs b/src/session.rs index df4ba3d..574fa5b 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,13 +1,15 @@ -use rand::RngCore; -use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, convert::TryFrom, sync::{ - atomic::{AtomicBool, Ordering}, Arc, RwLock, + atomic::{AtomicBool, Ordering}, }, }; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; use time::OffsetDateTime as DateTime; /// # The main session type. @@ -27,8 +29,9 @@ use time::OffsetDateTime as DateTime; /// /// ### Change tracking example /// ```rust -/// # use async_session::Session; -/// # fn main() -> async_session::Result { async_std::task::block_on(async { +/// # use saysion::Session; +/// # #[tokio::main] +/// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert!(!session.data_changed()); /// @@ -52,9 +55,9 @@ use time::OffsetDateTime as DateTime; /// assert!(!session.data_changed()); /// session.remove("key"); /// assert!(session.data_changed()); -/// # Ok(()) }) } +/// # Ok(()) } /// ``` -#[derive(Debug, Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Debug)] pub struct Session { id: String, expiry: Option, @@ -90,8 +93,8 @@ impl Default for Session { /// generates a random cookie value fn generate_cookie(len: usize) -> String { let mut key = vec![0u8; len]; - rand::thread_rng().fill_bytes(&mut key); - base64::encode(key) + rand::rng().fill_bytes(&mut key); + BASE64_STANDARD.encode(key) } impl Session { @@ -101,12 +104,15 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let session = Session::new(); /// assert_eq!(None, session.expiry()); /// assert!(session.into_cookie_value().is_some()); - /// # Ok(()) }) } + /// # Ok(()) + /// # } + /// ``` pub fn new() -> Self { let cookie_value = generate_cookie(64); let id = Session::id_from_cookie_value(&cookie_value).unwrap(); @@ -129,18 +135,20 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let session = Session::new(); /// let id = session.id().to_string(); /// let cookie_value = session.into_cookie_value().unwrap(); /// assert_eq!(id, Session::id_from_cookie_value(&cookie_value)?); - /// # Ok(()) }) } + /// # Ok(()) + /// # } /// ``` pub fn id_from_cookie_value(string: &str) -> Result { - let decoded = base64::decode(string)?; + let decoded = BASE64_STANDARD.decode(string)?; let hash = blake3::hash(&decoded); - Ok(base64::encode(hash.as_bytes())) + Ok(BASE64_STANDARD.encode(hash.as_bytes())) } /// mark this session for destruction. the actual session record @@ -149,13 +157,15 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert!(!session.is_destroyed()); /// session.destroy(); /// assert!(session.is_destroyed()); - /// # Ok(()) }) } + /// # Ok(()) } + /// ``` pub fn destroy(&mut self) { self.destroy.store(true, Ordering::SeqCst); } @@ -165,14 +175,15 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert!(!session.is_destroyed()); /// session.destroy(); /// assert!(session.is_destroyed()); - /// # Ok(()) }) } - + /// # Ok(()) } + /// ``` pub fn is_destroyed(&self) -> bool { self.destroy.load(Ordering::SeqCst) } @@ -182,13 +193,15 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let session = Session::new(); /// let id = session.id().to_owned(); /// let cookie_value = session.into_cookie_value().unwrap(); /// assert_eq!(id, Session::id_from_cookie_value(&cookie_value)?); - /// # Ok(()) }) } + /// # Ok(()) } + /// ``` pub fn id(&self) -> &str { &self.id } @@ -200,15 +213,17 @@ impl Session { /// /// ```rust /// # use serde::{Serialize, Deserialize}; - /// # use async_session::Session; + /// # use saysion::Session; /// #[derive(Serialize, Deserialize)] /// struct User { /// name: String, /// legs: u8 /// } + /// # fn main() { /// let mut session = Session::new(); /// session.insert("user", User { name: "chashu".into(), legs: 4 }).expect("serializable"); /// assert_eq!(r#"{"name":"chashu","legs":4}"#, session.get_raw("user").unwrap()); + /// # } /// ``` pub fn insert(&mut self, key: &str, value: impl Serialize) -> Result<(), serde_json::Error> { self.insert_raw(key, serde_json::to_string(&value)?); @@ -220,11 +235,13 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; + /// # use saysion::Session; + /// # fn main() { /// let mut session = Session::new(); /// session.insert_raw("ten", "10".to_string()); /// let ten: usize = session.get("ten").unwrap(); /// assert_eq!(ten, 10); + /// # } /// ``` pub fn insert_raw(&mut self, key: &str, value: String) { let mut data = self.data.write().unwrap(); @@ -239,11 +256,14 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; + /// # use saysion::Session; + /// # fn main() -> saysion::Result { /// let mut session = Session::new(); - /// session.insert("key", vec![1, 2, 3]); + /// session.insert("key", vec![1, 2, 3])?; /// let numbers: Vec = session.get("key").unwrap(); /// assert_eq!(vec![1, 2, 3], numbers); + /// # Ok(()) + /// # } /// ``` pub fn get(&self, key: &str) -> Option { let data = self.data.read().unwrap(); @@ -256,10 +276,13 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; + /// # use saysion::Session; + /// # fn main() -> saysion::Result { /// let mut session = Session::new(); - /// session.insert("key", vec![1, 2, 3]); + /// session.insert("key", vec![1, 2, 3])?; /// assert_eq!("[1,2,3]", session.get_raw("key").unwrap()); + /// # Ok(()) + /// # } /// ``` pub fn get_raw(&self, key: &str) -> Option { let data = self.data.read().unwrap(); @@ -271,12 +294,15 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; + /// # use saysion::Session; + /// # fn main() -> saysion::Result { /// let mut session = Session::new(); - /// session.insert("key", "value"); + /// session.insert("key", "value")?; /// session.remove("key"); /// assert!(session.get_raw("key").is_none()); /// assert_eq!(session.len(), 0); + /// # Ok(()) + /// # } /// ``` pub fn remove(&mut self, key: &str) { let mut data = self.data.write().unwrap(); @@ -290,11 +316,14 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; + /// # use saysion::Session; + /// # fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert_eq!(session.len(), 0); - /// session.insert("key", 0); + /// session.insert("key", 0)?; /// assert_eq!(session.len(), 1); + /// # Ok(()) + /// # } /// ``` pub fn len(&self) -> usize { self.data.read().unwrap().len() @@ -305,11 +334,15 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; + /// # use saysion::Session; + /// # fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert!(session.is_empty()); - /// session.insert("key", 0); + /// session.insert("key", 0)?; /// assert!(!session.is_empty()); + /// # Ok(()) + /// # } + /// ``` pub fn is_empty(&self) -> bool { return self.data.read().unwrap().is_empty(); } @@ -319,8 +352,9 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// let old_id = session.id().to_string(); /// session.regenerate(); @@ -328,7 +362,8 @@ impl Session { /// let new_id = session.id().to_string(); /// let cookie_value = session.into_cookie_value().unwrap(); /// assert_eq!(new_id, Session::id_from_cookie_value(&cookie_value)?); - /// # Ok(()) }) } + /// # Ok(()) + /// # } /// ``` pub fn regenerate(&mut self) { let cookie_value = generate_cookie(64); @@ -344,13 +379,14 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// session.set_cookie_value("hello".to_owned()); /// let cookie_value = session.into_cookie_value().unwrap(); /// assert_eq!(cookie_value, "hello".to_owned()); - /// # Ok(()) }) } + /// # Ok(()) } /// ``` pub fn set_cookie_value(&mut self, cookie_value: String) { self.cookie_value = Some(cookie_value) @@ -361,13 +397,14 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert_eq!(None, session.expiry()); /// session.expire_in(std::time::Duration::from_secs(1)); /// assert!(session.expiry().is_some()); - /// # Ok(()) }) } + /// # Ok(()) } /// ``` pub fn expiry(&self) -> Option<&DateTime> { self.expiry.as_ref() @@ -378,13 +415,14 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert_eq!(None, session.expiry()); /// session.set_expiry(time::OffsetDateTime::now_utc()); /// assert!(session.expiry().is_some()); - /// # Ok(()) }) } + /// # Ok(()) } /// ``` pub fn set_expiry(&mut self, expiry: DateTime) { self.expiry = Some(expiry); @@ -395,13 +433,14 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert_eq!(None, session.expiry()); /// session.expire_in(std::time::Duration::from_secs(1)); /// assert!(session.expiry().is_some()); - /// # Ok(()) }) } + /// # Ok(()) } /// ``` pub fn expire_in(&mut self, ttl: std::time::Duration) { self.expiry = Some(DateTime::now_utc() + ttl); @@ -414,18 +453,19 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; + /// # use saysion::Session; /// # use std::time::Duration; - /// # use async_std::task; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert_eq!(None, session.expiry()); /// assert!(!session.is_expired()); /// session.expire_in(Duration::from_secs(1)); /// assert!(!session.is_expired()); - /// task::sleep(Duration::from_secs(2)).await; + /// tokio::time::sleep(Duration::from_secs(2)).await; /// assert!(session.is_expired()); - /// # Ok(()) }) } + /// # Ok(()) + /// # } /// ``` pub fn is_expired(&self) -> bool { match self.expiry { @@ -439,24 +479,21 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; + /// # use saysion::Session; /// # use std::time::Duration; - /// # use async_std::task; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let session = Session::new(); /// let mut session = session.validate().unwrap(); /// session.expire_in(Duration::from_secs(1)); /// let session = session.validate().unwrap(); - /// task::sleep(Duration::from_secs(2)).await; + /// tokio::time::sleep(Duration::from_secs(2)).await; /// assert_eq!(None, session.validate()); - /// # Ok(()) }) } + /// # Ok(()) + /// # } /// ``` pub fn validate(self) -> Option { - if self.is_expired() { - None - } else { - Some(self) - } + if self.is_expired() { None } else { Some(self) } } /// Checks if the data has been modified. This is based on the @@ -465,18 +502,19 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert!(!session.data_changed(), "new session is not changed"); - /// session.insert("key", 1); + /// session.insert("key", 1)?; /// assert!(session.data_changed()); /// /// session.reset_data_changed(); /// assert!(!session.data_changed()); /// session.remove("key"); /// assert!(session.data_changed()); - /// # Ok(()) }) } + /// # Ok(()) + /// # } /// ``` pub fn data_changed(&self) -> bool { self.data_changed.load(Ordering::Acquire) @@ -489,39 +527,40 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # fn main() -> saysion::Result { /// let mut session = Session::new(); /// assert!(!session.data_changed(), "new session is not changed"); - /// session.insert("key", 1); + /// session.insert("key", 1)?; /// assert!(session.data_changed()); /// /// session.reset_data_changed(); /// assert!(!session.data_changed()); /// session.remove("key"); /// assert!(session.data_changed()); - /// # Ok(()) }) } + /// # Ok(()) + /// # } /// ``` pub fn reset_data_changed(&self) { self.data_changed.store(false, Ordering::SeqCst); } - /// Ensures that this session is not expired. Returns None if it is expired + /// Duration from now to the expiry time of this session /// /// # Example /// /// ```rust - /// # use async_session::Session; + /// # use saysion::Session; /// # use std::time::Duration; - /// # use async_std::task; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// session.expire_in(Duration::from_secs(123)); /// let expires_in = session.expires_in().unwrap(); /// assert!(123 - expires_in.as_secs() < 2); - /// # Ok(()) }) } + /// # Ok(()) + /// # } /// ``` - /// Duration from now to the expiry time of this session pub fn expires_in(&self) -> Option { let dur = self.expiry? - DateTime::now_utc(); if dur.is_negative() { @@ -537,13 +576,14 @@ impl Session { /// # Example /// /// ```rust - /// # use async_session::Session; - /// # fn main() -> async_session::Result { async_std::task::block_on(async { + /// # use saysion::Session; + /// # #[tokio::main] + /// # async fn main() -> saysion::Result { /// let mut session = Session::new(); /// session.set_cookie_value("hello".to_owned()); /// let cookie_value = session.into_cookie_value().unwrap(); /// assert_eq!(cookie_value, "hello".to_owned()); - /// # Ok(()) }) } + /// # Ok(()) } /// ``` pub fn into_cookie_value(mut self) -> Option { self.cookie_value.take() diff --git a/src/session_store.rs b/src/session_store.rs index 5049dea..6503488 100644 --- a/src/session_store.rs +++ b/src/session_store.rs @@ -1,4 +1,4 @@ -use crate::{async_trait, Result, Session}; +use crate::{Result, Session, async_trait}; /// An async session backend. #[async_trait] diff --git a/tests/test.rs b/tests/test.rs deleted file mode 100644 index 8b13789..0000000 --- a/tests/test.rs +++ /dev/null @@ -1 +0,0 @@ - From b6b8959b260fa4b0cf7b1e03162c0a478f614cf1 Mon Sep 17 00:00:00 2001 From: Chrislearn Young Date: Fri, 2 Jan 2026 19:11:13 +0800 Subject: [PATCH 2/4] reexport some libs --- Cargo.toml | 2 +- src/lib.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index a0bcc0d..338f30b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "saysion" -version = "0.1.0" +version = "0.1.1" license = "MIT OR Apache-2.0" repository = "https://github.com/salvo-rs/saysion" documentation = "https://docs.rs/saysion" diff --git a/src/lib.rs b/src/lib.rs index 168fb19..be5d29a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -60,3 +60,9 @@ pub use cookie_store::CookieStore; pub use memory_store::MemoryStore; pub use session::Session; pub use session_store::SessionStore; + +pub use base64; +pub use blake3; +pub use hmac; +pub use sha2; +pub use time; From 02233f702793bdc22de178bd60d0ab7ab184128a Mon Sep 17 00:00:00 2001 From: Chrislearn Young Date: Sat, 3 Jan 2026 20:35:32 +0800 Subject: [PATCH 3/4] update dependencies version --- Cargo.toml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 338f30b..ce17e5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "saysion" -version = "0.1.1" +version = "0.1.2" license = "MIT OR Apache-2.0" repository = "https://github.com/salvo-rs/saysion" documentation = "https://docs.rs/saysion" @@ -17,19 +17,19 @@ authors = [ ] [dependencies] -async-trait = "0.1.59" -rand = "0.9.2" -base64 = "0.22.1" -sha2 = "0.10.9" -hmac = "0.12.1" -serde = { version = "1.0.150", features = ["derive", "rc"] } -serde_json = "1.0.89" -postcard = { version = "1.1.3", default-features = false, features = ["use-std"] } -anyhow = "1.0.100" -blake3 = "1.8.2" -async-lock = "3.4.2" -time = { version = "0.3.17", features = ["serde"] } -tracing = "0.1.44" +async-trait = "0.1" +rand = "0.9" +base64 = "0.22" +sha2 = "0.10" +hmac = "0.12" +serde = { version = "1", features = ["derive", "rc"] } +serde_json = "1" +postcard = { version = "1", default-features = false, features = ["use-std"] } +anyhow = "1" +blake3 = "1" +async-lock = "3" +time = { version = "0.3", features = ["serde"] } +tracing = "0.1" [dev-dependencies] -tokio = { version = "1.36.0", features = ["macros", "rt-multi-thread", "time"] } \ No newline at end of file +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } \ No newline at end of file From a38e21922a1ec45b27b208bad42ad4d909ba4c32 Mon Sep 17 00:00:00 2001 From: Chrislearn Young Date: Tue, 7 Apr 2026 21:36:56 +0800 Subject: [PATCH 4/4] feat: add redis, mongodb and sqlx session stores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new pluggable session stores behind cargo features: - `redis` → `RedisStore` (uses ConnectionManager for connection reuse) - `mongodb` → `MongoDbStore` (TTL index via `initialize()`) - `sqlx` + `sqlx-postgres` / `sqlx-sqlite` / `sqlx-mysql` → `SqlxPostgresStore` / `SqlxSqliteStore` / `SqlxMySqlStore`, all using `sqlx::Pool` for connection pooling Includes ignored integration tests for every backend and bilingual (en/zh) usage docs under `docs/`. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 17 ++ docs/en/stores.md | 178 +++++++++++++++++++ docs/zh/stores.md | 171 ++++++++++++++++++ src/lib.rs | 18 ++ src/mongodb_store.rs | 126 +++++++++++++ src/redis_store.rs | 117 +++++++++++++ src/sqlx_store.rs | 390 +++++++++++++++++++++++++++++++++++++++++ tests/mongodb.rs | 74 ++++++++ tests/redis.rs | 77 ++++++++ tests/sqlx_mysql.rs | 76 ++++++++ tests/sqlx_postgres.rs | 76 ++++++++ tests/sqlx_sqlite.rs | 92 ++++++++++ 12 files changed, 1412 insertions(+) create mode 100644 docs/en/stores.md create mode 100644 docs/zh/stores.md create mode 100644 src/mongodb_store.rs create mode 100644 src/redis_store.rs create mode 100644 src/sqlx_store.rs create mode 100644 tests/mongodb.rs create mode 100644 tests/redis.rs create mode 100644 tests/sqlx_mysql.rs create mode 100644 tests/sqlx_postgres.rs create mode 100644 tests/sqlx_sqlite.rs diff --git a/Cargo.toml b/Cargo.toml index ce17e5e..7ed8cdb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,5 +31,22 @@ async-lock = "3" time = { version = "0.3", features = ["serde"] } tracing = "0.1" +# Optional backends +redis = { version = "0.27", default-features = false, features = ["tokio-comp", "connection-manager"], optional = true } +mongodb = { version = "3", optional = true } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls"], optional = true } + +[features] +default = [] +redis = ["dep:redis"] +mongodb = ["dep:mongodb"] +sqlx = ["dep:sqlx"] +sqlx-postgres = ["sqlx", "sqlx/postgres"] +sqlx-sqlite = ["sqlx", "sqlx/sqlite"] +sqlx-mysql = ["sqlx", "sqlx/mysql"] + +[package.metadata.docs.rs] +all-features = true + [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } \ No newline at end of file diff --git a/docs/en/stores.md b/docs/en/stores.md new file mode 100644 index 0000000..bcaca14 --- /dev/null +++ b/docs/en/stores.md @@ -0,0 +1,178 @@ +# Saysion Session Stores + +Saysion ships with several pluggable session stores. Most are gated +behind cargo features so you only pull in the dependencies you need. + +| Store | Feature flag | Backend | +|---------------------|------------------|------------------| +| `MemoryStore` | *(always on)* | In-process | +| `CookieStore` | *(always on)* | Client cookie | +| `RedisStore` | `redis` | Redis | +| `MongoDbStore` | `mongodb` | MongoDB | +| `SqlxPostgresStore` | `sqlx-postgres` | PostgreSQL | +| `SqlxSqliteStore` | `sqlx-sqlite` | SQLite | +| `SqlxMySqlStore` | `sqlx-mysql` | MySQL / MariaDB | + +All stores implement the [`SessionStore`] trait, so they are +interchangeable. + +## Redis + +```toml +[dependencies] +saysion = { version = "0.1", features = ["redis"] } +``` + +```rust +use saysion::{RedisStore, Session, SessionStore}; + +#[tokio::main] +async fn main() -> saysion::Result { + let store = RedisStore::from_url("redis://127.0.0.1:6379") + .await? + .with_prefix("myapp/"); + + let mut session = Session::new(); + session.insert("user_id", 42)?; + let cookie = store.store_session(session).await?.unwrap(); + + let loaded = store.load_session(cookie).await?.unwrap(); + assert_eq!(loaded.get::("user_id"), Some(42)); + Ok(()) +} +``` + +`RedisStore` uses [`redis::aio::ConnectionManager`] internally, which +provides connection pooling and automatic reconnection. Sessions with +an expiry are stored using `SET ... EX ` so Redis evicts them +automatically. + +## MongoDB + +```toml +[dependencies] +saysion = { version = "0.1", features = ["mongodb"] } +``` + +```rust +use saysion::{MongoDbStore, Session, SessionStore}; + +#[tokio::main] +async fn main() -> saysion::Result { + let store = MongoDbStore::from_uri("mongodb://127.0.0.1:27017", "myapp") + .await? + .with_collection("sessions"); + + // Create the TTL index. Idempotent — safe to call on every startup. + store.initialize().await?; + + let mut session = Session::new(); + session.insert("user_id", 42)?; + let cookie = store.store_session(session).await?.unwrap(); + + let loaded = store.load_session(cookie).await?.unwrap(); + assert_eq!(loaded.get::("user_id"), Some(42)); + Ok(()) +} +``` + +The official `mongodb` driver maintains a connection pool internally, +so a single `MongoDbStore` can be cloned and shared. Calling +`initialize()` creates a TTL index on `expires_at` so MongoDB purges +expired sessions automatically. + +## SQLx (PostgreSQL / SQLite / MySQL) + +Pick the feature(s) for the database(s) you actually use: + +```toml +[dependencies] +saysion = { version = "0.1", features = ["sqlx-postgres"] } +# or "sqlx-sqlite", "sqlx-mysql" — multiple are allowed +``` + +All three SQLx stores share the same API and use a `sqlx::Pool` for +connection pooling. + +```rust +use saysion::{Session, SessionStore, SqlxPostgresStore}; + +#[tokio::main] +async fn main() -> saysion::Result { + let store = SqlxPostgresStore::from_url( + "postgres://postgres:postgres@127.0.0.1/myapp", + ) + .await?; + + // Create the table on first run. + store.migrate().await?; + + let mut session = Session::new(); + session.insert("user_id", 42)?; + let cookie = store.store_session(session).await?.unwrap(); + + let loaded = store.load_session(cookie).await?.unwrap(); + assert_eq!(loaded.get::("user_id"), Some(42)); + + // Periodically remove expired rows. + store.cleanup().await?; + Ok(()) +} +``` + +For SQLite and MySQL, swap the type and connection URL: + +```rust +let store = saysion::SqlxSqliteStore::from_url("sqlite://sessions.db?mode=rwc").await?; +let store = saysion::SqlxMySqlStore::from_url("mysql://root:root@127.0.0.1/myapp").await?; +``` + +### Schema + +All three SQLx stores use the same schema (table name configurable +via `with_table`, defaults to `saysion_sessions`): + +| Column | Type | Notes | +|-----------|-------------------------------|--------------------------------| +| `id` | `TEXT` / `VARCHAR(128)` PK | Hashed session id | +| `expires` | `BIGINT` / `INTEGER` nullable | Unix seconds, `NULL` = forever | +| `session` | `TEXT` | JSON-serialized `Session` | + +Storing the expiry as a unix-second integer keeps queries portable +across all three databases without pulling in extra type-conversion +features. + +## Sharing a store between handlers + +All stores are `Clone` and cheap to clone (they hold an internal pool +or connection manager), so wrapping them in an `Arc` is unnecessary — +just clone into your application state. + +## Running integration tests + +The crate ships integration tests for every backend. They are marked +`#[ignore]` so they will not run unless you opt in: + +```bash +# Redis +REDIS_URL=redis://127.0.0.1:6379 \ + cargo test --features redis -- --ignored + +# MongoDB +MONGODB_URL=mongodb://127.0.0.1:27017 \ + cargo test --features mongodb -- --ignored + +# PostgreSQL +POSTGRES_URL=postgres://postgres:postgres@127.0.0.1/saysion_test \ + cargo test --features sqlx-postgres -- --ignored + +# SQLite (no service required) +cargo test --features sqlx-sqlite -- --ignored + +# MySQL +MYSQL_URL=mysql://root:root@127.0.0.1/saysion_test \ + cargo test --features sqlx-mysql -- --ignored +``` + +[`SessionStore`]: https://docs.rs/saysion/latest/saysion/trait.SessionStore.html +[`redis::aio::ConnectionManager`]: https://docs.rs/redis/latest/redis/aio/struct.ConnectionManager.html diff --git a/docs/zh/stores.md b/docs/zh/stores.md new file mode 100644 index 0000000..5ad9043 --- /dev/null +++ b/docs/zh/stores.md @@ -0,0 +1,171 @@ +# Saysion 会话存储 + +Saysion 提供多种可插拔的会话存储后端,大多数通过 cargo feature +启用,你只会拉取实际用到的依赖。 + +| 存储 | Feature 标志 | 后端 | +|---------------------|------------------|------------------| +| `MemoryStore` | *(始终可用)* | 进程内内存 | +| `CookieStore` | *(始终可用)* | 客户端 Cookie | +| `RedisStore` | `redis` | Redis | +| `MongoDbStore` | `mongodb` | MongoDB | +| `SqlxPostgresStore` | `sqlx-postgres` | PostgreSQL | +| `SqlxSqliteStore` | `sqlx-sqlite` | SQLite | +| `SqlxMySqlStore` | `sqlx-mysql` | MySQL / MariaDB | + +所有存储都实现了 [`SessionStore`] trait,可以互相替换。 + +## Redis + +```toml +[dependencies] +saysion = { version = "0.1", features = ["redis"] } +``` + +```rust +use saysion::{RedisStore, Session, SessionStore}; + +#[tokio::main] +async fn main() -> saysion::Result { + let store = RedisStore::from_url("redis://127.0.0.1:6379") + .await? + .with_prefix("myapp/"); + + let mut session = Session::new(); + session.insert("user_id", 42)?; + let cookie = store.store_session(session).await?.unwrap(); + + let loaded = store.load_session(cookie).await?.unwrap(); + assert_eq!(loaded.get::("user_id"), Some(42)); + Ok(()) +} +``` + +`RedisStore` 内部使用 [`redis::aio::ConnectionManager`],自带连接复用 +与自动重连。带有过期时间的会话通过 `SET ... EX ` 写入,Redis 会 +自动清理过期键。 + +## MongoDB + +```toml +[dependencies] +saysion = { version = "0.1", features = ["mongodb"] } +``` + +```rust +use saysion::{MongoDbStore, Session, SessionStore}; + +#[tokio::main] +async fn main() -> saysion::Result { + let store = MongoDbStore::from_uri("mongodb://127.0.0.1:27017", "myapp") + .await? + .with_collection("sessions"); + + // 创建 TTL 索引,幂等操作,可以在每次启动时调用。 + store.initialize().await?; + + let mut session = Session::new(); + session.insert("user_id", 42)?; + let cookie = store.store_session(session).await?.unwrap(); + + let loaded = store.load_session(cookie).await?.unwrap(); + assert_eq!(loaded.get::("user_id"), Some(42)); + Ok(()) +} +``` + +官方 `mongodb` 驱动内部维护连接池,因此 `MongoDbStore` 可以直接克隆 +共享。调用 `initialize()` 会在 `expires_at` 字段上创建 TTL 索引, +MongoDB 会自动清理过期会话。 + +## SQLx (PostgreSQL / SQLite / MySQL) + +按需启用对应数据库的 feature(可以同时启用多个): + +```toml +[dependencies] +saysion = { version = "0.1", features = ["sqlx-postgres"] } +# 或者 "sqlx-sqlite", "sqlx-mysql" +``` + +三种 SQLx store API 完全一致,均使用 `sqlx::Pool` 进行连接池管理。 + +```rust +use saysion::{Session, SessionStore, SqlxPostgresStore}; + +#[tokio::main] +async fn main() -> saysion::Result { + let store = SqlxPostgresStore::from_url( + "postgres://postgres:postgres@127.0.0.1/myapp", + ) + .await?; + + // 首次运行时建表。 + store.migrate().await?; + + let mut session = Session::new(); + session.insert("user_id", 42)?; + let cookie = store.store_session(session).await?.unwrap(); + + let loaded = store.load_session(cookie).await?.unwrap(); + assert_eq!(loaded.get::("user_id"), Some(42)); + + // 定期清理过期记录。 + store.cleanup().await?; + Ok(()) +} +``` + +SQLite 和 MySQL 用法相同,只需替换类型与连接 URL: + +```rust +let store = saysion::SqlxSqliteStore::from_url("sqlite://sessions.db?mode=rwc").await?; +let store = saysion::SqlxMySqlStore::from_url("mysql://root:root@127.0.0.1/myapp").await?; +``` + +### 表结构 + +三种 SQLx store 使用同一套表结构(表名通过 `with_table` 自定义, +默认为 `saysion_sessions`): + +| 字段 | 类型 | 说明 | +|-----------|-------------------------------|-----------------------------------| +| `id` | `TEXT` / `VARCHAR(128)` 主键 | 会话 id 的哈希 | +| `expires` | `BIGINT` / `INTEGER` 可空 | Unix 秒,`NULL` 表示永不过期 | +| `session` | `TEXT` | JSON 序列化后的 `Session` | + +过期时间存为 unix 秒整数,使三种数据库的查询完全一致,也避免了 +为 sqlx 启用额外的时间类型转换 feature。 + +## 在多个 handler 之间共享 store + +所有 store 都实现了 `Clone`,克隆代价很低(内部已经是连接池或连接 +管理器),无需再用 `Arc` 包装,直接克隆到应用状态里即可。 + +## 运行集成测试 + +每个后端都附带集成测试,默认带 `#[ignore]` 标记,需要显式开启: + +```bash +# Redis +REDIS_URL=redis://127.0.0.1:6379 \ + cargo test --features redis -- --ignored + +# MongoDB +MONGODB_URL=mongodb://127.0.0.1:27017 \ + cargo test --features mongodb -- --ignored + +# PostgreSQL +POSTGRES_URL=postgres://postgres:postgres@127.0.0.1/saysion_test \ + cargo test --features sqlx-postgres -- --ignored + +# SQLite(无需外部服务) +cargo test --features sqlx-sqlite -- --ignored + +# MySQL +MYSQL_URL=mysql://root:root@127.0.0.1/saysion_test \ + cargo test --features sqlx-mysql -- --ignored +``` + +[`SessionStore`]: https://docs.rs/saysion/latest/saysion/trait.SessionStore.html +[`redis::aio::ConnectionManager`]: https://docs.rs/redis/latest/redis/aio/struct.ConnectionManager.html diff --git a/src/lib.rs b/src/lib.rs index be5d29a..d6e2d3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,11 +56,29 @@ mod memory_store; mod session; mod session_store; +#[cfg(feature = "redis")] +mod redis_store; +#[cfg(feature = "mongodb")] +mod mongodb_store; +#[cfg(any(feature = "sqlx-postgres", feature = "sqlx-sqlite", feature = "sqlx-mysql"))] +mod sqlx_store; + pub use cookie_store::CookieStore; pub use memory_store::MemoryStore; pub use session::Session; pub use session_store::SessionStore; +#[cfg(feature = "redis")] +pub use redis_store::RedisStore; +#[cfg(feature = "mongodb")] +pub use mongodb_store::MongoDbStore; +#[cfg(feature = "sqlx-postgres")] +pub use sqlx_store::SqlxPostgresStore; +#[cfg(feature = "sqlx-sqlite")] +pub use sqlx_store::SqlxSqliteStore; +#[cfg(feature = "sqlx-mysql")] +pub use sqlx_store::SqlxMySqlStore; + pub use base64; pub use blake3; pub use hmac; diff --git a/src/mongodb_store.rs b/src/mongodb_store.rs new file mode 100644 index 0000000..6a53234 --- /dev/null +++ b/src/mongodb_store.rs @@ -0,0 +1,126 @@ +//! MongoDB-backed session store. +//! +//! Enabled by the `mongodb` cargo feature. + +use mongodb::bson::{DateTime as BsonDateTime, doc}; +use mongodb::options::IndexOptions; +use mongodb::{Client, Collection, IndexModel}; +use serde::{Deserialize, Serialize}; + +use crate::{Result, Session, SessionStore, async_trait}; + +const DEFAULT_COLLECTION: &str = "saysion_sessions"; + +/// A session store backed by MongoDB. +/// +/// The official `mongodb` driver maintains its own connection pool +/// internally, so a single [`Client`] (and therefore a single +/// `MongoDbStore`) can be cloned cheaply and shared between tasks. +#[derive(Clone, Debug)] +pub struct MongoDbStore { + client: Client, + db: String, + coll_name: String, +} + +#[derive(Serialize, Deserialize)] +struct SessionDoc { + #[serde(rename = "_id")] + id: String, + expires_at: Option, + session: String, +} + +impl MongoDbStore { + /// Create a store from an existing [`Client`]. + pub fn new(client: Client, db: impl Into) -> Self { + Self { + client, + db: db.into(), + coll_name: DEFAULT_COLLECTION.to_string(), + } + } + + /// Connect using a MongoDB URI such as `mongodb://localhost:27017`. + pub async fn from_uri(uri: &str, db: impl Into) -> Result { + Ok(Self::new(Client::with_uri_str(uri).await?, db)) + } + + /// Use a custom collection name. Defaults to `saysion_sessions`. + pub fn with_collection(mut self, coll: impl Into) -> Self { + self.coll_name = coll.into(); + self + } + + fn coll(&self) -> Collection { + self.client + .database(&self.db) + .collection(&self.coll_name) + } + + /// Create a TTL index on `expires_at` so MongoDB will purge + /// expired sessions automatically. Idempotent — safe to call on + /// every startup. + pub async fn initialize(&self) -> Result { + let model = IndexModel::builder() + .keys(doc! { "expires_at": 1 }) + .options( + IndexOptions::builder() + .expire_after(std::time::Duration::from_secs(0)) + .build(), + ) + .build(); + self.coll().create_index(model).await?; + Ok(()) + } + + /// Returns the number of stored sessions. + pub async fn count(&self) -> Result { + Ok(self.coll().count_documents(doc! {}).await?) + } +} + +#[async_trait] +impl SessionStore for MongoDbStore { + async fn load_session(&self, cookie_value: String) -> Result> { + let id = Session::id_from_cookie_value(&cookie_value)?; + tracing::debug!("loading session by id `{}`", id); + let found = self.coll().find_one(doc! { "_id": &id }).await?; + Ok(match found { + None => None, + Some(d) => serde_json::from_str::(&d.session)?.validate(), + }) + } + + async fn store_session(&self, session: Session) -> Result> { + let id = session.id().to_string(); + tracing::debug!("storing session by id `{}`", id); + let json = serde_json::to_string(&session)?; + let expires_at = session + .expiry() + .map(|e| BsonDateTime::from_millis(e.unix_timestamp() * 1000)); + let doc_value = SessionDoc { + id: id.clone(), + expires_at, + session: json, + }; + self.coll() + .replace_one(doc! { "_id": &id }, &doc_value) + .upsert(true) + .await?; + session.reset_data_changed(); + Ok(session.into_cookie_value()) + } + + async fn destroy_session(&self, session: Session) -> Result { + tracing::debug!("destroying session by id `{}`", session.id()); + self.coll().delete_one(doc! { "_id": session.id() }).await?; + Ok(()) + } + + async fn clear_store(&self) -> Result { + tracing::debug!("clearing mongodb store"); + self.coll().delete_many(doc! {}).await?; + Ok(()) + } +} diff --git a/src/redis_store.rs b/src/redis_store.rs new file mode 100644 index 0000000..5978c6b --- /dev/null +++ b/src/redis_store.rs @@ -0,0 +1,117 @@ +//! Redis-backed session store. +//! +//! Enabled by the `redis` cargo feature. + +use redis::aio::ConnectionManager; +use redis::{AsyncCommands, Client}; + +use crate::{Result, Session, SessionStore, async_trait}; + +const DEFAULT_PREFIX: &str = "saysion/"; + +/// A session store backed by Redis. +/// +/// Internally uses [`redis::aio::ConnectionManager`] which provides +/// connection pooling/reuse and automatic reconnection. +#[derive(Clone)] +pub struct RedisStore { + conn: ConnectionManager, + prefix: String, +} + +impl std::fmt::Debug for RedisStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RedisStore") + .field("prefix", &self.prefix) + .finish() + } +} + +impl RedisStore { + /// Create a new `RedisStore` from an existing redis [`Client`]. + pub async fn new(client: Client) -> Result { + let conn = ConnectionManager::new(client).await?; + Ok(Self { + conn, + prefix: DEFAULT_PREFIX.to_string(), + }) + } + + /// Connect to redis using a connection URL such as + /// `redis://127.0.0.1:6379`. + pub async fn from_url(url: &str) -> Result { + Self::new(Client::open(url)?).await + } + + /// Set the key prefix used for stored sessions. Defaults to + /// `saysion/`. + pub fn with_prefix(mut self, prefix: impl Into) -> Self { + self.prefix = prefix.into(); + self + } + + fn key(&self, id: &str) -> String { + format!("{}{}", self.prefix, id) + } + + /// Returns the number of stored sessions matching this store's + /// prefix. Uses `KEYS` and is intended for tests / small + /// deployments only. + pub async fn count(&self) -> Result { + let mut conn = self.conn.clone(); + let pattern = format!("{}*", self.prefix); + let keys: Vec = conn.keys(pattern).await?; + Ok(keys.len()) + } +} + +#[async_trait] +impl SessionStore for RedisStore { + async fn load_session(&self, cookie_value: String) -> Result> { + let id = Session::id_from_cookie_value(&cookie_value)?; + tracing::debug!("loading session by id `{}`", id); + let mut conn = self.conn.clone(); + let value: Option = conn.get(self.key(&id)).await?; + Ok(match value { + None => None, + Some(v) => serde_json::from_str::(&v)?.validate(), + }) + } + + async fn store_session(&self, session: Session) -> Result> { + let id = session.id().to_string(); + tracing::debug!("storing session by id `{}`", id); + let json = serde_json::to_string(&session)?; + let mut conn = self.conn.clone(); + match session.expires_in() { + Some(ttl) => { + let _: () = conn + .set_ex(self.key(&id), json, ttl.as_secs().max(1)) + .await?; + } + None => { + let _: () = conn.set(self.key(&id), json).await?; + } + } + session.reset_data_changed(); + Ok(session.into_cookie_value()) + } + + async fn destroy_session(&self, session: Session) -> Result { + tracing::debug!("destroying session by id `{}`", session.id()); + let mut conn = self.conn.clone(); + let _: () = conn.del(self.key(session.id())).await?; + Ok(()) + } + + async fn clear_store(&self) -> Result { + tracing::debug!("clearing redis store with prefix `{}`", self.prefix); + let mut conn = self.conn.clone(); + let pattern = format!("{}*", self.prefix); + let keys: Vec = conn.keys(pattern).await?; + if !keys.is_empty() { + let _: () = conn.del(keys).await?; + } + Ok(()) + } +} diff --git a/src/sqlx_store.rs b/src/sqlx_store.rs new file mode 100644 index 0000000..6ade6ba --- /dev/null +++ b/src/sqlx_store.rs @@ -0,0 +1,390 @@ +//! SQLx-backed session stores. +//! +//! Three concrete stores are exposed, each gated behind its own +//! cargo feature: +//! +//! - [`SqlxPostgresStore`] (`sqlx-postgres`) +//! - [`SqlxSqliteStore`] (`sqlx-sqlite`) +//! - [`SqlxMySqlStore`] (`sqlx-mysql`) +//! +//! All three use a `sqlx::Pool` internally, so connections are +//! pooled and reused across tasks. Expiry timestamps are stored as +//! signed unix-second integers (`NULL` means "no expiry"), keeping +//! the schema and queries portable across backends. +//! +//! Before first use call `migrate()` once to create the table. + +use time::OffsetDateTime; + +use crate::Session; + +fn now_unix() -> i64 { + OffsetDateTime::now_utc().unix_timestamp() +} + +fn expiry_unix(session: &Session) -> Option { + session.expiry().map(|e| e.unix_timestamp()) +} + +fn parse_session(json: &str) -> Option { + serde_json::from_str::(json).ok().and_then(Session::validate) +} + +// ---------- Postgres ---------- + +#[cfg(feature = "sqlx-postgres")] +mod pg { + use super::*; + use sqlx::PgPool; + + use crate::{Result, Session, SessionStore, async_trait}; + + /// PostgreSQL-backed session store. + #[derive(Clone, Debug)] + pub struct SqlxPostgresStore { + pool: PgPool, + table: String, + } + + impl SqlxPostgresStore { + /// Create a store from an existing connection pool. + pub fn new(pool: PgPool) -> Self { + Self { + pool, + table: "saysion_sessions".to_string(), + } + } + + /// Connect to Postgres using a connection URL. + pub async fn from_url(url: &str) -> Result { + Ok(Self::new(PgPool::connect(url).await?)) + } + + /// Override the table name. Defaults to `saysion_sessions`. + pub fn with_table(mut self, table: impl Into) -> Self { + self.table = table.into(); + self + } + + /// Create the sessions table if it does not yet exist. + pub async fn migrate(&self) -> Result { + let sql = format!( + "CREATE TABLE IF NOT EXISTS {table} (\ + id TEXT PRIMARY KEY NOT NULL, \ + expires BIGINT NULL, \ + session TEXT NOT NULL\ + )", + table = self.table + ); + sqlx::query(&sql).execute(&self.pool).await?; + Ok(()) + } + + /// Delete all expired session rows. + pub async fn cleanup(&self) -> Result { + let sql = format!( + "DELETE FROM {} WHERE expires IS NOT NULL AND expires < $1", + self.table + ); + sqlx::query(&sql).bind(now_unix()).execute(&self.pool).await?; + Ok(()) + } + + /// Returns the number of stored sessions. + pub async fn count(&self) -> Result { + let sql = format!("SELECT COUNT(*) FROM {}", self.table); + let (n,): (i64,) = sqlx::query_as(&sql).fetch_one(&self.pool).await?; + Ok(n) + } + } + + #[async_trait] + impl SessionStore for SqlxPostgresStore { + async fn load_session(&self, cookie_value: String) -> Result> { + let id = Session::id_from_cookie_value(&cookie_value)?; + let sql = format!( + "SELECT session FROM {} WHERE id = $1 AND (expires IS NULL OR expires > $2)", + self.table + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(&id) + .bind(now_unix()) + .fetch_optional(&self.pool) + .await?; + Ok(row.and_then(|(s,)| parse_session(&s))) + } + + async fn store_session(&self, session: Session) -> Result> { + let id = session.id().to_string(); + let json = serde_json::to_string(&session)?; + let expires = expiry_unix(&session); + let sql = format!( + "INSERT INTO {table} (id, expires, session) VALUES ($1, $2, $3) \ + ON CONFLICT (id) DO UPDATE SET expires = EXCLUDED.expires, session = EXCLUDED.session", + table = self.table + ); + sqlx::query(&sql) + .bind(&id) + .bind(expires) + .bind(&json) + .execute(&self.pool) + .await?; + session.reset_data_changed(); + Ok(session.into_cookie_value()) + } + + async fn destroy_session(&self, session: Session) -> Result { + let sql = format!("DELETE FROM {} WHERE id = $1", self.table); + sqlx::query(&sql) + .bind(session.id()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn clear_store(&self) -> Result { + let sql = format!("DELETE FROM {}", self.table); + sqlx::query(&sql).execute(&self.pool).await?; + Ok(()) + } + } +} + +#[cfg(feature = "sqlx-postgres")] +pub use pg::SqlxPostgresStore; + +// ---------- SQLite ---------- + +#[cfg(feature = "sqlx-sqlite")] +#[allow(missing_docs)] +mod sqlite { + use super::*; + use sqlx::SqlitePool; + + use crate::{Result, Session, SessionStore, async_trait}; + + /// SQLite-backed session store. + #[derive(Clone, Debug)] + pub struct SqlxSqliteStore { + pool: SqlitePool, + table: String, + } + + impl SqlxSqliteStore { + pub fn new(pool: SqlitePool) -> Self { + Self { + pool, + table: "saysion_sessions".to_string(), + } + } + + pub async fn from_url(url: &str) -> Result { + Ok(Self::new(SqlitePool::connect(url).await?)) + } + + pub fn with_table(mut self, table: impl Into) -> Self { + self.table = table.into(); + self + } + + pub async fn migrate(&self) -> Result { + let sql = format!( + "CREATE TABLE IF NOT EXISTS {table} (\ + id TEXT PRIMARY KEY NOT NULL, \ + expires INTEGER NULL, \ + session TEXT NOT NULL\ + )", + table = self.table + ); + sqlx::query(&sql).execute(&self.pool).await?; + Ok(()) + } + + pub async fn cleanup(&self) -> Result { + let sql = format!( + "DELETE FROM {} WHERE expires IS NOT NULL AND expires < ?", + self.table + ); + sqlx::query(&sql).bind(now_unix()).execute(&self.pool).await?; + Ok(()) + } + + pub async fn count(&self) -> Result { + let sql = format!("SELECT COUNT(*) FROM {}", self.table); + let (n,): (i64,) = sqlx::query_as(&sql).fetch_one(&self.pool).await?; + Ok(n) + } + } + + #[async_trait] + impl SessionStore for SqlxSqliteStore { + async fn load_session(&self, cookie_value: String) -> Result> { + let id = Session::id_from_cookie_value(&cookie_value)?; + let sql = format!( + "SELECT session FROM {} WHERE id = ? AND (expires IS NULL OR expires > ?)", + self.table + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(&id) + .bind(now_unix()) + .fetch_optional(&self.pool) + .await?; + Ok(row.and_then(|(s,)| parse_session(&s))) + } + + async fn store_session(&self, session: Session) -> Result> { + let id = session.id().to_string(); + let json = serde_json::to_string(&session)?; + let expires = expiry_unix(&session); + let sql = format!( + "INSERT INTO {table} (id, expires, session) VALUES (?, ?, ?) \ + ON CONFLICT(id) DO UPDATE SET expires = excluded.expires, session = excluded.session", + table = self.table + ); + sqlx::query(&sql) + .bind(&id) + .bind(expires) + .bind(&json) + .execute(&self.pool) + .await?; + session.reset_data_changed(); + Ok(session.into_cookie_value()) + } + + async fn destroy_session(&self, session: Session) -> Result { + let sql = format!("DELETE FROM {} WHERE id = ?", self.table); + sqlx::query(&sql) + .bind(session.id()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn clear_store(&self) -> Result { + let sql = format!("DELETE FROM {}", self.table); + sqlx::query(&sql).execute(&self.pool).await?; + Ok(()) + } + } +} + +#[cfg(feature = "sqlx-sqlite")] +pub use sqlite::SqlxSqliteStore; + +// ---------- MySQL ---------- + +#[cfg(feature = "sqlx-mysql")] +#[allow(missing_docs)] +mod mysql { + use super::*; + use sqlx::MySqlPool; + + use crate::{Result, Session, SessionStore, async_trait}; + + /// MySQL/MariaDB-backed session store. + #[derive(Clone, Debug)] + pub struct SqlxMySqlStore { + pool: MySqlPool, + table: String, + } + + impl SqlxMySqlStore { + pub fn new(pool: MySqlPool) -> Self { + Self { + pool, + table: "saysion_sessions".to_string(), + } + } + + pub async fn from_url(url: &str) -> Result { + Ok(Self::new(MySqlPool::connect(url).await?)) + } + + pub fn with_table(mut self, table: impl Into) -> Self { + self.table = table.into(); + self + } + + pub async fn migrate(&self) -> Result { + let sql = format!( + "CREATE TABLE IF NOT EXISTS {table} (\ + id VARCHAR(128) PRIMARY KEY NOT NULL, \ + expires BIGINT NULL, \ + session TEXT NOT NULL\ + )", + table = self.table + ); + sqlx::query(&sql).execute(&self.pool).await?; + Ok(()) + } + + pub async fn cleanup(&self) -> Result { + let sql = format!( + "DELETE FROM {} WHERE expires IS NOT NULL AND expires < ?", + self.table + ); + sqlx::query(&sql).bind(now_unix()).execute(&self.pool).await?; + Ok(()) + } + + pub async fn count(&self) -> Result { + let sql = format!("SELECT COUNT(*) FROM {}", self.table); + let (n,): (i64,) = sqlx::query_as(&sql).fetch_one(&self.pool).await?; + Ok(n) + } + } + + #[async_trait] + impl SessionStore for SqlxMySqlStore { + async fn load_session(&self, cookie_value: String) -> Result> { + let id = Session::id_from_cookie_value(&cookie_value)?; + let sql = format!( + "SELECT session FROM {} WHERE id = ? AND (expires IS NULL OR expires > ?)", + self.table + ); + let row: Option<(String,)> = sqlx::query_as(&sql) + .bind(&id) + .bind(now_unix()) + .fetch_optional(&self.pool) + .await?; + Ok(row.and_then(|(s,)| parse_session(&s))) + } + + async fn store_session(&self, session: Session) -> Result> { + let id = session.id().to_string(); + let json = serde_json::to_string(&session)?; + let expires = expiry_unix(&session); + let sql = format!( + "INSERT INTO {table} (id, expires, session) VALUES (?, ?, ?) \ + ON DUPLICATE KEY UPDATE expires = VALUES(expires), session = VALUES(session)", + table = self.table + ); + sqlx::query(&sql) + .bind(&id) + .bind(expires) + .bind(&json) + .execute(&self.pool) + .await?; + session.reset_data_changed(); + Ok(session.into_cookie_value()) + } + + async fn destroy_session(&self, session: Session) -> Result { + let sql = format!("DELETE FROM {} WHERE id = ?", self.table); + sqlx::query(&sql) + .bind(session.id()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn clear_store(&self) -> Result { + let sql = format!("DELETE FROM {}", self.table); + sqlx::query(&sql).execute(&self.pool).await?; + Ok(()) + } + } +} + +#[cfg(feature = "sqlx-mysql")] +pub use mysql::SqlxMySqlStore; diff --git a/tests/mongodb.rs b/tests/mongodb.rs new file mode 100644 index 0000000..56ff7cc --- /dev/null +++ b/tests/mongodb.rs @@ -0,0 +1,74 @@ +#![cfg(feature = "mongodb")] +//! Integration tests for `MongoDbStore`. +//! +//! Requires a running MongoDB instance. Set `MONGODB_URL` to override +//! the default of `mongodb://127.0.0.1:27017`. All tests are +//! `#[ignore]` — run with `cargo test --features mongodb -- --ignored`. + +use std::time::Duration; + +use saysion::{MongoDbStore, Session, SessionStore}; + +fn url() -> String { + std::env::var("MONGODB_URL").unwrap_or_else(|_| "mongodb://127.0.0.1:27017".to_string()) +} + +async fn fresh_store() -> MongoDbStore { + let store = MongoDbStore::from_uri(&url(), "saysion_test") + .await + .expect("connect to mongodb") + .with_collection("sessions_test"); + store.clear_store().await.expect("clear"); + store +} + +#[tokio::test] +#[ignore] +async fn mongodb_roundtrip() { + let store = fresh_store().await; + + let mut session = Session::new(); + session.insert("hello", "world").unwrap(); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + let loaded = store.load_session(cookie).await.unwrap().unwrap(); + assert_eq!(loaded.get::("hello").unwrap(), "world"); + + store.clear_store().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn mongodb_expiry() { + let store = fresh_store().await; + + let mut session = Session::new(); + session.expire_in(Duration::from_secs(1)); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + assert!(store.load_session(cookie.clone()).await.unwrap().is_some()); + tokio::time::sleep(Duration::from_secs(2)).await; + assert!(store.load_session(cookie).await.unwrap().is_none()); + + store.clear_store().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn mongodb_destroy_and_clear() { + let store = fresh_store().await; + + for _ in 0..3 { + store.store_session(Session::new()).await.unwrap(); + } + let cookie = store.store_session(Session::new()).await.unwrap().unwrap(); + assert_eq!(store.count().await.unwrap(), 4); + + let loaded = store.load_session(cookie.clone()).await.unwrap().unwrap(); + store.destroy_session(loaded).await.unwrap(); + assert!(store.load_session(cookie).await.unwrap().is_none()); + assert_eq!(store.count().await.unwrap(), 3); + + store.clear_store().await.unwrap(); + assert_eq!(store.count().await.unwrap(), 0); +} diff --git a/tests/redis.rs b/tests/redis.rs new file mode 100644 index 0000000..dca723f --- /dev/null +++ b/tests/redis.rs @@ -0,0 +1,77 @@ +#![cfg(feature = "redis")] +//! Integration tests for `RedisStore`. +//! +//! Requires a running Redis instance. Set `REDIS_URL` to override the +//! default of `redis://127.0.0.1:6379`. All tests are `#[ignore]` — +//! run with `cargo test --features redis -- --ignored`. + +use std::time::Duration; + +use saysion::{RedisStore, Session, SessionStore}; + +fn url() -> String { + std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()) +} + +async fn fresh_store() -> RedisStore { + let store = RedisStore::from_url(&url()) + .await + .expect("connect to redis") + .with_prefix("saysion-test/"); + store.clear_store().await.expect("clear"); + store +} + +#[tokio::test] +#[ignore] +async fn redis_roundtrip() { + let store = fresh_store().await; + + let mut session = Session::new(); + session.insert("hello", "world").unwrap(); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + let loaded = store.load_session(cookie).await.unwrap().unwrap(); + assert_eq!(loaded.get::("hello").unwrap(), "world"); + + store.clear_store().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn redis_expiry() { + let store = fresh_store().await; + + let mut session = Session::new(); + session.expire_in(Duration::from_secs(1)); + session.insert("k", "v").unwrap(); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + assert!(store.load_session(cookie.clone()).await.unwrap().is_some()); + tokio::time::sleep(Duration::from_secs(2)).await; + assert!(store.load_session(cookie).await.unwrap().is_none()); + + store.clear_store().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn redis_destroy_and_clear() { + let store = fresh_store().await; + + for _ in 0..3 { + store.store_session(Session::new()).await.unwrap(); + } + let mut s = Session::new(); + s.insert("a", 1).unwrap(); + let cookie = store.store_session(s).await.unwrap().unwrap(); + assert_eq!(store.count().await.unwrap(), 4); + + let loaded = store.load_session(cookie.clone()).await.unwrap().unwrap(); + store.destroy_session(loaded).await.unwrap(); + assert!(store.load_session(cookie).await.unwrap().is_none()); + assert_eq!(store.count().await.unwrap(), 3); + + store.clear_store().await.unwrap(); + assert_eq!(store.count().await.unwrap(), 0); +} diff --git a/tests/sqlx_mysql.rs b/tests/sqlx_mysql.rs new file mode 100644 index 0000000..8605437 --- /dev/null +++ b/tests/sqlx_mysql.rs @@ -0,0 +1,76 @@ +#![cfg(feature = "sqlx-mysql")] +//! Integration tests for `SqlxMySqlStore`. +//! +//! Set `MYSQL_URL` (default +//! `mysql://root:root@127.0.0.1/saysion_test`). +//! Run with `cargo test --features sqlx-mysql -- --ignored`. + +use std::time::Duration; + +use saysion::{Session, SessionStore, SqlxMySqlStore}; + +fn url() -> String { + std::env::var("MYSQL_URL") + .unwrap_or_else(|_| "mysql://root:root@127.0.0.1/saysion_test".to_string()) +} + +async fn fresh_store() -> SqlxMySqlStore { + let store = SqlxMySqlStore::from_url(&url()) + .await + .expect("connect to mysql") + .with_table("saysion_test_sessions"); + store.migrate().await.expect("migrate"); + store.clear_store().await.expect("clear"); + store +} + +#[tokio::test] +#[ignore] +async fn mysql_roundtrip() { + let store = fresh_store().await; + + let mut session = Session::new(); + session.insert("hello", "world").unwrap(); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + let loaded = store.load_session(cookie).await.unwrap().unwrap(); + assert_eq!(loaded.get::("hello").unwrap(), "world"); + + store.clear_store().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn mysql_expiry_and_cleanup() { + let store = fresh_store().await; + + let mut session = Session::new(); + session.expire_in(Duration::from_secs(1)); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + assert!(store.load_session(cookie.clone()).await.unwrap().is_some()); + tokio::time::sleep(Duration::from_secs(2)).await; + assert!(store.load_session(cookie).await.unwrap().is_none()); + + store.cleanup().await.unwrap(); + assert_eq!(store.count().await.unwrap(), 0); +} + +#[tokio::test] +#[ignore] +async fn mysql_destroy_and_clear() { + let store = fresh_store().await; + + for _ in 0..3 { + store.store_session(Session::new()).await.unwrap(); + } + let cookie = store.store_session(Session::new()).await.unwrap().unwrap(); + assert_eq!(store.count().await.unwrap(), 4); + + let loaded = store.load_session(cookie.clone()).await.unwrap().unwrap(); + store.destroy_session(loaded).await.unwrap(); + assert!(store.load_session(cookie).await.unwrap().is_none()); + + store.clear_store().await.unwrap(); + assert_eq!(store.count().await.unwrap(), 0); +} diff --git a/tests/sqlx_postgres.rs b/tests/sqlx_postgres.rs new file mode 100644 index 0000000..98aab3c --- /dev/null +++ b/tests/sqlx_postgres.rs @@ -0,0 +1,76 @@ +#![cfg(feature = "sqlx-postgres")] +//! Integration tests for `SqlxPostgresStore`. +//! +//! Set `POSTGRES_URL` (default +//! `postgres://postgres:postgres@127.0.0.1/saysion_test`). +//! Run with `cargo test --features sqlx-postgres -- --ignored`. + +use std::time::Duration; + +use saysion::{Session, SessionStore, SqlxPostgresStore}; + +fn url() -> String { + std::env::var("POSTGRES_URL") + .unwrap_or_else(|_| "postgres://postgres:postgres@127.0.0.1/saysion_test".to_string()) +} + +async fn fresh_store() -> SqlxPostgresStore { + let store = SqlxPostgresStore::from_url(&url()) + .await + .expect("connect to postgres") + .with_table("saysion_test_sessions"); + store.migrate().await.expect("migrate"); + store.clear_store().await.expect("clear"); + store +} + +#[tokio::test] +#[ignore] +async fn pg_roundtrip() { + let store = fresh_store().await; + + let mut session = Session::new(); + session.insert("hello", "world").unwrap(); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + let loaded = store.load_session(cookie).await.unwrap().unwrap(); + assert_eq!(loaded.get::("hello").unwrap(), "world"); + + store.clear_store().await.unwrap(); +} + +#[tokio::test] +#[ignore] +async fn pg_expiry_and_cleanup() { + let store = fresh_store().await; + + let mut session = Session::new(); + session.expire_in(Duration::from_secs(1)); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + assert!(store.load_session(cookie.clone()).await.unwrap().is_some()); + tokio::time::sleep(Duration::from_secs(2)).await; + assert!(store.load_session(cookie).await.unwrap().is_none()); + + store.cleanup().await.unwrap(); + assert_eq!(store.count().await.unwrap(), 0); +} + +#[tokio::test] +#[ignore] +async fn pg_destroy_and_clear() { + let store = fresh_store().await; + + for _ in 0..3 { + store.store_session(Session::new()).await.unwrap(); + } + let cookie = store.store_session(Session::new()).await.unwrap().unwrap(); + assert_eq!(store.count().await.unwrap(), 4); + + let loaded = store.load_session(cookie.clone()).await.unwrap().unwrap(); + store.destroy_session(loaded).await.unwrap(); + assert!(store.load_session(cookie).await.unwrap().is_none()); + + store.clear_store().await.unwrap(); + assert_eq!(store.count().await.unwrap(), 0); +} diff --git a/tests/sqlx_sqlite.rs b/tests/sqlx_sqlite.rs new file mode 100644 index 0000000..87bc57e --- /dev/null +++ b/tests/sqlx_sqlite.rs @@ -0,0 +1,92 @@ +#![cfg(feature = "sqlx-sqlite")] +//! Integration tests for `SqlxSqliteStore`. +//! +//! Uses a temporary on-disk SQLite database. Run with +//! `cargo test --features sqlx-sqlite -- --ignored`. + +use std::time::Duration; + +use saysion::{Session, SessionStore, SqlxSqliteStore}; + +async fn fresh_store() -> (SqlxSqliteStore, tempfile_path::TempDb) { + let temp = tempfile_path::TempDb::new(); + let store = SqlxSqliteStore::from_url(&temp.url()) + .await + .expect("connect to sqlite"); + store.migrate().await.expect("migrate"); + (store, temp) +} + +mod tempfile_path { + pub struct TempDb { + path: std::path::PathBuf, + } + impl TempDb { + pub fn new() -> Self { + let mut p = std::env::temp_dir(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + p.push(format!("saysion-test-{nanos}.sqlite")); + Self { path: p } + } + pub fn url(&self) -> String { + format!("sqlite://{}?mode=rwc", self.path.display()) + } + } + impl Drop for TempDb { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } +} + +#[tokio::test] +#[ignore] +async fn sqlite_roundtrip() { + let (store, _g) = fresh_store().await; + + let mut session = Session::new(); + session.insert("hello", "world").unwrap(); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + let loaded = store.load_session(cookie).await.unwrap().unwrap(); + assert_eq!(loaded.get::("hello").unwrap(), "world"); +} + +#[tokio::test] +#[ignore] +async fn sqlite_expiry_and_cleanup() { + let (store, _g) = fresh_store().await; + + let mut session = Session::new(); + session.expire_in(Duration::from_secs(1)); + let cookie = store.store_session(session).await.unwrap().unwrap(); + + assert!(store.load_session(cookie.clone()).await.unwrap().is_some()); + tokio::time::sleep(Duration::from_secs(2)).await; + assert!(store.load_session(cookie).await.unwrap().is_none()); + + store.cleanup().await.unwrap(); + assert_eq!(store.count().await.unwrap(), 0); +} + +#[tokio::test] +#[ignore] +async fn sqlite_destroy_and_clear() { + let (store, _g) = fresh_store().await; + + for _ in 0..3 { + store.store_session(Session::new()).await.unwrap(); + } + let cookie = store.store_session(Session::new()).await.unwrap().unwrap(); + assert_eq!(store.count().await.unwrap(), 4); + + let loaded = store.load_session(cookie.clone()).await.unwrap().unwrap(); + store.destroy_session(loaded).await.unwrap(); + assert!(store.load_session(cookie).await.unwrap().is_none()); + + store.clear_store().await.unwrap(); + assert_eq!(store.count().await.unwrap(), 0); +}