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 ttl(mut self, ttl: Duration) -> Self {
46 self.ttl = ttl;
47 self
48 }
49
50 pub fn max_body(mut self, bytes: usize) -> Self {
51 self.max_body = bytes.max(1);
52 self
53 }
54
55 pub fn vary(mut self, headers: &[&str]) -> Self {
56 self.vary = headers.iter().map(|s| s.to_ascii_lowercase()).collect();
57 self
58 }
59
60 pub fn cache_private(mut self, yes: bool) -> Self {
62 self.cache_private = yes;
63 self
64 }
65
66 pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
67 self.prefix = prefix.into();
68 self
69 }
70
71 pub async fn invalidate_prefix(&self, path_prefix: &str) -> u64 {
82 let full = format!("{}{}", self.prefix, path_prefix);
83 self.store.clear_prefix(&full).await
84 }
85
86 pub fn middleware(self) -> MwEntry {
87 named(
88 "response-cache",
89 with_state(self, |rt, req, next| async move { run(rt, req, next).await }),
90 )
91 }
92
93 fn cache_key(&self, req: &Request) -> String {
94 let mut parts = vec![req.method.as_str().to_string(), req.path.clone()];
95 let mut vary_vals: Vec<(String, String)> = self
96 .vary
97 .iter()
98 .map(|h| {
99 (
100 h.clone(),
101 req.header(h).unwrap_or("").to_string(),
102 )
103 })
104 .collect();
105 vary_vals.sort_by(|a, b| a.0.cmp(&b.0));
106 for (h, v) in vary_vals {
107 parts.push(format!("{h}={v}"));
108 }
109 format!("{}{}", self.prefix, parts.join("|"))
110 }
111}
112
113impl Plugin for ResponseCache {
114 fn id(&self) -> &'static str {
115 "response-cache"
116 }
117
118 fn meta(&self) -> sova_core::PluginMeta {
119 sova_core::PluginMeta::new("Response cache")
120 .description("Cache GET 200 responses in KvStore")
121 .version(env!("CARGO_PKG_VERSION"))
122 }
123
124 fn install(self, app: &mut App) {
125 app.state(self.clone_handle());
126 app.use_middleware(self.middleware());
127 }
128}
129
130impl ResponseCache {
131 fn clone_handle(&self) -> ResponseCacheHandle {
132 ResponseCacheHandle {
133 store: Arc::clone(&self.store),
134 prefix: self.prefix.clone(),
135 }
136 }
137}
138
139#[derive(Clone)]
141pub struct ResponseCacheHandle {
142 store: Arc<dyn KvStore>,
143 prefix: String,
144}
145
146impl ResponseCacheHandle {
147 pub async fn invalidate_prefix(&self, path_prefix: &str) -> u64 {
148 let full = format!("{}{}", self.prefix, path_prefix);
149 self.store.clear_prefix(&full).await
150 }
151}
152
153async fn run(rt: Arc<ResponseCache>, req: Request, next: sova_core::Next) -> Response {
154 if req.method != Method::GET {
155 return next(req).await;
156 }
157 if !rt.cache_private
158 && (req.header("authorization").is_some() || req.header("cookie").is_some())
159 {
160 return next(req).await;
161 }
162
163 let key = rt.cache_key(&req);
164 if let Some(bytes) = rt.store.get(&key).await {
165 if let Ok(cached) = serde_json::from_slice::<Cached>(&bytes) {
166 let mut res = Response::bytes(cached.body, "application/octet-stream").status(cached.status);
167 for (n, v) in cached.headers {
168 res = res.header(n, v);
169 }
170 return res
171 .header("x-cache", "HIT")
172 .header("cache-control", format!("max-age={}", rt.ttl.as_secs()));
173 }
174 }
175
176 let res = next(req).await;
177 if res.status_code().as_u16() != 200 {
178 return res.header("x-cache", "MISS");
179 }
180 let Some(body) = res.body_bytes().map(|b| b.to_vec()) else {
181 return res.header("x-cache", "MISS");
182 };
183 if body.len() > rt.max_body {
184 return res.header("x-cache", "MISS");
185 }
186
187 let keep = ["content-type", "content-language", "etag"];
188 let headers: Vec<(String, String)> = res
189 .headers()
190 .iter()
191 .filter_map(|(k, v)| {
192 let name = k.as_str().to_ascii_lowercase();
193 if keep.contains(&name.as_str()) {
194 v.to_str().ok().map(|val| (name, val.to_string()))
195 } else {
196 None
197 }
198 })
199 .collect();
200
201 let payload = Cached {
202 status: 200,
203 headers,
204 body,
205 };
206 if let Ok(raw) = serde_json::to_vec(&payload) {
207 rt.store.set(&key, Bytes::from(raw), Some(rt.ttl)).await;
208 }
209 res.header("x-cache", "MISS")
210 .header("cache-control", format!("public, max-age={}", rt.ttl.as_secs()))
211}