Skip to main content

goose_http/cache/
mod.rs

1//! HTTP caching helpers covering validators, freshness, and revalidation.
2//!
3//! Implements parsing of `Cache-Control`, derivation of freshness metadata, and
4//! helpers for ensuring compliant response headers per RFC 9111.
5
6use std::time::{Duration, SystemTime};
7
8use crate::{
9    date,
10    headers::{Headers, header_keys},
11};
12
13/// Represents parsed Cache-Control directives.
14#[derive(Debug, Default, Clone, PartialEq, Eq)]
15pub struct CacheControl {
16    pub no_store: bool,
17    pub no_cache: bool,
18    pub must_revalidate: bool,
19    pub max_age: Option<u64>,
20    pub other: Vec<String>,
21}
22
23/// Represents cache policy metadata derived from headers.
24#[derive(Debug, Default, Clone, PartialEq, Eq)]
25pub struct CachePolicy {
26    pub control: Option<CacheControl>,
27    pub age: Option<u64>,
28    pub expires: Option<SystemTime>,
29    pub vary: Vec<String>,
30}
31
32/// Parse a Cache-Control header into structured directives.
33pub fn parse_cache_control(value: &str) -> CacheControl {
34    let mut control = CacheControl::default();
35    for directive in value.split(',') {
36        let directive = directive.trim();
37        if directive.is_empty() {
38            continue;
39        }
40        let mut parts = directive.splitn(2, '=');
41        let token = parts.next().unwrap().trim().to_ascii_lowercase();
42        let param = parts.next().map(|p| p.trim_matches('"'));
43        match token.as_str() {
44            "no-store" => control.no_store = true,
45            "no-cache" => control.no_cache = true,
46            "must-revalidate" => control.must_revalidate = true,
47            "max-age" => {
48                if let Some(value) = param.and_then(|p| p.parse::<u64>().ok()) {
49                    control.max_age = Some(value);
50                }
51            }
52            _ => control.other.push(directive.to_owned()),
53        }
54    }
55    control
56}
57
58/// Analyse response headers to produce a [`CachePolicy`].
59pub fn policy_from_headers(headers: &Headers) -> CachePolicy {
60    let control = headers
61        .get(header_keys::CACHE_CONTROL)
62        .map(parse_cache_control);
63
64    let age = headers
65        .get(header_keys::AGE)
66        .and_then(|value| value.parse::<u64>().ok());
67
68    let expires = headers
69        .get(header_keys::EXPIRES)
70        .and_then(date::parse_http_date);
71
72    let vary = headers
73        .get(header_keys::VARY)
74        .map(|value| value.split(',').map(|v| v.trim().to_string()).collect())
75        .unwrap_or_default();
76
77    CachePolicy {
78        control,
79        age,
80        expires,
81        vary,
82    }
83}
84
85/// Compute the freshness lifetime hinted by the headers.
86pub fn freshness_lifetime(policy: &CachePolicy, date: Option<SystemTime>) -> Option<Duration> {
87    if let Some(control) = &policy.control {
88        if let Some(max_age) = control.max_age {
89            return Some(Duration::from_secs(max_age));
90        }
91    }
92
93    if let (Some(expires), Some(date_value)) = (policy.expires, date) {
94        if let Ok(delta) = expires.duration_since(date_value) {
95            return Some(delta);
96        }
97    }
98
99    None
100}
101
102/// Ensure the Age header is set when absent (origin servers should emit Age).
103pub fn ensure_age_header(headers: &mut Headers) {
104    if !headers.contains(header_keys::AGE) {
105        headers.insert(header_keys::AGE, "0");
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn parse_cache_control_recognises_common_directives() {
115        let control = parse_cache_control("max-age=60, must-revalidate, no-store");
116        assert_eq!(control.max_age, Some(60));
117        assert!(control.must_revalidate);
118        assert!(control.no_store);
119        assert!(!control.no_cache);
120    }
121
122    #[test]
123    fn policy_extracts_age_and_vary() {
124        let mut headers = Headers::new();
125        headers.insert(header_keys::CACHE_CONTROL, "max-age=120");
126        headers.insert(header_keys::AGE, "15");
127        headers.insert(header_keys::VARY, "Accept-Encoding, Accept-Language");
128        let policy = policy_from_headers(&headers);
129        assert_eq!(policy.control.unwrap().max_age, Some(120));
130        assert_eq!(policy.age, Some(15));
131        assert_eq!(policy.vary.len(), 2);
132    }
133}