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    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    /// Allow caching requests that carry `Authorization` / `Cookie`.
61    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    /// Invalidate keys under `path_prefix` (same as [`ResponseCacheHandle::invalidate_prefix`]).
72    ///
73    /// Typical pattern with [`sova_core::EventBus`]:
74    /// ```ignore
75    /// let handle = app.try_state::<ResponseCacheHandle>().unwrap();
76    /// bus.listen::<NoteCreated, _>(move |_| {
77    ///     let h = handle.clone();
78    ///     tokio::spawn(async move { h.invalidate_prefix("/api/notes").await; });
79    /// });
80    /// ```
81    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/// App-state handle for invalidation from handlers / event listeners.
140#[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}