Skip to main content

sova_response_cache/
lib.rs

1//! GET response cache with optional private caching and prefix invalidation.
2
3use 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/// Cache successful GET responses in a [`KvStore`].
16#[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    /// Use installed [`sova_store::AppStore`] (`rcache` namespace).
46    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    /// Allow caching requests that carry `Authorization` / `Cookie`.
69    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    /// Invalidate keys under `path_prefix` (same as [`ResponseCacheHandle::invalidate_prefix`]).
80    ///
81    /// Typical pattern with [`sova_core::EventBus`]:
82    /// ```ignore
83    /// let handle = app.try_state::<ResponseCacheHandle>().unwrap();
84    /// bus.listen::<NoteCreated, _>(move |_| {
85    ///     let h = handle.clone();
86    ///     tokio::spawn(async move { h.invalidate_prefix("/api/notes").await; });
87    /// });
88    /// ```
89    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/// App-state handle for invalidation from handlers / event listeners.
148#[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}