use std::time::Duration;
use rustlavel_cache::redis::RedisStore as RedisClient;
use rustlavel_core::{Json, Result};
use crate::session::Session;
use crate::store::{DEFAULT_LIFETIME, SessionStore, StoreFuture};
#[derive(Clone)]
pub struct RedisSessions {
client: RedisClient,
prefix: String,
lifetime: Duration,
}
impl RedisSessions {
pub fn connect(url: &str) -> Result<RedisSessions> {
Ok(RedisSessions {
client: RedisClient::connect(url)?,
prefix: "session:".into(),
lifetime: DEFAULT_LIFETIME,
})
}
pub fn new(client: RedisClient) -> RedisSessions {
RedisSessions { client, prefix: "session:".into(), lifetime: DEFAULT_LIFETIME }
}
pub fn prefix(mut self, prefix: impl Into<String>) -> RedisSessions {
self.prefix = prefix.into();
self
}
pub fn lifetime(mut self, lifetime: Duration) -> RedisSessions {
self.lifetime = lifetime;
self
}
pub async fn verify(&self) -> Result<()> {
self.client.verify().await
}
fn key(&self, id: &str) -> String {
format!("{}{id}", self.prefix)
}
}
impl SessionStore for RedisSessions {
fn read<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Session>> {
Box::pin(async move {
if !crate::session::is_valid_id(id) {
return Ok(None);
}
let reply = self
.client
.pool()
.command(&[b"GET", self.key(id).as_bytes()])
.await?
.into_result()?;
let Some(source) = reply.as_str() else { return Ok(None) };
let Ok(payload) = Json::parse(source) else {
let _ = self.destroy(id).await;
return Ok(None);
};
Ok(Some(Session::from_json(id, &payload)))
})
}
fn write<'a>(&'a self, session: &'a Session) -> StoreFuture<'a, ()> {
Box::pin(async move {
if !crate::session::is_valid_id(session.id()) {
return Err(rustlavel_core::Error::msg(
"refusing to write a session whose id was not generated by this framework",
));
}
let mut stamped = session.clone();
stamped.touch();
let millis = self.lifetime.as_millis().to_string();
self.client
.pool()
.command(&[
b"SET",
self.key(session.id()).as_bytes(),
stamped.to_json().to_string().as_bytes(),
b"PX",
millis.as_bytes(),
])
.await?
.into_result()?;
Ok(())
})
}
fn destroy<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
Box::pin(async move {
if crate::session::is_valid_id(id) {
self.client.pool().command(&[b"DEL", self.key(id).as_bytes()]).await?.into_result()?;
}
Ok(())
})
}
fn gc(&self) -> StoreFuture<'_, usize> {
Box::pin(async move { Ok(0) })
}
fn lifetime(&self) -> Duration {
self.lifetime
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_are_prefixed_and_the_prefix_can_be_changed() {
let sessions = RedisSessions::connect("redis://127.0.0.1:6379").unwrap();
assert_eq!(sessions.key("abc"), "session:abc");
let namespaced = sessions.prefix("pamjaya:session:");
assert_eq!(namespaced.key("abc"), "pamjaya:session:abc");
}
#[tokio::test]
async fn an_id_of_the_wrong_shape_is_refused_without_asking_redis() {
let sessions = RedisSessions::connect("redis://127.0.0.1:1").unwrap();
assert!(sessions.read("../../etc/passwd").await.unwrap().is_none());
assert!(sessions.read("short").await.unwrap().is_none());
}
#[tokio::test]
async fn a_session_survives_the_round_trip_on_a_live_server() {
let Ok(url) = std::env::var("REDIS_URL") else {
println!("skipped: set REDIS_URL to run the live session-store test");
return;
};
let sessions = RedisSessions::connect(&url)
.unwrap()
.prefix(format!("test:{}:session:", std::process::id()))
.lifetime(Duration::from_secs(60));
let mut session = Session::new();
session.put("user_id", 42);
session.put("theme", "dark");
let id = session.id().to_string();
sessions.write(&session).await.expect("written");
let found = sessions.read(&id).await.expect("read").expect("the session is there");
assert_eq!(found.id(), id);
assert_eq!(found.get("user_id").and_then(|v| v.as_i64()), Some(42));
assert_eq!(found.get("theme").and_then(|v| v.as_str()), Some("dark"));
sessions.destroy(&id).await.expect("destroyed");
assert!(sessions.read(&id).await.expect("read").is_none(), "a destroyed session came back");
}
#[test]
fn the_lifetime_is_the_one_the_middleware_will_ask_for() {
let sessions = RedisSessions::connect("redis://127.0.0.1:6379")
.unwrap()
.lifetime(Duration::from_secs(1800));
assert_eq!(SessionStore::lifetime(&sessions), Duration::from_secs(1800));
}
}