Skip to main content

api_bones/
cache.rs

1//! `Cache-Control` header builder and parser (RFC 7234).
2//!
3//! [`CacheControl`] represents the structured set of directives that can
4//! appear in a `Cache-Control` HTTP header, with builder methods for the most
5//! common request and response directives.
6//!
7//! # Example
8//!
9//! ```rust
10//! use api_bones::cache::CacheControl;
11//!
12//! // Build a typical immutable public response.
13//! let cc = CacheControl::new()
14//!     .public()
15//!     .max_age(31_536_000)
16//!     .immutable();
17//! assert_eq!(cc.to_string(), "public, immutable, max-age=31536000");
18//!
19//! // Parse a header value.
20//! let cc: CacheControl = "no-store, no-cache".parse().unwrap();
21//! assert!(cc.no_store);
22//! assert!(cc.no_cache);
23//! ```
24
25#[cfg(all(not(feature = "std"), feature = "alloc"))]
26use alloc::{format, string::String, vec::Vec};
27use core::{fmt, str::FromStr};
28#[cfg(feature = "serde")]
29use serde::{Deserialize, Serialize};
30
31// ---------------------------------------------------------------------------
32// CacheControl
33// ---------------------------------------------------------------------------
34
35/// Structured `Cache-Control` header (RFC 7234 §5.2).
36///
37/// All boolean directives default to `false`; numeric directives default to
38/// `None` (absent). Use the builder methods to set them.
39#[derive(Debug, Clone, PartialEq, Eq, Default)]
40#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
41#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
42#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
43#[allow(clippy::struct_excessive_bools)]
44#[non_exhaustive]
45pub struct CacheControl {
46    // -----------------------------------------------------------------------
47    // Response directives
48    // -----------------------------------------------------------------------
49    /// `public` — response may be stored by any cache.
50    pub public: bool,
51    /// `private` — response is intended for a single user; must not be stored
52    /// by a shared cache.
53    pub private: bool,
54    /// `no-cache` — cache must revalidate with the origin before serving.
55    pub no_cache: bool,
56    /// `no-store` — must not store any part of the request or response.
57    pub no_store: bool,
58    /// `no-transform` — no transformations or conversions should be made.
59    pub no_transform: bool,
60    /// `must-revalidate` — stale responses must not be used without revalidation.
61    pub must_revalidate: bool,
62    /// `proxy-revalidate` — like `must-revalidate` but only for shared caches.
63    pub proxy_revalidate: bool,
64    /// `immutable` — response body will not change over its lifetime.
65    pub immutable: bool,
66    /// `max-age=<seconds>` — maximum time the response is considered fresh.
67    pub max_age: Option<u64>,
68    /// `s-maxage=<seconds>` — overrides `max-age` for shared caches.
69    pub s_maxage: Option<u64>,
70    /// `stale-while-revalidate=<seconds>` — serve stale while revalidating.
71    pub stale_while_revalidate: Option<u64>,
72    /// `stale-if-error=<seconds>` — use stale response on error.
73    pub stale_if_error: Option<u64>,
74
75    // -----------------------------------------------------------------------
76    // Request directives
77    // -----------------------------------------------------------------------
78    /// `only-if-cached` — do not use the network; only return a cached response.
79    pub only_if_cached: bool,
80    /// `max-stale[=<seconds>]` — accept a response up to this many seconds stale.
81    /// `Some(0)` means any staleness is acceptable; `None` means the directive
82    /// is absent.
83    pub max_stale: Option<u64>,
84    /// `min-fresh=<seconds>` — require at least this much remaining freshness.
85    pub min_fresh: Option<u64>,
86}
87
88impl CacheControl {
89    /// Create an empty `CacheControl` with all directives absent.
90    #[must_use]
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    // -----------------------------------------------------------------------
96    // Builder methods — response directives
97    // -----------------------------------------------------------------------
98
99    /// Set the `public` directive.
100    #[must_use]
101    pub fn public(mut self) -> Self {
102        self.public = true;
103        self
104    }
105
106    /// Set the `private` directive.
107    #[must_use]
108    pub fn private(mut self) -> Self {
109        self.private = true;
110        self
111    }
112
113    /// Set the `no-cache` directive.
114    #[must_use]
115    pub fn no_cache(mut self) -> Self {
116        self.no_cache = true;
117        self
118    }
119
120    /// Set the `no-store` directive.
121    #[must_use]
122    pub fn no_store(mut self) -> Self {
123        self.no_store = true;
124        self
125    }
126
127    /// Set the `no-transform` directive.
128    #[must_use]
129    pub fn no_transform(mut self) -> Self {
130        self.no_transform = true;
131        self
132    }
133
134    /// Set the `must-revalidate` directive.
135    #[must_use]
136    pub fn must_revalidate(mut self) -> Self {
137        self.must_revalidate = true;
138        self
139    }
140
141    /// Set the `proxy-revalidate` directive.
142    #[must_use]
143    pub fn proxy_revalidate(mut self) -> Self {
144        self.proxy_revalidate = true;
145        self
146    }
147
148    /// Set the `immutable` directive.
149    #[must_use]
150    pub fn immutable(mut self) -> Self {
151        self.immutable = true;
152        self
153    }
154
155    /// Set `max-age=<seconds>`.
156    #[must_use]
157    pub fn max_age(mut self, seconds: u64) -> Self {
158        self.max_age = Some(seconds);
159        self
160    }
161
162    /// Set `s-maxage=<seconds>`.
163    #[must_use]
164    pub fn s_maxage(mut self, seconds: u64) -> Self {
165        self.s_maxage = Some(seconds);
166        self
167    }
168
169    /// Set `stale-while-revalidate=<seconds>`.
170    #[must_use]
171    pub fn stale_while_revalidate(mut self, seconds: u64) -> Self {
172        self.stale_while_revalidate = Some(seconds);
173        self
174    }
175
176    /// Set `stale-if-error=<seconds>`.
177    #[must_use]
178    pub fn stale_if_error(mut self, seconds: u64) -> Self {
179        self.stale_if_error = Some(seconds);
180        self
181    }
182
183    // -----------------------------------------------------------------------
184    // Builder methods — request directives
185    // -----------------------------------------------------------------------
186
187    /// Set the `only-if-cached` directive.
188    #[must_use]
189    pub fn only_if_cached(mut self) -> Self {
190        self.only_if_cached = true;
191        self
192    }
193
194    /// Set `max-stale[=<seconds>]`.  Pass `0` to accept any staleness.
195    #[must_use]
196    pub fn max_stale(mut self, seconds: u64) -> Self {
197        self.max_stale = Some(seconds);
198        self
199    }
200
201    /// Set `min-fresh=<seconds>`.
202    #[must_use]
203    pub fn min_fresh(mut self, seconds: u64) -> Self {
204        self.min_fresh = Some(seconds);
205        self
206    }
207
208    // -----------------------------------------------------------------------
209    // Convenience constructors
210    // -----------------------------------------------------------------------
211
212    /// Convenience: `no-store` (disable all caching).
213    ///
214    /// ```
215    /// use api_bones::cache::CacheControl;
216    ///
217    /// let cc = CacheControl::no_caching();
218    /// assert!(cc.no_store);
219    /// assert_eq!(cc.to_string(), "no-store");
220    /// ```
221    #[must_use]
222    pub fn no_caching() -> Self {
223        Self::new().no_store()
224    }
225
226    /// Convenience: `private, no-cache, no-store`.
227    ///
228    /// ```
229    /// use api_bones::cache::CacheControl;
230    ///
231    /// let cc = CacheControl::private_no_cache();
232    /// assert!(cc.private && cc.no_cache && cc.no_store);
233    /// ```
234    #[must_use]
235    pub fn private_no_cache() -> Self {
236        Self::new().private().no_cache().no_store()
237    }
238}
239
240// ---------------------------------------------------------------------------
241// Display
242// ---------------------------------------------------------------------------
243
244impl fmt::Display for CacheControl {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        let mut parts: Vec<&str> = Vec::new();
247        // We write variable-width entries inline; use a local buffer.
248        // Collect fixed-string directives first, then emit numeric ones.
249
250        // Boolean directives (response)
251        if self.public {
252            parts.push("public");
253        }
254        if self.private {
255            parts.push("private");
256        }
257        if self.no_cache {
258            parts.push("no-cache");
259        }
260        if self.no_store {
261            parts.push("no-store");
262        }
263        if self.no_transform {
264            parts.push("no-transform");
265        }
266        if self.must_revalidate {
267            parts.push("must-revalidate");
268        }
269        if self.proxy_revalidate {
270            parts.push("proxy-revalidate");
271        }
272        if self.immutable {
273            parts.push("immutable");
274        }
275        // Boolean directives (request)
276        if self.only_if_cached {
277            parts.push("only-if-cached");
278        }
279
280        // Write fixed parts first
281        for (i, p) in parts.iter().enumerate() {
282            if i > 0 {
283                f.write_str(", ")?;
284            }
285            f.write_str(p)?;
286        }
287
288        // Collect numeric directives.
289        let numeric: [Option<(&str, u64)>; 6] = [
290            self.max_age.map(|v| ("max-age", v)),
291            self.s_maxage.map(|v| ("s-maxage", v)),
292            self.stale_while_revalidate
293                .map(|v| ("stale-while-revalidate", v)),
294            self.stale_if_error.map(|v| ("stale-if-error", v)),
295            self.max_stale.map(|v| ("max-stale", v)),
296            self.min_fresh.map(|v| ("min-fresh", v)),
297        ];
298
299        let mut need_sep = !parts.is_empty();
300        for (name, v) in numeric.iter().flatten() {
301            if need_sep {
302                f.write_str(", ")?;
303            }
304            write!(f, "{name}={v}")?;
305            need_sep = true;
306        }
307
308        Ok(())
309    }
310}
311
312// ---------------------------------------------------------------------------
313// Parse error
314// ---------------------------------------------------------------------------
315
316/// Error returned when parsing a `Cache-Control` header fails.
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct ParseCacheControlError(String);
319
320impl fmt::Display for ParseCacheControlError {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        write!(f, "invalid Cache-Control header: {}", self.0)
323    }
324}
325
326#[cfg(feature = "std")]
327impl std::error::Error for ParseCacheControlError {}
328
329// ---------------------------------------------------------------------------
330// FromStr
331// ---------------------------------------------------------------------------
332
333impl FromStr for CacheControl {
334    type Err = ParseCacheControlError;
335
336    /// Parse a `Cache-Control` header value.
337    ///
338    /// Unknown directives are silently ignored, matching real-world HTTP
339    /// caching behaviour.
340    ///
341    /// ```
342    /// use api_bones::cache::CacheControl;
343    ///
344    /// let cc: CacheControl = "public, max-age=3600, must-revalidate".parse().unwrap();
345    /// assert!(cc.public);
346    /// assert_eq!(cc.max_age, Some(3600));
347    /// assert!(cc.must_revalidate);
348    /// ```
349    fn from_str(s: &str) -> Result<Self, Self::Err> {
350        let mut cc = Self::new();
351        for token in s.split(',') {
352            let token = token.trim();
353            if token.is_empty() {
354                continue;
355            }
356            let (name, value) = if let Some(eq) = token.find('=') {
357                (&token[..eq], Some(token[eq + 1..].trim()))
358            } else {
359                (token, None)
360            };
361            let name = name.trim().to_lowercase();
362
363            let parse_u64 = |v: Option<&str>| -> Result<u64, ParseCacheControlError> {
364                v.ok_or_else(|| ParseCacheControlError(format!("{name} requires a value")))?
365                    .parse::<u64>()
366                    .map_err(|_| {
367                        ParseCacheControlError(format!("{name} value is not a valid integer"))
368                    })
369            };
370
371            match name.as_str() {
372                "public" => cc.public = true,
373                "private" => cc.private = true,
374                "no-cache" => cc.no_cache = true,
375                "no-store" => cc.no_store = true,
376                "no-transform" => cc.no_transform = true,
377                "must-revalidate" => cc.must_revalidate = true,
378                "proxy-revalidate" => cc.proxy_revalidate = true,
379                "immutable" => cc.immutable = true,
380                "only-if-cached" => cc.only_if_cached = true,
381                "max-age" => cc.max_age = Some(parse_u64(value)?),
382                "s-maxage" => cc.s_maxage = Some(parse_u64(value)?),
383                "stale-while-revalidate" => cc.stale_while_revalidate = Some(parse_u64(value)?),
384                "stale-if-error" => cc.stale_if_error = Some(parse_u64(value)?),
385                "max-stale" => cc.max_stale = Some(value.and_then(|v| v.parse().ok()).unwrap_or(0)),
386                "min-fresh" => cc.min_fresh = Some(parse_u64(value)?),
387                _ => {} // unknown directives are ignored per RFC
388            }
389        }
390        Ok(cc)
391    }
392}
393
394// ---------------------------------------------------------------------------
395// Tests
396// ---------------------------------------------------------------------------
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn default_is_empty() {
404        let cc = CacheControl::new();
405        assert_eq!(cc.to_string(), "");
406    }
407
408    #[test]
409    fn builder_public_max_age_immutable() {
410        let cc = CacheControl::new().public().max_age(31_536_000).immutable();
411        // Boolean directives (public, immutable) appear before numeric ones (max-age).
412        assert_eq!(cc.to_string(), "public, immutable, max-age=31536000");
413    }
414
415    #[test]
416    fn builder_no_store() {
417        let cc = CacheControl::no_caching();
418        assert_eq!(cc.to_string(), "no-store");
419    }
420
421    #[test]
422    fn builder_private_no_cache() {
423        let cc = CacheControl::private_no_cache();
424        assert!(cc.private);
425        assert!(cc.no_cache);
426        assert!(cc.no_store);
427    }
428
429    #[test]
430    fn parse_simple_flags() {
431        let cc: CacheControl = "no-store, no-cache".parse().unwrap();
432        assert!(cc.no_store);
433        assert!(cc.no_cache);
434    }
435
436    #[test]
437    fn parse_numeric_directives() {
438        let cc: CacheControl = "public, max-age=3600, s-maxage=7200".parse().unwrap();
439        assert!(cc.public);
440        assert_eq!(cc.max_age, Some(3600));
441        assert_eq!(cc.s_maxage, Some(7200));
442    }
443
444    #[test]
445    fn parse_unknown_directive_ignored() {
446        let cc: CacheControl = "no-store, x-custom-thing=42".parse().unwrap();
447        assert!(cc.no_store);
448    }
449
450    #[test]
451    fn roundtrip_complex() {
452        let original = CacheControl::new()
453            .public()
454            .max_age(600)
455            .must_revalidate()
456            .stale_if_error(86_400);
457        let s = original.to_string();
458        let parsed: CacheControl = s.parse().unwrap();
459        assert_eq!(parsed.public, original.public);
460        assert_eq!(parsed.max_age, original.max_age);
461        assert_eq!(parsed.must_revalidate, original.must_revalidate);
462        assert_eq!(parsed.stale_if_error, original.stale_if_error);
463    }
464
465    #[test]
466    fn parse_case_insensitive() {
467        let cc: CacheControl = "No-Store, Max-Age=60".parse().unwrap();
468        assert!(cc.no_store);
469        assert_eq!(cc.max_age, Some(60));
470    }
471
472    #[test]
473    fn parse_max_stale_no_value() {
474        let cc: CacheControl = "max-stale".parse().unwrap();
475        assert_eq!(cc.max_stale, Some(0));
476    }
477
478    #[test]
479    fn parse_max_stale_with_value() {
480        let cc: CacheControl = "max-stale=300".parse().unwrap();
481        assert_eq!(cc.max_stale, Some(300));
482    }
483
484    #[test]
485    fn builder_private() {
486        let cc = CacheControl::new().private();
487        assert!(cc.private);
488        assert_eq!(cc.to_string(), "private");
489    }
490
491    #[test]
492    fn builder_no_cache() {
493        let cc = CacheControl::new().no_cache();
494        assert!(cc.no_cache);
495        assert_eq!(cc.to_string(), "no-cache");
496    }
497
498    #[test]
499    fn builder_no_transform() {
500        let cc = CacheControl::new().no_transform();
501        assert!(cc.no_transform);
502        assert_eq!(cc.to_string(), "no-transform");
503    }
504
505    #[test]
506    fn builder_must_revalidate() {
507        let cc = CacheControl::new().must_revalidate();
508        assert!(cc.must_revalidate);
509        assert_eq!(cc.to_string(), "must-revalidate");
510    }
511
512    #[test]
513    fn builder_proxy_revalidate() {
514        let cc = CacheControl::new().proxy_revalidate();
515        assert!(cc.proxy_revalidate);
516        assert_eq!(cc.to_string(), "proxy-revalidate");
517    }
518
519    #[test]
520    fn builder_s_maxage() {
521        let cc = CacheControl::new().s_maxage(7200);
522        assert_eq!(cc.s_maxage, Some(7200));
523        assert_eq!(cc.to_string(), "s-maxage=7200");
524    }
525
526    #[test]
527    fn builder_stale_while_revalidate() {
528        let cc = CacheControl::new().stale_while_revalidate(60);
529        assert_eq!(cc.stale_while_revalidate, Some(60));
530        assert_eq!(cc.to_string(), "stale-while-revalidate=60");
531    }
532
533    #[test]
534    fn builder_stale_if_error() {
535        let cc = CacheControl::new().stale_if_error(86_400);
536        assert_eq!(cc.stale_if_error, Some(86_400));
537        assert_eq!(cc.to_string(), "stale-if-error=86400");
538    }
539
540    #[test]
541    fn builder_only_if_cached() {
542        let cc = CacheControl::new().only_if_cached();
543        assert!(cc.only_if_cached);
544        assert_eq!(cc.to_string(), "only-if-cached");
545    }
546
547    #[test]
548    fn builder_max_stale() {
549        let cc = CacheControl::new().max_stale(120);
550        assert_eq!(cc.max_stale, Some(120));
551        assert_eq!(cc.to_string(), "max-stale=120");
552    }
553
554    #[test]
555    fn builder_min_fresh() {
556        let cc = CacheControl::new().min_fresh(30);
557        assert_eq!(cc.min_fresh, Some(30));
558        assert_eq!(cc.to_string(), "min-fresh=30");
559    }
560
561    #[test]
562    fn parse_cache_control_error_display() {
563        let err = ParseCacheControlError("max-age requires a value".into());
564        let s = err.to_string();
565        assert!(s.contains("invalid Cache-Control header"));
566        assert!(s.contains("max-age requires a value"));
567    }
568
569    #[test]
570    fn parse_numeric_missing_value_is_error() {
571        // max-age without a value should return an error
572        let result = "max-age".parse::<CacheControl>();
573        assert!(result.is_err());
574    }
575
576    #[test]
577    fn parse_numeric_bad_integer_is_error() {
578        let result = "max-age=abc".parse::<CacheControl>();
579        assert!(result.is_err());
580    }
581
582    #[test]
583    fn parse_all_boolean_directives() {
584        let cc: CacheControl = "public, private, no-cache, no-store, no-transform, must-revalidate, proxy-revalidate, immutable, only-if-cached"
585            .parse()
586            .unwrap();
587        assert!(cc.public);
588        assert!(cc.private);
589        assert!(cc.no_cache);
590        assert!(cc.no_store);
591        assert!(cc.no_transform);
592        assert!(cc.must_revalidate);
593        assert!(cc.proxy_revalidate);
594        assert!(cc.immutable);
595        assert!(cc.only_if_cached);
596    }
597
598    #[test]
599    fn parse_all_numeric_directives() {
600        let cc: CacheControl =
601            "max-age=10, s-maxage=20, stale-while-revalidate=30, stale-if-error=40, max-stale=50, min-fresh=60"
602                .parse()
603                .unwrap();
604        assert_eq!(cc.max_age, Some(10));
605        assert_eq!(cc.s_maxage, Some(20));
606        assert_eq!(cc.stale_while_revalidate, Some(30));
607        assert_eq!(cc.stale_if_error, Some(40));
608        assert_eq!(cc.max_stale, Some(50));
609        assert_eq!(cc.min_fresh, Some(60));
610    }
611
612    #[test]
613    fn display_mixed_boolean_and_numeric_with_only_if_cached() {
614        let cc = CacheControl::new()
615            .only_if_cached()
616            .max_stale(0)
617            .min_fresh(10);
618        let s = cc.to_string();
619        assert!(s.contains("only-if-cached"));
620        assert!(s.contains("max-stale=0"));
621        assert!(s.contains("min-fresh=10"));
622    }
623}