sova_response_cache/
lib.rs1use bytes::Bytes;
4use http::Method;
5use serde::{Deserialize, Serialize};
6use sova_core::extend::{named, MwEntry};
7use sova_core::{with_state, App, Plugin, Request, Response};
8use sova_store::KvStore;
9use std::sync::Arc;
10use std::time::Duration;
11
12const DEFAULT_TTL: Duration = Duration::from_secs(60);
13const DEFAULT_MAX_BODY: usize = 512 * 1024;
14
15#[derive(Clone)]
17pub struct ResponseCache {
18 store: Arc<dyn KvStore>,
19 ttl: Duration,
20 max_body: usize,
21 vary: Vec<String>,
22 cache_private: bool,
23 prefix: String,
24}
25
26#[derive(Serialize, Deserialize)]
27struct Cached {
28 status: u16,
29 headers: Vec<(String, String)>,
30 body: Vec<u8>,
31}
32
33impl ResponseCache {
34 pub fn new(store: Arc<dyn KvStore>) -> Self {
35 Self {
36 store,
37 ttl: DEFAULT_TTL,
38 max_body: DEFAULT_MAX_BODY,
39 vary: Vec::new(),
40 cache_private: false,
41 prefix: "rcache:".into(),
42 }
43 }
44
45 pub fn from_app(app: &App) -> Self {
47 let store = app.try_state::<sova_store::AppStore>().unwrap_or_else(|| {
48 panic!("ResponseCache::from_app requires SharedStore / AppStore installed first")
49 });
50 Self::new(store.namespaced("rcache"))
51 }
52
53 pub fn ttl(mut self, ttl: Duration) -> Self {
54 self.ttl = ttl;
55 self
56 }
57
58 pub fn max_body(mut self, bytes: usize) -> Self {
59 self.max_body = bytes.max(1);
60 self
61 }
62
63 pub fn vary(mut self, headers: &[&str]) -> Self {
64 self.vary = headers.iter().map(|s| s.to_ascii_lowercase()).collect();
65 self
66 }
67
68 pub fn cache_private(mut self, yes: bool) -> Self {
70 self.cache_private = yes;
71 self
72 }
73
74 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
75 self.prefix = prefix.into();
76 self
77 }
78
79 pub async fn invalidate_prefix(&self, path_prefix: &str) -> u64 {
90 let full = format!("{}{}", self.prefix, path_prefix);
91 self.store.clear_prefix(&full).await
92 }
93
94 pub fn middleware(self) -> MwEntry {
95 named(
96 "response-cache",
97 with_state(self, |rt, req, next| async move { run(rt, req, next).await }),
98 )
99 }
100
101 fn cache_key(&self, req: &Request) -> String {
102 let mut parts = vec![req.method.as_str().to_string(), req.path.clone()];
103 let mut vary_vals: Vec<(String, String)> = self
104 .vary
105 .iter()
106 .map(|h| {
107 (
108 h.clone(),
109 req.header(h).unwrap_or("").to_string(),
110 )
111 })
112 .collect();
113 vary_vals.sort_by(|a, b| a.0.cmp(&b.0));
114 for (h, v) in vary_vals {
115 parts.push(format!("{h}={v}"));
116 }
117 format!("{}{}", self.prefix, parts.join("|"))
118 }
119}
120
121impl Plugin for ResponseCache {
122 fn id(&self) -> &'static str {
123 "response-cache"
124 }
125
126 fn meta(&self) -> sova_core::PluginMeta {
127 sova_core::PluginMeta::new("Response cache")
128 .description("Cache GET 200 responses in KvStore")
129 .version(env!("CARGO_PKG_VERSION"))
130 }
131
132 fn install(self, app: &mut App) {
133 app.state(self.clone_handle());
134 app.use_middleware(self.middleware());
135 }
136}
137
138impl ResponseCache {
139 fn clone_handle(&self) -> ResponseCacheHandle {
140 ResponseCacheHandle {
141 store: Arc::clone(&self.store),
142 prefix: self.prefix.clone(),
143 }
144 }
145}
146
147#[derive(Clone)]
149pub struct ResponseCacheHandle {
150 store: Arc<dyn KvStore>,
151 prefix: String,
152}
153
154impl ResponseCacheHandle {
155 pub async fn invalidate_prefix(&self, path_prefix: &str) -> u64 {
156 let full = format!("{}{}", self.prefix, path_prefix);
157 self.store.clear_prefix(&full).await
158 }
159}
160
161async fn run(rt: Arc<ResponseCache>, req: Request, next: sova_core::Next) -> Response {
162 if req.method != Method::GET {
163 return next(req).await;
164 }
165 if !rt.cache_private
166 && (req.header("authorization").is_some() || req.header("cookie").is_some())
167 {
168 return next(req).await;
169 }
170
171 let key = rt.cache_key(&req);
172 if let Some(bytes) = rt.store.get(&key).await {
173 if let Ok(cached) = serde_json::from_slice::<Cached>(&bytes) {
174 let mut res = Response::bytes(cached.body, "application/octet-stream").status(cached.status);
175 for (n, v) in cached.headers {
176 res = res.header(n, v);
177 }
178 return res
179 .header("x-cache", "HIT")
180 .header("cache-control", format!("max-age={}", rt.ttl.as_secs()));
181 }
182 }
183
184 let res = next(req).await;
185 if res.status_code().as_u16() != 200 {
186 return res.header("x-cache", "MISS");
187 }
188 let Some(body) = res.body_bytes().map(|b| b.to_vec()) else {
189 return res.header("x-cache", "MISS");
190 };
191 if body.len() > rt.max_body {
192 return res.header("x-cache", "MISS");
193 }
194
195 let keep = ["content-type", "content-language", "etag"];
196 let headers: Vec<(String, String)> = res
197 .headers()
198 .iter()
199 .filter_map(|(k, v)| {
200 let name = k.as_str().to_ascii_lowercase();
201 if keep.contains(&name.as_str()) {
202 v.to_str().ok().map(|val| (name, val.to_string()))
203 } else {
204 None
205 }
206 })
207 .collect();
208
209 let payload = Cached {
210 status: 200,
211 headers,
212 body,
213 };
214 if let Ok(raw) = serde_json::to_vec(&payload) {
215 rt.store.set(&key, Bytes::from(raw), Some(rt.ttl)).await;
216 }
217 res.header("x-cache", "MISS")
218 .header("cache-control", format!("public, max-age={}", rt.ttl.as_secs()))
219}