mod memory;
#[cfg(feature = "redis-cache")]
mod redis_adapter;
use crate::error::{AuthError, AuthResult};
use async_trait::async_trait;
use chrono::Duration;
pub use memory::MemoryCacheAdapter;
#[cfg(feature = "redis-cache")]
pub use redis_adapter::RedisAdapter;
#[async_trait]
pub trait CacheAdapter: Send + Sync {
async fn set(&self, key: &str, value: &str, expires_in: Duration) -> AuthResult<()>;
async fn set_without_expiry(&self, _key: &str, _value: &str) -> AuthResult<()> {
Err(AuthError::internal(
"persistent cache writes are unsupported by this adapter",
))
}
async fn get(&self, key: &str) -> AuthResult<Option<String>>;
async fn get_and_delete(&self, _key: &str) -> AuthResult<Option<String>> {
Err(AuthError::internal(
"atomic cache consumption is not supported by this adapter",
))
}
async fn increment(&self, _key: &str, _expires_in: std::time::Duration) -> AuthResult<f64> {
Err(AuthError::internal(
"atomic cache increment is not supported by this adapter",
))
}
async fn delete(&self, key: &str) -> AuthResult<()>;
async fn exists(&self, key: &str) -> AuthResult<bool>;
async fn expire(&self, key: &str, expires_in: Duration) -> AuthResult<()>;
async fn clear(&self) -> AuthResult<()>;
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use tokio::sync::Barrier;
use tokio::task::JoinSet;
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[expect(
clippy::panic_in_result_fn,
reason = "Assertions report test failures; Result propagates setup and fixture errors"
)]
async fn atomic_cache_consumption_has_one_winner_and_rejects_expired_values() -> AuthResult<()>
{
let cache = Arc::new(MemoryCacheAdapter::new());
cache.set_without_expiry("token", "single-use").await?;
let barrier = Arc::new(Barrier::new(8));
let mut tasks = JoinSet::new();
for _ in 0..8 {
let cache = Arc::clone(&cache);
let barrier = Arc::clone(&barrier);
_ = tasks.spawn(async move {
_ = barrier.wait().await;
cache.get_and_delete("token").await
});
}
let mut winners = Vec::new();
while let Some(result) = tasks.join_next().await {
if let Some(value) =
result.map_err(|error| AuthError::internal(error.to_string()))??
{
winners.push(value);
}
}
assert_eq!(winners, ["single-use"]);
assert!(cache.get_and_delete("token").await?.is_none());
cache
.set("expired", "expired-value", Duration::seconds(-1))
.await?;
assert!(cache.get_and_delete("expired").await?.is_none());
assert!(!cache.exists("expired").await?);
cache.set_without_expiry("expired", "renewed").await?;
assert!(cache.exists("expired").await?);
assert_eq!(cache.get("expired").await?.as_deref(), Some("renewed"));
cache.expire("expired", Duration::seconds(-1)).await?;
assert!(cache.get_and_delete("expired").await?.is_none());
let ttl = std::time::Duration::from_secs(60);
assert_eq!(cache.increment("counter", ttl).await?, 1.0);
assert_eq!(
cache
.increment("counter", std::time::Duration::ZERO)
.await?,
2.0
);
assert_eq!(cache.get("counter").await?.as_deref(), Some("2"));
cache.set_without_expiry("corrupt", "not-a-number").await?;
assert!(cache.increment("corrupt", ttl).await.is_err());
assert_eq!(cache.get("corrupt").await?.as_deref(), Some("not-a-number"));
cache.expire("counter", Duration::seconds(-1)).await?;
assert_eq!(cache.increment("counter", ttl).await?, 1.0);
Ok(())
}
}