umbral_cache/lib.rs
1//! umbral-cache — pluggable cache for umbral.
2//!
3//! A cache framework, the slice that matters for production:
4//! a [`Cache`] handle over a [`CacheBackend`] trait, three built-in
5//! backends (in-memory, SQLite, Redis), and a [`cache_page`] view
6//! middleware that caches full GET responses.
7//!
8//! ```ignore
9//! // Boot wiring (App::builder)
10//! let cache = Cache::memory();
11//! // … or for Redis in production:
12//! // let cache = Cache::redis("redis://localhost:6379/0").await?;
13//! CachePlugin::init(cache.clone());
14//!
15//! // In a handler — explicit cache access
16//! cache.set("homepage:html", &rendered, Some(Duration::from_secs(60))).await;
17//! if let Some(html) = cache.get::<String>("homepage:html").await {
18//! return Ok(Html(html));
19//! }
20//!
21//! // View-level caching (wraps a Router subtree)
22//! use umbral_cache::cache_page;
23//! let public = Router::new()
24//! .route("/", get(home))
25//! .layer(cache_page(Duration::from_secs(60)));
26//! ```
27//!
28//! ## Surface
29//!
30//! - [`CacheBackend`] — the trait. Bytes in, bytes out, async.
31//! - [`CacheError`] — unified error type for backends that can fail.
32//! - [`Cache`] — the handle. Generic-over-T methods wrap the backend
33//! with serde encoding so callers traffic in their own types.
34//! - [`MemoryBackend`] — `tokio::sync::Mutex<HashMap>` with per-key
35//! expiry. Lost on process exit. Default choice for development
36//! and single-process deployments.
37//! - [`SqliteBackend`] — table-backed, durable across restarts.
38//! Expired rows are lazily skipped on read and cleared on a
39//! background pass when [`SqliteBackend::sweep`] is called.
40//! - [`RedisBackend`] — (feature = `"redis"`) production backend via
41//! `redis::aio::ConnectionManager`. Handles reconnect transparently.
42//! - [`cache_page`] — tower [`Layer`] that caches full GET/HEAD responses.
43//! Only status 200 is cached; skips when `Cache-Control: no-store`
44//! or `Set-Cookie` appears on the response.
45//! - [`CachePlugin`] — empty Plugin impl so other plugins can name
46//! "cache" as a dependency.
47//!
48//! ## Deferred past v0
49//!
50//! - `get_or_set` helper that fills on miss inside a single round-trip.
51//! - Versioned keys + `incr/decr` atomic ops.
52//! - Memcached backend.
53//! - Distributed cache invalidation (tag-based).
54//! - ETag / 304 conditional caching inside `cache_page` — the current
55//! implementation always serves the cached body in full.
56
57use std::collections::HashMap;
58use std::sync::{Arc, OnceLock};
59use std::time::Duration;
60
61use async_trait::async_trait;
62use chrono::{DateTime, Utc};
63use http::header::{CACHE_CONTROL, HeaderValue, VARY};
64use serde::{Serialize, de::DeserializeOwned};
65use sqlx::SqlitePool;
66use tokio::sync::Mutex;
67use tower_http::compression::CompressionLayer;
68use tower_http::set_header::SetResponseHeaderLayer;
69use umbral::prelude::*;
70
71pub mod cache_page;
72pub use cache_page::cache_page;
73
74// ── Ambient cache handle ─────────────────────────────────────────────────────
75
76/// Process-wide ambient cache, set once during `App::build()` (or manually
77/// by calling [`CachePlugin::init`]). `cache_page` reads this automatically.
78static AMBIENT_CACHE: OnceLock<Cache> = OnceLock::new();
79
80/// Return the ambient cache, or `None` if [`CachePlugin::init`] hasn't run.
81pub fn ambient() -> Option<&'static Cache> {
82 AMBIENT_CACHE.get()
83}
84
85// ── Error type ───────────────────────────────────────────────────────────────
86
87/// Error variants emitted by cache backends that can fail (Redis, SQLite).
88/// `MemoryBackend` is infallible — its methods are fire-and-forget.
89#[derive(Debug)]
90pub enum CacheError {
91 /// A Redis-level error (connection, protocol, server).
92 #[cfg(feature = "redis")]
93 Redis(redis::RedisError),
94 /// A SQLite-level error.
95 Sqlx(sqlx::Error),
96 /// Any other I/O or configuration error.
97 Other(String),
98}
99
100impl std::fmt::Display for CacheError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 #[cfg(feature = "redis")]
104 CacheError::Redis(e) => write!(f, "cache redis error: {e}"),
105 CacheError::Sqlx(e) => write!(f, "cache sqlite error: {e}"),
106 CacheError::Other(s) => write!(f, "cache error: {s}"),
107 }
108 }
109}
110
111impl std::error::Error for CacheError {}
112
113#[cfg(feature = "redis")]
114impl From<redis::RedisError> for CacheError {
115 fn from(e: redis::RedisError) -> Self {
116 CacheError::Redis(e)
117 }
118}
119
120impl From<sqlx::Error> for CacheError {
121 fn from(e: sqlx::Error) -> Self {
122 CacheError::Sqlx(e)
123 }
124}
125
126// ── CacheBackend trait ───────────────────────────────────────────────────────
127
128/// Bytes-in / bytes-out backend. All methods are async because the
129/// SQLite and Redis implementations need to be.
130///
131/// `get_bytes` / `set_bytes` / `delete` / `clear` are infallible at the
132/// trait level — backends swallow errors internally and log them rather
133/// than propagating. Constructors (`new`, `connect`) surface errors via
134/// [`CacheError`] so misconfiguration is caught at boot.
135#[async_trait]
136pub trait CacheBackend: Send + Sync {
137 async fn get(&self, key: &str) -> Option<Vec<u8>>;
138 async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>);
139 async fn delete(&self, key: &str);
140 async fn clear(&self);
141}
142
143// ── Cache handle ─────────────────────────────────────────────────────────────
144
145/// Public handle. Owns its backend behind an Arc so views can clone
146/// it freely (typically stashed in the request context or accessed via
147/// the ambient [`AMBIENT_CACHE`]).
148#[derive(Clone)]
149pub struct Cache {
150 backend: Arc<dyn CacheBackend>,
151}
152
153impl Cache {
154 /// Build a cache backed by a freshly-allocated [`MemoryBackend`].
155 pub fn memory() -> Self {
156 Self {
157 backend: Arc::new(MemoryBackend::default()),
158 }
159 }
160
161 /// Build a cache backed by a SQLite table. The constructor
162 /// creates the table on first call; it's idempotent.
163 pub async fn sqlite(pool: SqlitePool) -> Result<Self, CacheError> {
164 let backend = SqliteBackend::new(pool).await?;
165 Ok(Self {
166 backend: Arc::new(backend),
167 })
168 }
169
170 /// Build a cache backed by Redis.
171 ///
172 /// `url` is a Redis connection string: `redis://[user:pass@]host:port/[db]`.
173 /// Examples: `redis://localhost:6379/0`, `redis://:password@redis.example.com:6379`.
174 ///
175 /// The underlying [`redis::aio::ConnectionManager`] reconnects automatically
176 /// on dropped connections so the handle is safe to clone and reuse for the
177 /// lifetime of the process.
178 #[cfg(feature = "redis")]
179 pub async fn redis(url: &str) -> Result<Self, CacheError> {
180 let backend = RedisBackend::connect(url).await?;
181 Ok(Self {
182 backend: Arc::new(backend),
183 })
184 }
185
186 /// Wrap an arbitrary backend.
187 pub fn with_backend(backend: Arc<dyn CacheBackend>) -> Self {
188 Self { backend }
189 }
190
191 /// Look up a key, deserialise to T. Returns None on miss, on
192 /// expiry, or on a decode error (the entry is treated as
193 /// poisoned and ignored rather than crashing the caller).
194 pub async fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
195 let bytes = self.backend.get(key).await?;
196 serde_json::from_slice(&bytes).ok()
197 }
198
199 /// Set a key. The value is serialised with serde_json. `ttl =
200 /// None` means no expiry.
201 pub async fn set<T: Serialize + ?Sized>(
202 &self,
203 key: &str,
204 value: &T,
205 ttl: Option<Duration>,
206 ) -> Result<(), serde_json::Error> {
207 let bytes = serde_json::to_vec(value)?;
208 self.backend.set(key, bytes, ttl).await;
209 Ok(())
210 }
211
212 pub async fn delete(&self, key: &str) {
213 self.backend.delete(key).await;
214 }
215
216 pub async fn clear(&self) {
217 self.backend.clear().await;
218 }
219
220 // ── Raw bytes access for cache_page (avoids double-serialisation) ──
221
222 pub(crate) async fn get_bytes_raw(&self, key: &str) -> Option<Vec<u8>> {
223 self.backend.get(key).await
224 }
225
226 pub(crate) async fn set_bytes_raw(&self, key: &str, bytes: Vec<u8>, ttl: Option<Duration>) {
227 self.backend.set(key, bytes, ttl).await;
228 }
229}
230
231// ── MemoryBackend ────────────────────────────────────────────────────────────
232
233struct MemoryEntry {
234 value: Vec<u8>,
235 expires_at: Option<DateTime<Utc>>,
236}
237
238#[derive(Default)]
239pub struct MemoryBackend {
240 inner: Mutex<HashMap<String, MemoryEntry>>,
241}
242
243#[async_trait]
244impl CacheBackend for MemoryBackend {
245 async fn get(&self, key: &str) -> Option<Vec<u8>> {
246 let mut map = self.inner.lock().await;
247 if let Some(entry) = map.get(key) {
248 if let Some(exp) = entry.expires_at {
249 if Utc::now() >= exp {
250 map.remove(key);
251 return None;
252 }
253 }
254 return Some(entry.value.clone());
255 }
256 None
257 }
258
259 async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) {
260 let expires_at = ttl.and_then(|d| {
261 chrono::Duration::from_std(d)
262 .ok()
263 .and_then(|cd| Utc::now().checked_add_signed(cd))
264 });
265 self.inner
266 .lock()
267 .await
268 .insert(key.to_string(), MemoryEntry { value, expires_at });
269 }
270
271 async fn delete(&self, key: &str) {
272 self.inner.lock().await.remove(key);
273 }
274
275 async fn clear(&self) {
276 self.inner.lock().await.clear();
277 }
278}
279
280// ── SqliteBackend ────────────────────────────────────────────────────────────
281//
282// CLAUDE.md exception — backend-specific raw SQL is allowed here.
283//
284// The original blockers (no `Vec<u8>` field type, no `upsert`
285// terminal) both shipped in subsequent commits — see
286// `SqlType::Bytes` and `Manager::upsert`. The remaining reason this
287// backend keeps `sqlx::query(...)` calls:
288//
289// `SqliteBackend` takes an EXPLICIT `SqlitePool` by design (not
290// the framework's ambient pool). The ORM's `Manager` terminals
291// read `umbral::db::pool()` for ambient routing; binding them to
292// a different pool requires an `Manager::upsert_with(&pool, ...)`
293// escape hatch that doesn't yet exist. Adding it lands when the
294// first non-ambient-pool consumer asks for it.
295//
296// `Cache::sqlite(pool)` is the explicit-pool entry point — a user
297// who calls it opted into SQLite by name AND into a pool that may
298// be separate from the framework's main pool (cache I/O frequently
299// runs against its own smaller, dedicated pool). The Redis backend
300// below handles the non-SQLite case; an eventual `PgBackend` would
301// be its own sibling.
302
303/// SQLite-backed cache. Table: `umbral_cache(key TEXT PRIMARY KEY,
304/// value BLOB NOT NULL, expires_at TIMESTAMP NULL)`. Expired rows
305/// are skipped on read and removed by [`SqliteBackend::sweep`] for
306/// periodic cleanup.
307pub struct SqliteBackend {
308 pool: SqlitePool,
309}
310
311impl SqliteBackend {
312 pub async fn new(pool: SqlitePool) -> Result<Self, CacheError> {
313 sqlx::query(
314 "CREATE TABLE IF NOT EXISTS umbral_cache (
315 key TEXT PRIMARY KEY,
316 value BLOB NOT NULL,
317 expires_at TIMESTAMP NULL
318 )",
319 )
320 .execute(&pool)
321 .await
322 .map_err(CacheError::Sqlx)?;
323 Ok(Self { pool })
324 }
325
326 /// Remove every expired row. Call from a periodic task; reads
327 /// already skip expired rows so a call is never required for
328 /// correctness, only for keeping the table small.
329 pub async fn sweep(&self) -> Result<u64, CacheError> {
330 let result = sqlx::query(
331 "DELETE FROM umbral_cache WHERE expires_at IS NOT NULL AND expires_at <= ?",
332 )
333 .bind(Utc::now())
334 .execute(&self.pool)
335 .await
336 .map_err(CacheError::Sqlx)?;
337 Ok(result.rows_affected())
338 }
339}
340
341#[async_trait]
342impl CacheBackend for SqliteBackend {
343 async fn get(&self, key: &str) -> Option<Vec<u8>> {
344 let row: Option<(Vec<u8>, Option<DateTime<Utc>>)> =
345 sqlx::query_as("SELECT value, expires_at FROM umbral_cache WHERE key = ?")
346 .bind(key)
347 .fetch_optional(&self.pool)
348 .await
349 .ok()?;
350 let (value, expires_at) = row?;
351 if let Some(exp) = expires_at {
352 if Utc::now() >= exp {
353 return None;
354 }
355 }
356 Some(value)
357 }
358
359 async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) {
360 let expires_at = ttl.and_then(|d| {
361 chrono::Duration::from_std(d)
362 .ok()
363 .and_then(|cd| Utc::now().checked_add_signed(cd))
364 });
365 // BROKEN-12: log swallowed write errors. A cache backend is
366 // best-effort (a failed write must not break the request), but a
367 // locked SQLite / dead pool that no-ops every write forever should
368 // not be invisible — the trait doc promised "and log them".
369 if let Err(e) = sqlx::query(
370 "INSERT INTO umbral_cache (key, value, expires_at) VALUES (?, ?, ?)
371 ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
372 )
373 .bind(key)
374 .bind(value)
375 .bind(expires_at)
376 .execute(&self.pool)
377 .await
378 {
379 tracing::warn!(error = %e, key, "umbral-cache: SQLite cache set failed (swallowed)");
380 }
381 }
382
383 async fn delete(&self, key: &str) {
384 if let Err(e) = sqlx::query("DELETE FROM umbral_cache WHERE key = ?")
385 .bind(key)
386 .execute(&self.pool)
387 .await
388 {
389 tracing::warn!(error = %e, key, "umbral-cache: SQLite cache delete failed (swallowed)");
390 }
391 }
392
393 async fn clear(&self) {
394 if let Err(e) = sqlx::query("DELETE FROM umbral_cache")
395 .execute(&self.pool)
396 .await
397 {
398 tracing::warn!(error = %e, "umbral-cache: SQLite cache clear failed (swallowed)");
399 }
400 }
401}
402
403// ── RedisBackend ─────────────────────────────────────────────────────────────
404
405/// Redis-backed cache. Requires the `redis` cargo feature.
406///
407/// Uses `redis::aio::ConnectionManager` for automatic reconnection. TTL
408/// is stored natively via Redis `SETEX` when a duration is supplied, so
409/// expiry is handled server-side and does not require a background sweep.
410///
411/// gaps4 #21: every key is namespaced under [`Self::DEFAULT_PREFIX`], and
412/// `clear()` deletes only keys under that prefix (via `SCAN` + `UNLINK`) — NOT
413/// `FLUSHDB`, which would wipe every co-tenant of a shared Redis (sessions,
414/// rate limits, queues, another app). Point the backend at its own logical DB
415/// too if you can, but the prefix means a shared DB is no longer a data-loss
416/// footgun.
417#[cfg(feature = "redis")]
418pub struct RedisBackend {
419 client: redis::aio::ConnectionManager,
420 prefix: String,
421}
422
423#[cfg(feature = "redis")]
424impl RedisBackend {
425 /// Connect to Redis at `url`. Returns a ready-to-use backend or a
426 /// [`CacheError::Redis`] if the initial connection fails.
427 ///
428 /// `url` form: `redis://[user:pass@]host:port/[db]`
429 /// Example: `redis://localhost:6379/0`
430 pub async fn connect(url: &str) -> Result<Self, CacheError> {
431 Self::connect_with_prefix(url, Self::DEFAULT_PREFIX).await
432 }
433
434 /// The key namespace. Every entry is stored as `<prefix><key>`, and
435 /// `clear()` deletes exactly `<prefix>*`.
436 pub const DEFAULT_PREFIX: &'static str = "umbral:cache:";
437
438 /// [`Self::connect`] with a custom key prefix — set one per app when
439 /// several umbral apps share one Redis DB so their caches don't collide
440 /// and each `clear()` stays scoped to its own app.
441 pub async fn connect_with_prefix(url: &str, prefix: &str) -> Result<Self, CacheError> {
442 let client = redis::Client::open(url).map_err(CacheError::Redis)?;
443 let manager = redis::aio::ConnectionManager::new(client)
444 .await
445 .map_err(CacheError::Redis)?;
446 Ok(Self {
447 client: manager,
448 prefix: prefix.to_string(),
449 })
450 }
451
452 /// Namespace a caller key under this backend's prefix.
453 fn k(&self, key: &str) -> String {
454 format!("{}{key}", self.prefix)
455 }
456}
457
458#[cfg(feature = "redis")]
459#[async_trait]
460impl CacheBackend for RedisBackend {
461 async fn get(&self, key: &str) -> Option<Vec<u8>> {
462 use redis::AsyncCommands;
463 let mut conn = self.client.clone();
464 conn.get::<_, Option<Vec<u8>>>(self.k(key))
465 .await
466 .ok()
467 .flatten()
468 }
469
470 async fn set(&self, key: &str, value: Vec<u8>, ttl: Option<Duration>) {
471 use redis::AsyncCommands;
472 let mut conn = self.client.clone();
473 let key = self.k(key);
474 // BROKEN-12: log swallowed errors — a dead Redis that no-ops every
475 // write should not be silent (the trait doc promised "and log them").
476 let res: Result<(), _> = if let Some(dur) = ttl {
477 let secs = dur.as_secs().max(1);
478 conn.set_ex(&key, value, secs).await
479 } else {
480 conn.set(&key, value).await
481 };
482 if let Err(e) = res {
483 tracing::warn!(error = %e, key, "umbral-cache: Redis cache set failed (swallowed)");
484 }
485 }
486
487 async fn delete(&self, key: &str) {
488 use redis::AsyncCommands;
489 let mut conn = self.client.clone();
490 let key = self.k(key);
491 if let Err(e) = conn.del::<_, ()>(&key).await {
492 tracing::warn!(error = %e, key, "umbral-cache: Redis cache delete failed (swallowed)");
493 }
494 }
495
496 async fn clear(&self) {
497 // gaps4 #21: delete only THIS cache's keys, never `FLUSHDB`. SCAN the
498 // keyspace for `<prefix>*` in batches and UNLINK (non-blocking DEL) each
499 // batch, so a Redis DB shared with sessions / rate-limits / another app
500 // keeps its unrelated keys.
501 use redis::AsyncCommands;
502 let mut conn = self.client.clone();
503 let pattern = format!("{}*", self.prefix);
504 let mut cursor: u64 = 0;
505 loop {
506 let scan: redis::RedisResult<(u64, Vec<String>)> = redis::cmd("SCAN")
507 .arg(cursor)
508 .arg("MATCH")
509 .arg(&pattern)
510 .arg("COUNT")
511 .arg(512)
512 .query_async(&mut conn)
513 .await;
514 let (next, keys) = match scan {
515 Ok(v) => v,
516 Err(e) => {
517 tracing::warn!(error = %e, "umbral-cache: Redis cache clear SCAN failed (swallowed)");
518 return;
519 }
520 };
521 if !keys.is_empty() {
522 if let Err(e) = conn.unlink::<_, ()>(keys).await {
523 tracing::warn!(error = %e, "umbral-cache: Redis cache clear UNLINK failed (swallowed)");
524 }
525 }
526 cursor = next;
527 if cursor == 0 {
528 break;
529 }
530 }
531 }
532}
533
534// ── CacheHeaders config ──────────────────────────────────────────────────────
535
536/// Opt-in HTTP response-header config for `CachePlugin`.
537///
538/// Both knobs are **off by default** — wiring a `CachePlugin` without calling
539/// [`CachePlugin::with_compression`] or [`CachePlugin::cache_control`] leaves
540/// the response pipeline unchanged.
541///
542/// They are independent of and composable with the server-side `cache_page`
543/// store: `cache_page` caches full response bodies, while these knobs emit
544/// HTTP headers that tell downstream clients and proxies how to treat responses.
545///
546/// # Example
547///
548/// ```ignore
549/// App::builder()
550/// .plugin(
551/// CachePlugin::new(Cache::memory())
552/// .with_compression()
553/// .cache_control("public, max-age=3600")
554/// .vary("Accept-Encoding"),
555/// )
556/// .build()
557/// .await?;
558/// ```
559#[derive(Debug, Clone, Default)]
560pub struct CacheHeaders {
561 /// When `true`, applies `tower_http::compression::CompressionLayer` to the
562 /// router. The layer negotiates encoding with the client via
563 /// `Accept-Encoding` and compresses responses with gzip, brotli, deflate,
564 /// or zstd as available.
565 pub compression: bool,
566 /// When `Some(value)`, emits a `Cache-Control` response header on every
567 /// response (using `SetResponseHeaderLayer::overriding`). The value is the
568 /// raw directive string, e.g. `"public, max-age=3600"` or `"no-store"`.
569 pub cache_control: Option<String>,
570 /// When `Some(value)`, emits a `Vary` response header. Common value:
571 /// `"Accept-Encoding"` to tell caches that responses differ by encoding.
572 pub vary: Option<String>,
573}
574
575// ── CachePlugin ──────────────────────────────────────────────────────────────
576
577/// The plugin. Carries no models, no routes — just a `Cache` handle it
578/// installs as the ambient cache at boot, so `cache_page` and any handler
579/// that calls [`ambient()`] find it without explicit dependency injection.
580///
581/// Idiomatic registration (the carried cache is wired in `on_ready`):
582///
583/// ```ignore
584/// App::builder()
585/// .plugin(CachePlugin::new(Cache::memory()))
586/// // or: CachePlugin::new(Cache::redis("redis://localhost:6379/0").await?)
587/// .build()?;
588/// ```
589///
590/// `CachePlugin::init(cache)` remains for manual/test wiring outside the
591/// plugin lifecycle.
592///
593/// ## Opt-in compression and Cache-Control headers
594///
595/// ```ignore
596/// CachePlugin::new(Cache::memory())
597/// .with_compression() // enables gzip/br/zstd negotiation
598/// .cache_control("public, max-age=3600")
599/// .vary("Accept-Encoding")
600/// ```
601#[derive(Default)]
602pub struct CachePlugin {
603 /// Cache to install as the ambient handle in [`Plugin::on_ready`].
604 /// `None` for the legacy unit-style registration (where the ambient
605 /// cache is wired separately via [`CachePlugin::init`]).
606 cache: Option<Cache>,
607 /// Opt-in HTTP header + compression config. Default: nothing applied.
608 headers: CacheHeaders,
609}
610
611impl CachePlugin {
612 /// Build the plugin carrying `cache`. The idiomatic
613 /// `App::builder().plugin(CachePlugin::new(Cache::memory()))` then
614 /// installs it as the ambient handle at boot (BROKEN-9) — no separate
615 /// `init` call, so `cache_page` actually caches.
616 pub fn new(cache: Cache) -> Self {
617 Self {
618 cache: Some(cache),
619 headers: CacheHeaders::default(),
620 }
621 }
622
623 /// Store `cache` as the ambient handle directly, outside the plugin
624 /// lifecycle. Prefer [`CachePlugin::new`] in app code; this stays for
625 /// manual / test wiring. Calling it twice panics (same contract as
626 /// `settings::init`).
627 pub fn init(cache: Cache) {
628 if AMBIENT_CACHE.set(cache).is_err() {
629 panic!("CachePlugin::init called more than once");
630 }
631 }
632
633 /// Enable response compression. Applies `tower_http::compression::CompressionLayer`
634 /// to the router; negotiates gzip / brotli / deflate / zstd via `Accept-Encoding`.
635 /// Default: off.
636 pub fn with_compression(mut self) -> Self {
637 self.headers.compression = true;
638 self
639 }
640
641 /// Emit a `Cache-Control` header on every response. `value` is the raw
642 /// directive string (e.g. `"public, max-age=3600"`, `"no-store"`).
643 /// Default: not set.
644 pub fn cache_control(mut self, value: impl Into<String>) -> Self {
645 self.headers.cache_control = Some(value.into());
646 self
647 }
648
649 /// Emit a `Vary` header on every response. Typically paired with
650 /// [`with_compression`][Self::with_compression]: `"Accept-Encoding"` tells
651 /// caches that different encodings are distinct variants of the same URL.
652 /// Default: not set.
653 pub fn vary(mut self, value: impl Into<String>) -> Self {
654 self.headers.vary = Some(value.into());
655 self
656 }
657}
658
659impl Plugin for CachePlugin {
660 fn name(&self) -> &'static str {
661 "cache"
662 }
663
664 fn wrap_router(&self, router: Router) -> Router {
665 let h = &self.headers;
666 let mut router = router;
667
668 // Cache-Control header (overriding — the plugin's policy takes
669 // precedence over whatever a handler set).
670 if let Some(ref val) = h.cache_control {
671 if let Ok(hv) = HeaderValue::from_str(val) {
672 router = router.layer(SetResponseHeaderLayer::overriding(CACHE_CONTROL, hv));
673 } else {
674 tracing::warn!(
675 value = %val,
676 "CachePlugin: cache_control value contains invalid header characters; \
677 Cache-Control header will NOT be emitted"
678 );
679 }
680 }
681
682 // Vary header (overriding).
683 if let Some(ref val) = h.vary {
684 if let Ok(hv) = HeaderValue::from_str(val) {
685 router = router.layer(SetResponseHeaderLayer::overriding(VARY, hv));
686 } else {
687 tracing::warn!(
688 value = %val,
689 "CachePlugin: vary value contains invalid header characters; \
690 Vary header will NOT be emitted"
691 );
692 }
693 }
694
695 // Compression (outermost so the body is already compressed before any
696 // header-setter above runs on the response on the way out).
697 if h.compression {
698 router = router.layer(CompressionLayer::new());
699 }
700
701 router
702 }
703
704 fn on_ready(
705 &self,
706 _ctx: &umbral::plugin::AppContext,
707 ) -> Result<(), umbral::plugin::PluginError> {
708 // BROKEN-9: registering the plugin must actually wire the cache,
709 // otherwise `cache_page` silently no-ops on every request. If a
710 // cache was supplied via `new`, install it as the ambient handle.
711 match &self.cache {
712 Some(cache) => {
713 if AMBIENT_CACHE.set(cache.clone()).is_err() {
714 tracing::warn!(
715 "CachePlugin::new: an ambient cache was already installed (via \
716 CachePlugin::init or another CachePlugin); ignoring this one."
717 );
718 }
719 }
720 None if AMBIENT_CACHE.get().is_none() => {
721 tracing::warn!(
722 "CachePlugin registered with no cache and none set via CachePlugin::init — \
723 cache_page layers will silently no-op. Use \
724 CachePlugin::new(Cache::memory())."
725 );
726 }
727 None => {}
728 }
729 Ok(())
730 }
731}