1use thiserror::Error;
4
5#[derive(Debug, Error)]
7pub enum CacheError {
8 #[error("invalid ETag: {0}")]
10 InvalidETag(String),
11 #[error("invalid date: {0}")]
13 InvalidDate(String),
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum CachePolicy {
21 NoStore,
23 NoCache,
25 Immutable {
27 max_age_secs: u32,
29 },
30 Public {
32 max_age_secs: u32,
34 stale_while_revalidate_secs: Option<u32>,
36 stale_if_error_secs: Option<u32>,
38 },
39 Private {
41 max_age_secs: u32,
43 },
44}
45
46impl CachePolicy {
47 #[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 #[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 #[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 #[must_use]
98 pub fn static_asset() -> Self {
99 Self::Immutable {
100 max_age_secs: 31_536_000,
101 }
102 }
103
104 #[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#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ETag {
120 pub value: String,
122 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 #[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 #[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 #[must_use]
157 pub fn weak(value: impl Into<String>) -> Self {
158 Self {
159 value: value.into(),
160 weak: true,
161 }
162 }
163
164 #[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 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#[derive(Debug, Clone, Default)]
202pub struct VaryHeader {
203 pub fields: Vec<String>,
205}
206
207impl VaryHeader {
208 #[must_use]
210 pub fn new() -> Self {
211 Self::default()
212 }
213
214 #[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 #[must_use]
224 pub fn accept_encoding() -> Self {
225 Self::new().add("Accept-Encoding")
226 }
227
228 #[must_use]
230 pub fn origin_and_encoding() -> Self {
231 Self::new().add("Origin").add("Accept-Encoding")
232 }
233
234 #[must_use]
236 pub fn to_header_value(&self) -> String {
237 self.fields.join(", ")
238 }
239}
240
241#[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 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 let z = unix_secs / 86400 + 719_468;
266 let era = z / 146_097;
267 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y }; 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#[derive(Debug, Clone, Default)]
292pub struct CacheHeaders {
293 pub cache_control: String,
295 pub etag: Option<String>,
297 pub last_modified: Option<String>,
299 pub vary: Option<String>,
301 pub cdn_cache_control: Option<String>,
303 pub surrogate_control: Option<String>,
305}
306
307impl CacheHeaders {
308 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone)]
388pub struct TileCacheStrategy {
389 pub zoom_policies: Vec<(u8, u8, CachePolicy)>,
391 pub default_policy: CachePolicy,
393}
394
395impl TileCacheStrategy {
396 #[must_use]
398 pub fn new() -> Self {
399 Self {
400 zoom_policies: Vec::new(),
401 default_policy: CachePolicy::NoCache,
402 }
403 }
404
405 #[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 #[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 #[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}