shaperail_runtime/cache/
store.rs1use std::collections::HashMap;
2use std::sync::Arc;
3
4use redis::AsyncCommands;
5use sha2::{Digest, Sha256};
6
7#[derive(Clone)]
12pub struct RedisCache {
13 pool: Arc<deadpool_redis::Pool>,
14}
15
16impl RedisCache {
17 pub fn new(pool: Arc<deadpool_redis::Pool>) -> Self {
19 Self { pool }
20 }
21
22 pub fn build_key(
26 resource: &str,
27 endpoint: &str,
28 query_params: &HashMap<String, String>,
29 user_role: &str,
30 ) -> String {
31 let query_hash = Self::hash_query(query_params);
32 format!("shaperail:{resource}:{endpoint}:{query_hash}:{user_role}")
33 }
34
35 fn hash_query(params: &HashMap<String, String>) -> String {
37 let mut sorted: Vec<(&String, &String)> = params.iter().collect();
38 sorted.sort_by_key(|(k, _)| k.as_str());
39
40 let mut hasher = Sha256::new();
41 for (k, v) in sorted {
42 hasher.update(k.as_bytes());
43 hasher.update(b"=");
44 hasher.update(v.as_bytes());
45 hasher.update(b"&");
46 }
47 let result = hasher.finalize();
48 result[..8]
50 .iter()
51 .fold(String::with_capacity(16), |mut s, b| {
52 use std::fmt::Write;
53 let _ = write!(s, "{b:02x}");
54 s
55 })
56 }
57
58 pub async fn get(&self, key: &str) -> Option<String> {
62 let _span = crate::observability::telemetry::cache_span("get", key).entered();
63 let mut conn = self.pool.get().await.ok()?;
64 let result: Option<String> = conn.get(key).await.ok()?;
65 result
66 }
67
68 pub async fn set(&self, key: &str, value: &str, ttl_secs: u64) {
72 let _span = crate::observability::telemetry::cache_span("set", key).entered();
73 let Ok(mut conn) = self.pool.get().await else {
74 return;
75 };
76 let _: Result<(), _> = conn.set_ex(key, value, ttl_secs).await;
77 }
78
79 pub async fn invalidate_resource(&self, resource: &str) {
84 let Ok(mut conn) = self.pool.get().await else {
85 return;
86 };
87 let pattern = format!("shaperail:{resource}:*");
88 let keys: Vec<String> = match redis::cmd("KEYS")
89 .arg(&pattern)
90 .query_async(&mut *conn)
91 .await
92 {
93 Ok(keys) => keys,
94 Err(_) => return,
95 };
96 if keys.is_empty() {
97 return;
98 }
99 let _: Result<(), _> = conn.del(keys).await;
100 }
101
102 pub async fn invalidate_if_needed(
107 &self,
108 resource: &str,
109 action: &str,
110 invalidate_on: Option<&[String]>,
111 ) {
112 let should_invalidate = match invalidate_on {
113 Some(actions) => actions.iter().any(|a| a == action),
114 None => true, };
116 if should_invalidate {
117 self.invalidate_resource(resource).await;
118 }
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn build_key_format() {
128 let mut params = HashMap::new();
129 params.insert("filter[role]".to_string(), "admin".to_string());
130 let key = RedisCache::build_key("users", "list", ¶ms, "member");
131 assert!(key.starts_with("shaperail:users:list:"));
132 assert!(key.ends_with(":member"));
133 }
134
135 #[test]
136 fn build_key_empty_params() {
137 let params = HashMap::new();
138 let key = RedisCache::build_key("users", "list", ¶ms, "admin");
139 assert!(key.starts_with("shaperail:users:list:"));
140 assert!(key.ends_with(":admin"));
141 }
142
143 #[test]
144 fn hash_query_deterministic() {
145 let mut params1 = HashMap::new();
146 params1.insert("a".to_string(), "1".to_string());
147 params1.insert("b".to_string(), "2".to_string());
148
149 let mut params2 = HashMap::new();
150 params2.insert("b".to_string(), "2".to_string());
151 params2.insert("a".to_string(), "1".to_string());
152
153 assert_eq!(
155 RedisCache::hash_query(¶ms1),
156 RedisCache::hash_query(¶ms2)
157 );
158 }
159
160 #[test]
161 fn hash_query_different_params() {
162 let mut params1 = HashMap::new();
163 params1.insert("a".to_string(), "1".to_string());
164
165 let mut params2 = HashMap::new();
166 params2.insert("a".to_string(), "2".to_string());
167
168 assert_ne!(
169 RedisCache::hash_query(¶ms1),
170 RedisCache::hash_query(¶ms2)
171 );
172 }
173}