use guard_core_engine::distributed::{BanStore, SlidingWindowStore, StoreError};
#[derive(Debug)]
pub struct RedisStoreError(pub redis::RedisError);
impl core::fmt::Display for RedisStoreError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "redis store connection failed: {}", self.0)
}
}
impl std::error::Error for RedisStoreError {}
pub struct RedisStore {
client: redis::Client,
}
impl RedisStore {
pub fn connect(url: &str) -> Result<Self, RedisStoreError> {
Ok(Self {
client: redis::Client::open(url).map_err(RedisStoreError)?,
})
}
fn connection(&self) -> Result<redis::Connection, StoreError> {
self.client
.get_connection()
.map_err(|error| StoreError(error.to_string()))
}
}
impl SlidingWindowStore for RedisStore {
fn record_hit(&self, key: &str, now: f64, window: u64) -> Result<u64, StoreError> {
let mut connection = self.connection()?;
let window_start = now - f64::from(u32::try_from(window).unwrap_or(u32::MAX));
let count: i64 = redis::pipe()
.atomic()
.zadd(key, now.to_string(), now)
.ignore()
.zrembyscore(key, 0_f64, window_start)
.ignore()
.cmd("EXPIRE")
.arg(key)
.arg(window * 2)
.ignore()
.zcard(key)
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))?;
Ok(u64::try_from(count).unwrap_or(u64::MAX))
}
}
impl BanStore for RedisStore {
fn set_ban(&self, key: &str, expiry: f64, ttl_seconds: u64) -> Result<(), StoreError> {
let mut connection = self.connection()?;
redis::cmd("SET")
.arg(key)
.arg(expiry.to_string())
.arg("EX")
.arg(ttl_seconds)
.query::<()>(&mut connection)
.map_err(|error| StoreError(error.to_string()))
}
fn get_ban(&self, key: &str) -> Result<Option<f64>, StoreError> {
let mut connection = self.connection()?;
let stored: Option<String> = redis::cmd("GET")
.arg(key)
.query(&mut connection)
.map_err(|error| StoreError(error.to_string()))?;
Ok(stored.and_then(|value| value.parse::<f64>().ok()))
}
fn delete_ban(&self, key: &str) -> Result<(), StoreError> {
let mut connection = self.connection()?;
redis::cmd("DEL")
.arg(key)
.query::<i64>(&mut connection)
.map(|_| ())
.map_err(|error| StoreError(error.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn connect_fails_closed_on_an_unroutable_url() {
let store = RedisStore::connect("redis://127.0.0.1:1");
assert!(store.is_ok(), "client construction is lazy");
}
#[test]
fn store_impls_are_object_safe() {
let store: Arc<dyn SlidingWindowStore> = Arc::new(
RedisStore::connect("redis://127.0.0.1:1").expect("lazy client"),
);
let _bans: Arc<dyn BanStore> = Arc::new(
RedisStore::connect("redis://127.0.0.1:1").expect("lazy client"),
);
let _ = store;
}
}