Skip to main content

ocre/
cache.rs

1//! Caching: read-through values in Workers KV, HTTP `Cache-Control`/`ETag` and 304 responses.
2//!
3//! Two opt-in tools, both chosen for the Workers free plan.
4//!
5//! **Values** ([`fetch`], [`read`], [`write`](fn@write), [`delete`]) are JSON in the
6//! `CACHE` KV namespace (`ocre g cache` adds the binding to `cloudflare.config.ts`;
7//! `ocre deploy` creates the namespace). Use them for results that are slow or
8//! costly to compute and read far more often than they change: an external API
9//! call, a D1 aggregate over many rows. [`fetch`] is Rails'
10//! `Rails.cache.fetch`; the other three are the explicit forms.
11//!
12//! ```rust,no_run
13//! use std::time::Duration;
14//!
15//! use axum::{Json, extract::State};
16//! use ocre::{Ctx, Result};
17//! # #[derive(serde::Serialize, serde::Deserialize)]
18//! # struct Stats { posts: i64 }
19//! # impl Stats { async fn compute(_ctx: &Ctx) -> Result<Self> { Ok(Stats { posts: 0 }) } }
20//!
21//! async fn stats(State(ctx): State<Ctx>) -> Result<Json<Stats>> {
22//!     // One KV read; a miss runs the closure and costs one KV write.
23//!     let stats: Stats = ocre::cache::fetch(&ctx, "stats:v1", Duration::from_secs(3600), || async {
24//!         Stats::compute(&ctx).await
25//!     })
26//!     .await?;
27//!     Ok(Json(stats))
28//! }
29//! ```
30//!
31//! Free plan (September 2026): 100,000 KV reads and **1,000 writes a day**
32//! (writes and deletes to different keys; one write per second per key),
33//! 1 GB stored. Each [`fetch`] costs one read; a miss adds one write. A key
34//! refreshed every `ttl` seconds costs up to `86,400 / ttl` writes a day: a
35//! one-hour TTL is 24 writes per key, so about 40 hot keys fit in the budget.
36//! KV accepts TTLs of [`MIN_TTL`] (60 seconds) or more. KV is eventually
37//! consistent: other locations may see an old value for up to 60 seconds
38//! after a write or delete. Past a daily limit, KV operations fail; [`fetch`]
39//! then logs the failure (prefixed with [`LOG_PREFIX`]) and computes the
40//! value, so pages keep working.
41//!
42//! Values are the JSON of the type; put a version in the key (`stats:v1`) and
43//! change it when the type changes. A stored value that no longer decodes is
44//! logged and treated as a miss.
45//!
46//! **HTTP**: [`CacheControl`] and [`ETag`] are response parts, and
47//! [`Conditional`] answers `304 Not Modified` without rendering when the
48//! browser's copy is current, like Rails' `fresh_when`. That saves CPU (no
49//! template rendering) and bandwidth, never KV operations; the database query
50//! still runs.
51//!
52//! The Workers Cache API (`caches.default`) is not wrapped: it is a no-op on
53//! `*.workers.dev`, where Ocre apps deploy by default, and still runs the
54//! Worker on every request. To have Cloudflare serve whole pages without
55//! running the Worker, see "Workers Cache" in the README: it honors the
56//! [`CacheControl::public`] header (hits still count toward the 100,000
57//! requests a day).
58
59use std::{fmt, time::Duration};
60
61use axum::{
62    extract::FromRequestParts,
63    http::{HeaderValue, Method, StatusCode, header, request::Parts},
64    response::{IntoResponse, IntoResponseParts, Response, ResponseParts},
65};
66use serde::{Serialize, de::DeserializeOwned};
67use sha2::{Digest, Sha256};
68
69use crate::{Error, Result};
70
71pub use crate::runtime::cache::{clear, delete, fetch, read, write};
72#[cfg(feature = "html")]
73#[cfg_attr(docsrs, doc(cfg(feature = "html")))]
74pub use crate::runtime::cache::{fragment, fragments};
75
76/// Name of the KV namespace binding holding cached values: `CACHE`.
77///
78/// `ocre g cache` adds `CACHE: bindings.kv(),` to
79/// `cloudflare.config.ts`. Without it, [`fetch`], [`read`], [`write`](fn@write) and [`delete`]
80/// fail with [`Error::Internal`] naming that entry.
81///
82/// # Examples
83///
84/// ```
85/// assert_eq!(ocre::cache::CACHE_BINDING, "CACHE");
86/// ```
87pub const CACHE_BINDING: &str = "CACHE";
88
89/// Shortest TTL KV accepts (`expirationTtl`): 60 seconds.
90///
91/// A shorter TTL passed to [`fetch`] or [`write`](fn@write) is an [`Error::Internal`].
92///
93/// # Examples
94///
95/// ```
96/// assert_eq!(ocre::cache::MIN_TTL.as_secs(), 60);
97/// ```
98pub const MIN_TTL: Duration = Duration::from_secs(60);
99
100/// Longest key KV accepts, in bytes.
101const MAX_KEY_BYTES: usize = 512;
102
103/// Prefix of every line Ocre logs about the cache: `[ocre cache]`.
104///
105/// Survived KV failures are logged as ``[ocre cache] <operation> `<key>` failed: <error>``
106/// (operation `read`, `write` or `decode`); search the Worker logs for it.
107///
108/// # Examples
109///
110/// ```
111/// assert_eq!(ocre::cache::LOG_PREFIX, "[ocre cache]");
112/// ```
113pub const LOG_PREFIX: &str = "[ocre cache]";
114
115pub(crate) fn check_key(key: &str) -> Result<()> {
116    if key.is_empty() || key.len() > MAX_KEY_BYTES {
117        return Err(Error::internal(format!(
118            "cache key `{key}` is {} bytes; KV keys are 1 to {MAX_KEY_BYTES} bytes. Fix: use a short key such as \
119             \"posts:index:v1\"",
120            key.len()
121        )));
122    }
123    Ok(())
124}
125
126/// The TTL in whole seconds, at least [`MIN_TTL`].
127pub(crate) fn ttl_seconds(ttl: Duration) -> Result<u64> {
128    if ttl < MIN_TTL {
129        return Err(Error::internal(format!(
130            "cache TTL {ttl:?} is below KV's minimum of 60 seconds. Fix: pass Duration::from_secs(60) or more; \
131             each refresh costs a KV write (1,000 a day on the free plan)"
132        )));
133    }
134    Ok(ttl.as_secs())
135}
136
137pub(crate) fn encode<T: Serialize + ?Sized>(key: &str, value: &T) -> Result<String> {
138    serde_json::to_string(value)
139        .map_err(|err| Error::internal(format!("cannot cache `{key}`: the value does not serialize to JSON ({err})")))
140}
141
142/// The cached value, or `None` (logged) when it no longer matches `T`, e.g.
143/// after a deploy changed the struct: the caller recomputes it.
144pub(crate) fn decode<T: DeserializeOwned>(key: &str, text: &str) -> Option<T> {
145    serde_json::from_str(text)
146        .map_err(|err| log_failure("decode", key, &format!("{err}; recomputing it (change the key to avoid this)")))
147        .ok()
148}
149
150pub(crate) fn binding_error(err: &dyn fmt::Display) -> Error {
151    Error::internal(format!(
152        "KV binding `{CACHE_BINDING}` is missing ({err}). Fix: run `ocre g cache`, which adds \
153         `{CACHE_BINDING}: bindings.kv(),` to worker.env in cloudflare.config.ts"
154    ))
155}
156
157/// Logs a KV failure that the caller survives (over the daily limit, a
158/// value from an older deploy...).
159pub(crate) fn log_failure(operation: &str, key: &str, err: &dyn fmt::Display) {
160    crate::error::log_internal(&format!("{LOG_PREFIX} {operation} `{key}` failed: {err}"));
161}
162
163/// Name of the Worker variable choosing the cache store: `CACHE_STORE`.
164///
165/// `"kv"` (or no variable) stores values in the `CACHE` KV namespace;
166/// `"null"` turns caching off without code changes (Rails' `:null_store`):
167/// [`fetch`] and [`fragment`] always compute, [`read`] finds nothing,
168/// [`write`](fn@write) and [`delete`] do nothing, and no KV operation is
169/// made. Set it in `.dev.vars` (`CACHE_STORE=null`) to develop without the
170/// cache, like Rails' `bin/rails dev:cache`; any other value is an
171/// [`Error::Internal`] naming the two valid ones.
172///
173/// # Examples
174///
175/// ```
176/// assert_eq!(ocre::cache::STORE_VAR, "CACHE_STORE");
177/// ```
178pub const STORE_VAR: &str = "CACHE_STORE";
179
180/// The store [`STORE_VAR`] selects.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub(crate) enum Store {
183    Kv,
184    Null,
185}
186
187/// Reads the value of [`STORE_VAR`] (`None` when the variable is absent).
188pub(crate) fn store_kind(value: Option<&str>) -> Result<Store> {
189    match value.map(str::trim) {
190        None | Some("kv") => Ok(Store::Kv),
191        Some("null") => Ok(Store::Null),
192        Some(other) => Err(Error::internal(format!(
193            "{STORE_VAR} is `{other}`; it must be \"kv\" (the CACHE namespace, the default) or \"null\" (caching off). \
194             Fix: change it in .dev.vars or in worker.env of cloudflare.config.ts"
195        ))),
196    }
197}
198
199/// What [`clear`] deleted.
200///
201/// # Examples
202///
203/// ```
204/// let cleared = ocre::cache::Cleared { deleted: 200, more: true };
205/// assert!(cleared.more, "call clear again");
206/// ```
207#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
208pub struct Cleared {
209    /// How many values were deleted.
210    pub deleted: usize,
211    /// Whether keys with the prefix remain.
212    pub more: bool,
213}
214
215/// Prefix of the KV keys holding [`fragment`]s: `views/`, as in Rails.
216///
217/// # Examples
218///
219/// ```
220/// assert_eq!(ocre::cache::FRAGMENT_PREFIX, "views/");
221/// ```
222pub const FRAGMENT_PREFIX: &str = "views/";
223
224/// Keys longer than this are hashed by [`key`].
225const MAX_PLAIN_KEY: usize = 256;
226
227/// Builds a cache key from its parts joined by `/`, like Rails' `cache_key_with_version`.
228///
229/// Put in it everything the cached value depends on: the table, the id and
230/// `updated_at` of each record (so an update makes a new key and the old
231/// value expires with its TTL, no delete needed), the locale when the text
232/// is translated, and a version (`v1`) to bump when the template or type
233/// changes. Nested fragments compose keys (Russian doll caching): the outer
234/// key includes the newest `updated_at` of the records inside.
235///
236/// Keys longer than 256 bytes become `sha256/<64 hex characters>`, so any
237/// key fits KV's 512-byte limit. Pure: no KV operation. Parts are `Sync`,
238/// so a key built inline in an awaited call keeps handler futures `Send`.
239///
240/// # Examples
241///
242/// ```
243/// use ocre::cache::key;
244///
245/// let (id, updated_at) = (12, "2026-09-29 14:05:00");
246/// assert_eq!(key(&[&"posts", &id, &updated_at, &"v1"]), "posts/12/2026-09-29 14:05:00/v1");
247///
248/// let long = "x".repeat(300);
249/// assert!(key(&[&long]).starts_with("sha256/"));
250/// assert_eq!(key(&[&long]).len(), 7 + 64);
251/// ```
252pub fn key(parts: &[&(dyn fmt::Display + Sync)]) -> String {
253    let mut key = String::new();
254    for (index, part) in parts.iter().enumerate() {
255        if index > 0 {
256            key.push('/');
257        }
258        key.push_str(&part.to_string());
259    }
260    if key.len() <= MAX_PLAIN_KEY {
261        return key;
262    }
263    let mut hashed = String::from("sha256/");
264    push_hex(&mut hashed, &Sha256::digest(key.as_bytes()));
265    hashed
266}
267
268fn push_hex(out: &mut String, bytes: &[u8]) {
269    for byte in bytes {
270        out.push(char::from_digit(u32::from(byte >> 4), 16).expect("a nibble is a hex digit"));
271        out.push(char::from_digit(u32::from(byte & 0xf), 16).expect("a nibble is a hex digit"));
272    }
273}
274
275/// A cached piece of HTML, from [`fragment`] or [`fragments`].
276///
277/// It was rendered by an askama template, which escaped its values, so
278/// templates write it as is: `{{ row }}` needs no `|safe` (it implements
279/// askama's `HtmlSafe` with feature `html`). [`Display`](fmt::Display)
280/// writes the HTML.
281///
282/// # Examples
283///
284/// ```no_run
285/// use std::time::Duration;
286///
287/// use askama::Template;
288/// use ocre::{Ctx, Result, cache::{self, Fragment}};
289///
290/// #[derive(Template)]
291/// #[template(source = "<li>{{ title }}</li>", ext = "html")]
292/// struct Row<'a> {
293///     title: &'a str,
294/// }
295///
296/// async fn row(ctx: &Ctx) -> Result<Fragment> {
297///     let key = cache::key(&[&"posts", &12, &"2026-09-29 14:05:00", &"v1"]);
298///     cache::fragment(ctx, &key, Duration::from_secs(86_400), || Row { title: "Hello" }).await
299/// }
300/// ```
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct Fragment(String);
303
304impl Fragment {
305    #[cfg_attr(not(feature = "html"), allow(dead_code))]
306    pub(crate) fn new(html: String) -> Self {
307        Self(html)
308    }
309
310    /// The HTML.
311    ///
312    /// # Examples
313    ///
314    /// ```no_run
315    /// # async fn row(ctx: &ocre::Ctx) -> ocre::Result<ocre::cache::Fragment> { unimplemented!() }
316    /// # async fn example(ctx: &ocre::Ctx) -> ocre::Result<()> {
317    /// let row = row(ctx).await?;
318    /// assert!(row.as_str().starts_with('<'));
319    /// # Ok(()) }
320    /// ```
321    pub fn as_str(&self) -> &str {
322        &self.0
323    }
324
325    /// The HTML as a `String`, e.g. for a [`realtime`](crate::realtime) broadcast.
326    ///
327    /// # Examples
328    ///
329    /// ```no_run
330    /// # async fn row(ctx: &ocre::Ctx) -> ocre::Result<ocre::cache::Fragment> { unimplemented!() }
331    /// # async fn example(ctx: &ocre::Ctx) -> ocre::Result<()> {
332    /// let html: String = row(ctx).await?.into_string();
333    /// # let _ = html; Ok(()) }
334    /// ```
335    pub fn into_string(self) -> String {
336        self.0
337    }
338}
339
340impl fmt::Display for Fragment {
341    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342        f.write_str(&self.0)
343    }
344}
345
346/// Fragments are HTML rendered (and escaped) by askama templates.
347#[cfg(feature = "html")]
348impl askama::filters::HtmlSafe for Fragment {}
349
350/// The KV key of the fragment `key`.
351#[cfg_attr(not(feature = "html"), allow(dead_code))]
352pub(crate) fn fragment_key(key: &str) -> Result<String> {
353    let full = format!("{FRAGMENT_PREFIX}{key}");
354    check_key(&full)?;
355    Ok(full)
356}
357
358/// Most results the per-request query cache keeps; it is emptied when full.
359pub(crate) const QUERY_CACHE_LIMIT: usize = 100;
360
361/// Whether the query cache may serve `sql`: a statement starting with
362/// `SELECT` (case-insensitive, after whitespace and `--`/`/* */` comments).
363/// Anything else may write, so it empties the cache.
364pub(crate) fn is_read_query(sql: &str) -> bool {
365    let mut rest = sql;
366    loop {
367        rest = rest.trim_start();
368        if let Some(line) = rest.strip_prefix("--") {
369            rest = line.split_once('\n').map_or("", |(_, next)| next);
370        } else if let Some(block) = rest.strip_prefix("/*") {
371            rest = block.split_once("*/").map_or("", |(_, next)| next);
372        } else {
373            break;
374        }
375    }
376    rest.get(..6).is_some_and(|word| word.eq_ignore_ascii_case("select"))
377        && !rest[6..].starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_')
378}
379
380/// The query cache key of a statement: binding, SQL and parameter values.
381pub(crate) fn query_key(binding: &str, sql: &str, params: &[crate::Param]) -> String {
382    format!("{binding}\u{0}{sql}\u{0}{params:?}")
383}
384
385/// A `Cache-Control` response header, built from one of four policies.
386///
387/// Add it to a response tuple: `(CacheControl::public(Duration::from_secs(300)), Html(page))`.
388/// It implements [`IntoResponseParts`] and [`Display`](fmt::Display) (the header value).
389/// Durations are truncated to whole seconds. Free: no KV or D1 operation.
390///
391/// | Constructor | Header | Use for |
392/// |---|---|---|
393/// | [`no_store`](Self::no_store) | `no-store` | secrets, one-time tokens |
394/// | [`no_cache`](Self::no_cache) | `private, no-cache` | per-visitor pages, with an [`ETag`] |
395/// | [`private`](Self::private) | `private, max-age=N` | per-visitor data that may be stale for N seconds |
396/// | [`public`](Self::public) | `public, max-age=N` | responses identical for every visitor |
397///
398/// # Examples
399///
400/// ```
401/// use std::time::Duration;
402/// use ocre::cache::CacheControl;
403///
404/// assert_eq!(CacheControl::no_cache().to_string(), "private, no-cache");
405/// assert_eq!(
406///     CacheControl::public(Duration::from_secs(300)).stale_while_revalidate(Duration::from_secs(60)).to_string(),
407///     "public, max-age=300, stale-while-revalidate=60"
408/// );
409///
410/// // As a response part:
411/// use axum::response::IntoResponse;
412/// let response = (CacheControl::public(Duration::from_secs(300)), "hello").into_response();
413/// assert_eq!(response.headers()["cache-control"], "public, max-age=300");
414/// ```
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub struct CacheControl {
417    kind: Kind,
418    max_age: u64,
419    stale_while_revalidate: Option<u64>,
420}
421
422#[derive(Debug, Clone, Copy, PartialEq, Eq)]
423enum Kind {
424    NoStore,
425    NoCache,
426    Private,
427    Public,
428}
429
430impl CacheControl {
431    /// A `no-store` policy: never keep a copy (pages with secrets, one-time tokens).
432    ///
433    /// # Examples
434    ///
435    /// ```
436    /// assert_eq!(ocre::cache::CacheControl::no_store().to_string(), "no-store");
437    /// ```
438    pub fn no_store() -> Self {
439        Self { kind: Kind::NoStore, max_age: 0, stale_while_revalidate: None }
440    }
441
442    /// A `private, no-cache` policy: the browser keeps a copy but asks every time.
443    ///
444    /// With an [`ETag`] and [`Conditional`] the answer is usually a cheap
445    /// 304. The right default for pages that depend on the visitor (session,
446    /// locale, flash).
447    ///
448    /// # Examples
449    ///
450    /// ```
451    /// assert_eq!(ocre::cache::CacheControl::no_cache().to_string(), "private, no-cache");
452    /// ```
453    pub fn no_cache() -> Self {
454        Self { kind: Kind::NoCache, max_age: 0, stale_while_revalidate: None }
455    }
456
457    /// A `private, max-age=N` policy: the browser reuses its copy for `max_age` without asking.
458    ///
459    /// Shared caches (Cloudflare) never store it. `max_age` is truncated to
460    /// whole seconds.
461    ///
462    /// # Examples
463    ///
464    /// ```
465    /// use std::time::Duration;
466    /// assert_eq!(ocre::cache::CacheControl::private(Duration::from_secs(60)).to_string(), "private, max-age=60");
467    /// ```
468    pub fn private(max_age: Duration) -> Self {
469        Self { kind: Kind::Private, max_age: max_age.as_secs(), stale_while_revalidate: None }
470    }
471
472    /// A `public, max-age=N` policy: browsers **and Cloudflare** may reuse it for every visitor.
473    ///
474    /// Only for responses identical for everyone: no session data, no locale
475    /// unless it is in the path. With Workers Cache enabled (`cache: { enabled:
476    /// true }` in `worker` of cloudflare.config.ts) such responses are served without running the
477    /// Worker (no CPU, but each hit still counts toward the free plan's 100,000
478    /// requests a day). `max_age` is truncated to whole seconds.
479    ///
480    /// # Examples
481    ///
482    /// ```
483    /// use std::time::Duration;
484    /// assert_eq!(ocre::cache::CacheControl::public(Duration::from_secs(3600)).to_string(), "public, max-age=3600");
485    /// ```
486    pub fn public(max_age: Duration) -> Self {
487        Self { kind: Kind::Public, max_age: max_age.as_secs(), stale_while_revalidate: None }
488    }
489
490    /// Adds `stale-while-revalidate`, serving the old copy for up to `window` while fetching a new one.
491    ///
492    /// Applies after `max-age` runs out. Ignored by
493    /// [`no_store`](Self::no_store) and [`no_cache`](Self::no_cache), which
494    /// have no `max-age`.
495    ///
496    /// # Examples
497    ///
498    /// ```
499    /// use std::time::Duration;
500    /// use ocre::cache::CacheControl;
501    ///
502    /// let policy = CacheControl::private(Duration::from_secs(60)).stale_while_revalidate(Duration::from_secs(600));
503    /// assert_eq!(policy.to_string(), "private, max-age=60, stale-while-revalidate=600");
504    /// assert_eq!(CacheControl::no_store().stale_while_revalidate(Duration::from_secs(600)).to_string(), "no-store");
505    /// ```
506    pub fn stale_while_revalidate(self, window: Duration) -> Self {
507        Self { stale_while_revalidate: Some(window.as_secs()), ..self }
508    }
509}
510
511impl fmt::Display for CacheControl {
512    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
513        let scope = match self.kind {
514            Kind::NoStore => return f.write_str("no-store"),
515            Kind::NoCache => return f.write_str("private, no-cache"),
516            Kind::Private => "private",
517            Kind::Public => "public",
518        };
519        write!(f, "{scope}, max-age={}", self.max_age)?;
520        match self.stale_while_revalidate {
521            Some(window) => write!(f, ", stale-while-revalidate={window}"),
522            None => Ok(()),
523        }
524    }
525}
526
527impl IntoResponseParts for CacheControl {
528    type Error = std::convert::Infallible;
529
530    fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
531        let value = HeaderValue::from_str(&self.to_string()).expect("Cache-Control values are ASCII");
532        res.headers_mut().insert(header::CACHE_CONTROL, value);
533        Ok(res)
534    }
535}
536
537/// An entity tag naming the version of what a page shows: weak (`W/"<hash>"`, the default) or strong (`"<hash>"`).
538///
539/// Build it from everything the page displays, so it changes when the page
540/// would: the records, plus the locale, the signed-in user and the flash when
541/// the page shows them. The value is the first 128 bits of a SHA-256, as 32
542/// hex characters. It implements [`IntoResponseParts`] (sets `ETag`); pass it
543/// to [`Conditional::fresh_when`] to answer 304s. Like Rails, tags are weak
544/// unless built with [`strong`](Self::strong).
545///
546/// # Examples
547///
548/// ```
549/// use ocre::cache::ETag;
550///
551/// let a = ETag::new("post-42-1767225600");
552/// assert_eq!(a, ETag::new("post-42-1767225600"));
553/// assert_ne!(a, ETag::new("post-42-1767225601"));
554/// assert!(a.as_str().starts_with("W/\""));
555/// ```
556#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct ETag(String);
558
559impl ETag {
560    /// A tag for a version string you build, e.g. `format!("{}-{}", post.id, post.updated_at)`.
561    ///
562    /// Cheaper than [`of`](Self::of) for large data: only `version` is hashed.
563    ///
564    /// # Examples
565    ///
566    /// ```
567    /// let etag = ocre::cache::ETag::new("v1");
568    /// assert_eq!(etag.as_str().len(), 36); // W/" + 32 hex + "
569    /// ```
570    pub fn new(version: impl AsRef<[u8]>) -> Self {
571        Self::hashed("W/", version.as_ref())
572    }
573
574    /// A strong tag (`"<hash>"`, Rails' `strong_etag:`) for a version string you build.
575    ///
576    /// A strong tag promises that two responses with the same tag are
577    /// byte-for-byte identical, which caches and range requests rely on; use
578    /// it for a file or an exact body, not for a page whose HTML may vary
579    /// (nonces, CSRF tokens). `If-None-Match` compares weakly, so a strong
580    /// tag also answers 304s through [`Conditional`].
581    ///
582    /// # Examples
583    ///
584    /// ```
585    /// use ocre::cache::ETag;
586    ///
587    /// let etag = ETag::strong("report-2026-09.csv");
588    /// assert!(etag.as_str().starts_with('"'));
589    /// assert_eq!(etag.as_str().len(), 34); // " + 32 hex + "
590    /// assert!(etag.is_strong() && !ETag::new("x").is_strong());
591    /// ```
592    pub fn strong(version: impl AsRef<[u8]>) -> Self {
593        Self::hashed("", version.as_ref())
594    }
595
596    fn hashed(prefix: &str, version: &[u8]) -> Self {
597        let digest = Sha256::digest(version);
598        let mut tag = String::with_capacity(36);
599        tag.push_str(prefix);
600        tag.push('"');
601        push_hex(&mut tag, &digest[..16]);
602        tag.push('"');
603        Self(tag)
604    }
605
606    /// Whether the tag is strong (built with [`strong`](Self::strong)).
607    ///
608    /// # Examples
609    ///
610    /// ```
611    /// assert!(!ocre::cache::ETag::new("v1").is_strong());
612    /// ```
613    pub fn is_strong(&self) -> bool {
614        !self.0.starts_with("W/")
615    }
616
617    /// A tag for any serializable data, hashed as its JSON: `ETag::of(&(&posts, i18n.locale()))?`.
618    ///
619    /// # Errors
620    ///
621    /// [`Error::Internal`] (500) when `data` does not serialize to JSON (e.g.
622    /// a map with non-string keys).
623    ///
624    /// # Examples
625    ///
626    /// ```
627    /// use ocre::cache::ETag;
628    /// assert_eq!(ETag::of(&("Ada", 1))?, ETag::of(&("Ada", 1))?);
629    /// assert_ne!(ETag::of(&("Ada", 1))?, ETag::of(&("Ada", 2))?);
630    /// # Ok::<(), ocre::Error>(())
631    /// ```
632    pub fn of<T: Serialize + ?Sized>(data: &T) -> Result<Self> {
633        Self::from_json(serde_json::to_vec(data))
634    }
635
636    fn from_json(json: serde_json::Result<Vec<u8>>) -> Result<Self> {
637        let json = json
638            .map_err(|err| Error::internal(format!("cannot compute an ETag: the data does not serialize ({err})")))?;
639        Ok(Self::new(json))
640    }
641
642    /// The header value, `W/"<32 hex characters>"` (weak) or `"<32 hex characters>"` (strong).
643    ///
644    /// # Examples
645    ///
646    /// ```
647    /// let etag = ocre::cache::ETag::new("v1");
648    /// assert!(etag.as_str().starts_with("W/\"") && etag.as_str().ends_with('"'));
649    /// ```
650    pub fn as_str(&self) -> &str {
651        &self.0
652    }
653
654    /// Weak comparison (RFC 9110): `W/"x"` matches `"x"`.
655    fn matches(&self, other: &str) -> bool {
656        let opaque = |tag: &str| tag.trim().trim_start_matches("W/").to_owned();
657        opaque(&self.0) == opaque(other)
658    }
659}
660
661impl IntoResponseParts for ETag {
662    type Error = std::convert::Infallible;
663
664    fn into_response_parts(self, mut res: ResponseParts) -> Result<ResponseParts, Self::Error> {
665        let value = HeaderValue::from_str(&self.0).expect("ETags are ASCII");
666        res.headers_mut().insert(header::ETAG, value);
667        Ok(res)
668    }
669}
670
671/// Extractor for the request's `If-None-Match`, to answer `304 Not Modified` without rendering.
672///
673/// Like Rails' `fresh_when`. Only GET and HEAD requests are considered; for
674/// other methods (and when the header is absent or not ASCII) the client is
675/// never fresh. Extraction never fails. Comparison is weak (RFC 9110):
676/// `W/"x"` matches `"x"`, and `*` matches any tag. [`Default`] is a request
677/// without the header.
678///
679/// The handler still runs its database queries to build the [`ETag`]; a 304
680/// skips only rendering and the body, saving CPU and bandwidth.
681///
682/// # Examples
683///
684/// ```
685/// use axum::response::Response;
686/// use ocre::{Result, cache::{CacheControl, Conditional, ETag}};
687///
688/// async fn show(conditional: Conditional) -> Result<Response> {
689///     let post = ("Hello", 1767225600); // e.g. post::find(&ctx, id).await?.or_404()?
690///     let etag = ETag::of(&post)?; // everything the page shows
691///     conditional.fresh_when(etag, CacheControl::no_cache(), || Ok(format!("<h1>{}</h1>", post.0)))
692/// }
693///
694/// let response = pollster::block_on(show(Conditional::default()))?;
695/// assert_eq!(response.status(), 200);
696/// # Ok::<(), ocre::Error>(())
697/// ```
698#[derive(Debug, Clone, Default)]
699pub struct Conditional {
700    if_none_match: Option<String>,
701}
702
703impl Conditional {
704    /// Whether the client already has the version `etag`.
705    ///
706    /// Always `false` for requests other than GET and HEAD, and when the
707    /// request had no `If-None-Match`.
708    ///
709    /// # Examples
710    ///
711    /// ```
712    /// use axum::{extract::FromRequestParts, http::Request};
713    /// use ocre::cache::{Conditional, ETag};
714    ///
715    /// let etag = ETag::new("v1");
716    /// let (mut parts, ()) = Request::get("/").header("If-None-Match", etag.as_str()).body(()).unwrap().into_parts();
717    /// let conditional = pollster::block_on(Conditional::from_request_parts(&mut parts, &())).unwrap();
718    /// assert!(conditional.is_fresh(&etag));
719    /// assert!(!conditional.is_fresh(&ETag::new("v2")));
720    /// ```
721    pub fn is_fresh(&self, etag: &ETag) -> bool {
722        self.if_none_match
723            .as_deref()
724            .is_some_and(|header| header.trim() == "*" || header.split(',').any(|candidate| etag.matches(candidate)))
725    }
726
727    /// Answers `304 Not Modified` when the client's copy is current, or else renders the page.
728    ///
729    /// When [`is_fresh`](Self::is_fresh), the response is a 304 with the
730    /// `ETag` and `Cache-Control` headers and no body, and `render` is not
731    /// called. Otherwise `render` runs and its response gets both headers.
732    ///
733    /// # Errors
734    ///
735    /// Whatever `render` returns; a 304 never fails.
736    ///
737    /// # Examples
738    ///
739    /// ```
740    /// use axum::{extract::FromRequestParts, http::Request};
741    /// use ocre::cache::{CacheControl, Conditional, ETag};
742    ///
743    /// let response = Conditional::default().fresh_when(ETag::new("v1"), CacheControl::no_cache(), || Ok("page"))?;
744    /// assert_eq!(response.status(), 200);
745    /// assert_eq!(response.headers()["cache-control"], "private, no-cache");
746    ///
747    /// let etag = ETag::new("v1");
748    /// let (mut parts, ()) = Request::get("/").header("If-None-Match", etag.as_str()).body(()).unwrap().into_parts();
749    /// let conditional = pollster::block_on(Conditional::from_request_parts(&mut parts, &())).unwrap();
750    /// let response = conditional.fresh_when(etag, CacheControl::no_cache(), || -> ocre::Result<&str> {
751    ///     unreachable!("not rendered")
752    /// })?;
753    /// assert_eq!(response.status(), 304);
754    /// # Ok::<(), ocre::Error>(())
755    /// ```
756    pub fn fresh_when<R: IntoResponse>(
757        &self,
758        etag: ETag,
759        cache_control: CacheControl,
760        render: impl FnOnce() -> Result<R>,
761    ) -> Result<Response> {
762        if self.is_fresh(&etag) {
763            return Ok((StatusCode::NOT_MODIFIED, etag, cache_control, ()).into_response());
764        }
765        Ok((etag, cache_control, render()?).into_response())
766    }
767}
768
769impl<S: Send + Sync> FromRequestParts<S> for Conditional {
770    type Rejection = std::convert::Infallible;
771
772    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
773        let cacheable = parts.method == Method::GET || parts.method == Method::HEAD;
774        let if_none_match = parts
775            .headers
776            .get(header::IF_NONE_MATCH)
777            .and_then(|value| value.to_str().ok())
778            .filter(|_| cacheable)
779            .map(str::to_owned);
780        Ok(Self { if_none_match })
781    }
782}
783
784#[cfg(test)]
785#[path = "../tests/cache.rs"]
786mod tests;