Skip to main content

oxigdal_services/
cache_headers.rs

1//! CDN-friendly HTTP caching headers for tile servers and geospatial APIs.
2
3use thiserror::Error;
4
5/// Errors produced by cache-header operations.
6#[derive(Debug, Error)]
7pub enum CacheError {
8    /// The ETag string was not in a valid format.
9    #[error("invalid ETag: {0}")]
10    InvalidETag(String),
11    /// The date string was not in a valid format.
12    #[error("invalid date: {0}")]
13    InvalidDate(String),
14}
15
16// ── CachePolicy ───────────────────────────────────────────────────────────────
17
18/// Describes how a response should be cached by browsers and CDNs.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum CachePolicy {
21    /// `no-store` — prohibit all caching.
22    NoStore,
23    /// `no-cache` — revalidate on every request via ETag/Last-Modified.
24    NoCache,
25    /// `public, max-age=X, immutable` — ideal for content-addressed URLs.
26    Immutable {
27        /// Seconds the response may be served from cache without revalidation.
28        max_age_secs: u32,
29    },
30    /// `public, max-age=X [, stale-while-revalidate=Y] [, stale-if-error=Z]`
31    Public {
32        /// Seconds the response is considered fresh.
33        max_age_secs: u32,
34        /// Allow stale serving while revalidating in the background.
35        stale_while_revalidate_secs: Option<u32>,
36        /// Allow stale serving when the origin is returning errors.
37        stale_if_error_secs: Option<u32>,
38    },
39    /// `private, max-age=X` — browser-only; never cached by shared caches.
40    Private {
41        /// Seconds the response may be served from the private cache.
42        max_age_secs: u32,
43    },
44}
45
46impl CachePolicy {
47    /// Formats this policy as a `Cache-Control` header value.
48    #[must_use]
49    pub fn to_header_value(&self) -> String {
50        match self {
51            Self::NoStore => "no-store".to_owned(),
52            Self::NoCache => "no-cache".to_owned(),
53            Self::Immutable { max_age_secs } => {
54                format!("public, max-age={max_age_secs}, immutable")
55            }
56            Self::Public {
57                max_age_secs,
58                stale_while_revalidate_secs,
59                stale_if_error_secs,
60            } => {
61                let mut s = format!("public, max-age={max_age_secs}");
62                if let Some(swr) = stale_while_revalidate_secs {
63                    s.push_str(&format!(", stale-while-revalidate={swr}"));
64                }
65                if let Some(sie) = stale_if_error_secs {
66                    s.push_str(&format!(", stale-if-error={sie}"));
67                }
68                s
69            }
70            Self::Private { max_age_secs } => {
71                format!("private, max-age={max_age_secs}")
72            }
73        }
74    }
75
76    /// Default policy for map tiles: 1 h fresh, 60 s stale-while-revalidate, 24 h stale-if-error.
77    #[must_use]
78    pub fn tile_default() -> Self {
79        Self::Public {
80            max_age_secs: 3600,
81            stale_while_revalidate_secs: Some(60),
82            stale_if_error_secs: Some(86400),
83        }
84    }
85
86    /// Default policy for dataset/layer metadata: 5 min fresh, 30 s swr, 1 h sie.
87    #[must_use]
88    pub fn metadata_default() -> Self {
89        Self::Public {
90            max_age_secs: 300,
91            stale_while_revalidate_secs: Some(30),
92            stale_if_error_secs: Some(3600),
93        }
94    }
95
96    /// Policy for static assets referenced by content hash: 1 year, immutable.
97    #[must_use]
98    pub fn static_asset() -> Self {
99        Self::Immutable {
100            max_age_secs: 31_536_000,
101        }
102    }
103
104    /// Policy for dynamic API responses: 60 s fresh, 10 s swr, 10 min sie.
105    #[must_use]
106    pub fn api_response() -> Self {
107        Self::Public {
108            max_age_secs: 60,
109            stale_while_revalidate_secs: Some(10),
110            stale_if_error_secs: Some(600),
111        }
112    }
113}
114
115// ── ETag ──────────────────────────────────────────────────────────────────────
116
117/// An HTTP ETag header value, either strong or weak.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ETag {
120    /// The opaque tag value (without surrounding quotes or `W/` prefix).
121    pub value: String,
122    /// `true` if this is a weak ETag (`W/"…"`).
123    pub weak: bool,
124}
125
126const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
127const FNV_PRIME: u64 = 1_099_511_628_211;
128
129fn fnv1a_64(data: &[u8]) -> u64 {
130    data.iter().fold(FNV_OFFSET, |acc, &b| {
131        (acc ^ b as u64).wrapping_mul(FNV_PRIME)
132    })
133}
134
135impl ETag {
136    /// Creates a strong ETag whose value is the FNV-1a 64-bit hash of `data`.
137    #[must_use]
138    pub fn from_bytes(data: &[u8]) -> Self {
139        let hash = fnv1a_64(data);
140        Self {
141            value: format!("{hash:016x}"),
142            weak: false,
143        }
144    }
145
146    /// Creates a strong ETag with an explicit string value.
147    #[must_use]
148    pub fn from_str_value(s: &str) -> Self {
149        Self {
150            value: s.to_owned(),
151            weak: false,
152        }
153    }
154
155    /// Creates a weak ETag with the given value.
156    #[must_use]
157    pub fn weak(value: impl Into<String>) -> Self {
158        Self {
159            value: value.into(),
160            weak: true,
161        }
162    }
163
164    /// Formats the ETag as an HTTP header value: `"value"` or `W/"value"`.
165    #[must_use]
166    pub fn to_header_value(&self) -> String {
167        if self.weak {
168            format!("W/\"{}\"", self.value)
169        } else {
170            format!("\"{}\"", self.value)
171        }
172    }
173
174    /// Parses an ETag from an HTTP header value.
175    ///
176    /// Accepts `"value"` (strong) and `W/"value"` (weak).
177    ///
178    /// # Errors
179    /// Returns [`CacheError::InvalidETag`] if the string is not a valid ETag.
180    pub fn parse(s: &str) -> Result<Self, CacheError> {
181        let s = s.trim();
182        if let Some(rest) = s.strip_prefix("W/\"") {
183            let value = rest
184                .strip_suffix('"')
185                .ok_or_else(|| CacheError::InvalidETag(s.to_owned()))?;
186            return Ok(Self::weak(value));
187        }
188        if let Some(inner) = s.strip_prefix('"') {
189            let value = inner
190                .strip_suffix('"')
191                .ok_or_else(|| CacheError::InvalidETag(s.to_owned()))?;
192            return Ok(Self::from_str_value(value));
193        }
194        Err(CacheError::InvalidETag(s.to_owned()))
195    }
196}
197
198// ── VaryHeader ────────────────────────────────────────────────────────────────
199
200/// Builder for the HTTP `Vary` response header.
201#[derive(Debug, Clone, Default)]
202pub struct VaryHeader {
203    /// The list of request header field names that affect the response.
204    pub fields: Vec<String>,
205}
206
207impl VaryHeader {
208    /// Creates an empty `VaryHeader`.
209    #[must_use]
210    pub fn new() -> Self {
211        Self::default()
212    }
213
214    /// Appends a field name (builder-style).
215    #[must_use]
216    #[allow(clippy::should_implement_trait)]
217    pub fn add(mut self, field: impl Into<String>) -> Self {
218        self.fields.push(field.into());
219        self
220    }
221
222    /// Returns a `Vary` header containing only `Accept-Encoding`.
223    #[must_use]
224    pub fn accept_encoding() -> Self {
225        Self::new().add("Accept-Encoding")
226    }
227
228    /// Returns a `Vary` header containing `Origin` and `Accept-Encoding`.
229    #[must_use]
230    pub fn origin_and_encoding() -> Self {
231        Self::new().add("Origin").add("Accept-Encoding")
232    }
233
234    /// Formats the header value as a comma-separated list of field names.
235    #[must_use]
236    pub fn to_header_value(&self) -> String {
237        self.fields.join(", ")
238    }
239}
240
241// ── HTTP date formatting ──────────────────────────────────────────────────────
242
243/// Formats a Unix timestamp (seconds since 1970-01-01T00:00:00Z) as an HTTP
244/// date string, e.g. `"Thu, 01 Jan 1970 00:00:00 GMT"`.
245///
246/// Implemented without any date-time library using the civil-calendar algorithm
247/// by Howard Hinnant.
248#[must_use]
249pub fn format_http_date(unix_secs: u64) -> String {
250    const DAY_NAMES: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
251    const MONTH_NAMES: [&str; 12] = [
252        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
253    ];
254
255    // Day of week: epoch (1970-01-01) was a Thursday (index 0 in our array).
256    let day_of_week = DAY_NAMES[(unix_secs / 86400 % 7) as usize];
257
258    let secs_of_day = unix_secs % 86400;
259    let hour = secs_of_day / 3600;
260    let minute = (secs_of_day % 3600) / 60;
261    let second = secs_of_day % 60;
262
263    // Civil calendar from Unix days (Hinnant's algorithm, integer arithmetic only).
264    // Shift epoch to 0000-03-01 to simplify leap-year handling.
265    let z = unix_secs / 86400 + 719_468;
266    let era = z / 146_097;
267    let doe = z - era * 146_097; // day of era [0, 146096]
268    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // year of era [0, 399]
269    let y = yoe + era * 400; // year
270    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of year [0, 365]
271    let mp = (5 * doy + 2) / 153; // month of year [0, 11]
272    let d = doy - (153 * mp + 2) / 5 + 1; // day [1, 31]
273    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // month [1, 12]
274    let y = if m <= 2 { y + 1 } else { y }; // adjust year
275
276    format!(
277        "{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
278        day_of_week,
279        d,
280        MONTH_NAMES[(m - 1) as usize],
281        y,
282        hour,
283        minute,
284        second
285    )
286}
287
288// ── CacheHeaders ──────────────────────────────────────────────────────────────
289
290/// A complete set of cache-related HTTP response headers.
291#[derive(Debug, Clone, Default)]
292pub struct CacheHeaders {
293    /// Value of the `Cache-Control` header.
294    pub cache_control: String,
295    /// Value of the `ETag` header, if any.
296    pub etag: Option<String>,
297    /// Value of the `Last-Modified` header (HTTP date), if any.
298    pub last_modified: Option<String>,
299    /// Value of the `Vary` header, if any.
300    pub vary: Option<String>,
301    /// Value of the `CDN-Cache-Control` header, if any.
302    pub cdn_cache_control: Option<String>,
303    /// Value of the `Surrogate-Control` header (Varnish/Fastly), if any.
304    pub surrogate_control: Option<String>,
305}
306
307impl CacheHeaders {
308    /// Creates a new `CacheHeaders` from a [`CachePolicy`].
309    #[must_use]
310    pub fn new(policy: CachePolicy) -> Self {
311        Self {
312            cache_control: policy.to_header_value(),
313            ..Self::default()
314        }
315    }
316
317    /// Attaches an ETag header (builder-style).
318    #[must_use]
319    pub fn with_etag(mut self, etag: ETag) -> Self {
320        self.etag = Some(etag.to_header_value());
321        self
322    }
323
324    /// Attaches a `Last-Modified` header derived from a Unix timestamp
325    /// (builder-style).
326    #[must_use]
327    pub fn with_last_modified(mut self, unix_secs: u64) -> Self {
328        self.last_modified = Some(format_http_date(unix_secs));
329        self
330    }
331
332    /// Attaches a `Vary` header (builder-style).
333    #[must_use]
334    pub fn with_vary(mut self, vary: VaryHeader) -> Self {
335        self.vary = Some(vary.to_header_value());
336        self
337    }
338
339    /// Adds CDN-specific override headers (`CDN-Cache-Control` and
340    /// `Surrogate-Control`) with a custom max-age (builder-style).
341    #[must_use]
342    pub fn with_cdn_override(mut self, cdn_max_age_secs: u32) -> Self {
343        self.cdn_cache_control = Some(format!("public, max-age={cdn_max_age_secs}"));
344        self.surrogate_control = Some(format!("max-age={cdn_max_age_secs}"));
345        self
346    }
347
348    /// Returns `true` if the request's `If-None-Match` value matches this
349    /// response's ETag, indicating the client's copy is still valid (→ 304).
350    #[must_use]
351    pub fn is_not_modified(&self, if_none_match: Option<&str>) -> bool {
352        match (&self.etag, if_none_match) {
353            (Some(our_etag), Some(client_val)) => our_etag == client_val,
354            _ => false,
355        }
356    }
357
358    /// Returns all set headers as `(name, value)` pairs.
359    ///
360    /// `Cache-Control` is always included.  All other headers are omitted when
361    /// not set.
362    #[must_use]
363    pub fn to_header_pairs(&self) -> Vec<(String, String)> {
364        let mut pairs = vec![("Cache-Control".to_owned(), self.cache_control.clone())];
365        if let Some(v) = &self.etag {
366            pairs.push(("ETag".to_owned(), v.clone()));
367        }
368        if let Some(v) = &self.last_modified {
369            pairs.push(("Last-Modified".to_owned(), v.clone()));
370        }
371        if let Some(v) = &self.vary {
372            pairs.push(("Vary".to_owned(), v.clone()));
373        }
374        if let Some(v) = &self.cdn_cache_control {
375            pairs.push(("CDN-Cache-Control".to_owned(), v.clone()));
376        }
377        if let Some(v) = &self.surrogate_control {
378            pairs.push(("Surrogate-Control".to_owned(), v.clone()));
379        }
380        pairs
381    }
382}
383
384// ── TileCacheStrategy ─────────────────────────────────────────────────────────
385
386/// Zoom-level-aware caching strategy for map tiles.
387#[derive(Debug, Clone)]
388pub struct TileCacheStrategy {
389    /// Ordered list of `(min_zoom, max_zoom, policy)` bands.
390    pub zoom_policies: Vec<(u8, u8, CachePolicy)>,
391    /// Fallback policy when no band matches the requested zoom level.
392    pub default_policy: CachePolicy,
393}
394
395impl TileCacheStrategy {
396    /// Creates an empty strategy with `NoCache` as the fallback.
397    #[must_use]
398    pub fn new() -> Self {
399        Self {
400            zoom_policies: Vec::new(),
401            default_policy: CachePolicy::NoCache,
402        }
403    }
404
405    /// Returns the standard multi-tier tile caching strategy:
406    ///
407    /// | Zoom  | Policy                                          |
408    /// |-------|-------------------------------------------------|
409    /// | 0–7   | public, 24 h, swr 1 h, sie 7 d                 |
410    /// | 8–12  | public, 1 h, swr 60 s, sie 24 h                |
411    /// | 13–16 | public, 5 min, swr 30 s, sie 1 h               |
412    /// | 17–22 | no-cache                                        |
413    #[must_use]
414    pub fn standard_tile_strategy() -> Self {
415        Self {
416            zoom_policies: vec![
417                (
418                    0,
419                    7,
420                    CachePolicy::Public {
421                        max_age_secs: 86400,
422                        stale_while_revalidate_secs: Some(3600),
423                        stale_if_error_secs: Some(604_800),
424                    },
425                ),
426                (
427                    8,
428                    12,
429                    CachePolicy::Public {
430                        max_age_secs: 3600,
431                        stale_while_revalidate_secs: Some(60),
432                        stale_if_error_secs: Some(86400),
433                    },
434                ),
435                (
436                    13,
437                    16,
438                    CachePolicy::Public {
439                        max_age_secs: 300,
440                        stale_while_revalidate_secs: Some(30),
441                        stale_if_error_secs: Some(3600),
442                    },
443                ),
444                (17, 22, CachePolicy::NoCache),
445            ],
446            default_policy: CachePolicy::NoCache,
447        }
448    }
449
450    /// Returns the [`CachePolicy`] appropriate for the given zoom level.
451    #[must_use]
452    pub fn policy_for_zoom(&self, zoom: u8) -> &CachePolicy {
453        for (min, max, policy) in &self.zoom_policies {
454            if zoom >= *min && zoom <= *max {
455                return policy;
456            }
457        }
458        &self.default_policy
459    }
460
461    /// Builds [`CacheHeaders`] for a tile at `zoom` with content `tile_data`.
462    ///
463    /// The ETag is derived from the tile bytes; `Vary: Accept-Encoding` is
464    /// always set.
465    #[must_use]
466    pub fn headers_for_tile(&self, zoom: u8, tile_data: &[u8]) -> CacheHeaders {
467        let policy = self.policy_for_zoom(zoom).clone();
468        CacheHeaders::new(policy)
469            .with_etag(ETag::from_bytes(tile_data))
470            .with_vary(VaryHeader::accept_encoding())
471    }
472}
473
474impl Default for TileCacheStrategy {
475    fn default() -> Self {
476        Self::new()
477    }
478}