use std::time::Duration;
use apiplant_core::CacheConfig;
use redis::AsyncCommands;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[derive(Debug, thiserror::Error)]
pub enum CacheError {
#[error("cache configuration: {0}")]
Config(String),
#[error("invalid cache request: {0}")]
Request(String),
#[error("cache: {0}")]
Backend(String),
}
#[derive(Clone)]
pub struct Cache {
connection: redis::aio::ConnectionManager,
prefix: String,
default_ttl_secs: u64,
timeout: Duration,
}
impl std::fmt::Debug for Cache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Cache")
.field("prefix", &self.prefix)
.field("default_ttl_secs", &self.default_ttl_secs)
.finish()
}
}
impl Cache {
pub async fn connect(config: &CacheConfig) -> Result<Option<Cache>, CacheError> {
if !config.is_active() {
return Ok(None);
}
let url = config.url.trim();
let client =
redis::Client::open(url).map_err(|e| CacheError::Config(format!("{url}: {e}")))?;
let timeout = Duration::from_secs(config.timeout_secs.max(1));
let connection = tokio::time::timeout(timeout, redis::aio::ConnectionManager::new(client))
.await
.map_err(|_| CacheError::Backend(format!("timed out connecting to {url}")))?
.map_err(|e| CacheError::Backend(format!("{url}: {e}")))?;
Ok(Some(Cache {
connection,
prefix: config.prefix.clone(),
default_ttl_secs: config.default_ttl_secs,
timeout,
}))
}
fn key(&self, key: &str) -> Result<String, CacheError> {
prefixed(&self.prefix, key)
}
pub async fn execute(&self, request: &str) -> Result<Value, CacheError> {
let op: Op = serde_json::from_str(request)
.map_err(|e| CacheError::Request(format!("{e}; expected {}", Op::grammar())))?;
match op {
Op::Get { key } => {
let value = self.get(&key).await?;
Ok(json!({ "hit": value.is_some(), "value": value }))
}
Op::Set { key, value, ttl } => {
self.set(&key, &value, ttl).await?;
Ok(json!({ "ok": true }))
}
Op::Delete { key } => Ok(json!({ "deleted": self.delete(&key).await? })),
Op::Exists { key } => Ok(json!({ "exists": self.exists(&key).await? })),
Op::Incr { key, by, ttl } => {
let value = self.incr(&key, by, ttl).await?;
Ok(json!({ "value": value }))
}
Op::Ttl { key } => Ok(json!({ "ttl": self.ttl(&key).await? })),
}
}
pub async fn get(&self, key: &str) -> Result<Option<Value>, CacheError> {
let key = self.key(key)?;
let stored: Option<String> = self.run("get", self.connection.clone().get(&key)).await?;
Ok(stored.map(|text| serde_json::from_str(&text).unwrap_or(Value::String(text))))
}
pub async fn set(&self, key: &str, value: &Value, ttl: Option<u64>) -> Result<(), CacheError> {
let key = self.key(key)?;
let payload =
serde_json::to_string(value).map_err(|e| CacheError::Request(e.to_string()))?;
let ttl = ttl.unwrap_or(self.default_ttl_secs);
let mut connection = self.connection.clone();
if ttl == 0 {
self.run::<()>("set", connection.set(&key, payload)).await
} else {
self.run::<()>("setex", connection.set_ex(&key, payload, ttl))
.await
}
}
pub async fn delete(&self, key: &str) -> Result<bool, CacheError> {
let key = self.key(key)?;
let removed: i64 = self.run("del", self.connection.clone().del(&key)).await?;
Ok(removed > 0)
}
pub async fn exists(&self, key: &str) -> Result<bool, CacheError> {
let key = self.key(key)?;
self.run("exists", self.connection.clone().exists(&key))
.await
}
pub async fn incr(&self, key: &str, by: i64, ttl: Option<u64>) -> Result<i64, CacheError> {
let key = self.key(key)?;
let mut connection = self.connection.clone();
let value: i64 = self.run("incrby", connection.incr(&key, by)).await?;
let ttl = ttl.unwrap_or(self.default_ttl_secs);
if ttl > 0 && value == by {
self.run::<bool>("expire", connection.expire(&key, ttl as i64))
.await?;
}
Ok(value)
}
pub async fn ttl(&self, key: &str) -> Result<Option<i64>, CacheError> {
let key = self.key(key)?;
let seconds: i64 = self.run("ttl", self.connection.clone().ttl(&key)).await?;
Ok((seconds >= 0).then_some(seconds))
}
async fn run<T>(
&self,
command: &str,
future: impl std::future::Future<Output = redis::RedisResult<T>>,
) -> Result<T, CacheError> {
match tokio::time::timeout(self.timeout, future).await {
Ok(Ok(value)) => Ok(value),
Ok(Err(e)) => Err(CacheError::Backend(format!("{command}: {e}"))),
Err(_) => Err(CacheError::Backend(format!(
"{command}: timed out after {:?}",
self.timeout
))),
}
}
}
fn prefixed(prefix: &str, key: &str) -> Result<String, CacheError> {
let key = key.trim();
if key.is_empty() {
return Err(CacheError::Request("a cache key cannot be empty".into()));
}
Ok(format!("{prefix}{key}"))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "lowercase")]
pub enum Op {
Get {
key: String,
},
Set {
key: String,
value: Value,
#[serde(default)]
ttl: Option<u64>,
},
#[serde(alias = "del")]
Delete {
key: String,
},
Exists {
key: String,
},
#[serde(alias = "increment")]
Incr {
key: String,
#[serde(default = "one")]
by: i64,
#[serde(default)]
ttl: Option<u64>,
},
Ttl {
key: String,
},
}
fn one() -> i64 {
1
}
impl Op {
pub fn grammar() -> &'static str {
r#"{"op":"get"|"set"|"delete"|"exists"|"incr"|"ttl","key":"…", …}"#
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cache_is_only_connected_when_one_is_configured() {
assert!(!CacheConfig::default().is_active());
assert!(CacheConfig {
url: "redis://127.0.0.1:6379".into(),
..CacheConfig::default()
}
.is_active());
}
#[test]
fn operations_parse_from_the_json_a_function_sends() {
let get: Op = serde_json::from_str(r#"{"op":"get","key":"k"}"#).unwrap();
assert!(matches!(get, Op::Get { key } if key == "k"));
let set: Op =
serde_json::from_str(r#"{"op":"set","key":"k","value":{"a":1},"ttl":60}"#).unwrap();
match set {
Op::Set { key, value, ttl } => {
assert_eq!(key, "k");
assert_eq!(value["a"], 1);
assert_eq!(ttl, Some(60));
}
other => panic!("expected a set, got {other:?}"),
}
let no_ttl: Op = serde_json::from_str(r#"{"op":"set","key":"k","value":null}"#).unwrap();
assert!(matches!(no_ttl, Op::Set { ttl: None, .. }));
let never: Op =
serde_json::from_str(r#"{"op":"set","key":"k","value":1,"ttl":0}"#).unwrap();
assert!(matches!(never, Op::Set { ttl: Some(0), .. }));
let incr: Op = serde_json::from_str(r#"{"op":"incr","key":"hits"}"#).unwrap();
assert!(matches!(incr, Op::Incr { by: 1, .. }));
assert!(matches!(
serde_json::from_str::<Op>(r#"{"op":"del","key":"k"}"#).unwrap(),
Op::Delete { .. }
));
}
#[test]
fn an_unknown_operation_is_rejected() {
assert!(serde_json::from_str::<Op>(r#"{"op":"flushall"}"#).is_err());
assert!(serde_json::from_str::<Op>(r#"{"op":"get"}"#).is_err());
assert!(serde_json::from_str::<Op>("not json").is_err());
}
#[test]
fn keys_are_prefixed_and_never_empty() {
assert_eq!(prefixed("app:", "rates").unwrap(), "app:rates");
assert_eq!(prefixed("", " rates ").unwrap(), "rates");
assert!(prefixed("app:", "").is_err());
assert!(prefixed("app:", " ").is_err());
}
}