use thiserror::Error;
#[derive(Debug, Error)]
pub enum CacheError {
#[error("invalid ETag: {0}")]
InvalidETag(String),
#[error("invalid date: {0}")]
InvalidDate(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CachePolicy {
NoStore,
NoCache,
Immutable {
max_age_secs: u32,
},
Public {
max_age_secs: u32,
stale_while_revalidate_secs: Option<u32>,
stale_if_error_secs: Option<u32>,
},
Private {
max_age_secs: u32,
},
}
impl CachePolicy {
#[must_use]
pub fn to_header_value(&self) -> String {
match self {
Self::NoStore => "no-store".to_owned(),
Self::NoCache => "no-cache".to_owned(),
Self::Immutable { max_age_secs } => {
format!("public, max-age={max_age_secs}, immutable")
}
Self::Public {
max_age_secs,
stale_while_revalidate_secs,
stale_if_error_secs,
} => {
let mut s = format!("public, max-age={max_age_secs}");
if let Some(swr) = stale_while_revalidate_secs {
s.push_str(&format!(", stale-while-revalidate={swr}"));
}
if let Some(sie) = stale_if_error_secs {
s.push_str(&format!(", stale-if-error={sie}"));
}
s
}
Self::Private { max_age_secs } => {
format!("private, max-age={max_age_secs}")
}
}
}
#[must_use]
pub fn tile_default() -> Self {
Self::Public {
max_age_secs: 3600,
stale_while_revalidate_secs: Some(60),
stale_if_error_secs: Some(86400),
}
}
#[must_use]
pub fn metadata_default() -> Self {
Self::Public {
max_age_secs: 300,
stale_while_revalidate_secs: Some(30),
stale_if_error_secs: Some(3600),
}
}
#[must_use]
pub fn static_asset() -> Self {
Self::Immutable {
max_age_secs: 31_536_000,
}
}
#[must_use]
pub fn api_response() -> Self {
Self::Public {
max_age_secs: 60,
stale_while_revalidate_secs: Some(10),
stale_if_error_secs: Some(600),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ETag {
pub value: String,
pub weak: bool,
}
const FNV_OFFSET: u64 = 14_695_981_039_346_656_037;
const FNV_PRIME: u64 = 1_099_511_628_211;
fn fnv1a_64(data: &[u8]) -> u64 {
data.iter().fold(FNV_OFFSET, |acc, &b| {
(acc ^ b as u64).wrapping_mul(FNV_PRIME)
})
}
impl ETag {
#[must_use]
pub fn from_bytes(data: &[u8]) -> Self {
let hash = fnv1a_64(data);
Self {
value: format!("{hash:016x}"),
weak: false,
}
}
#[must_use]
pub fn from_str_value(s: &str) -> Self {
Self {
value: s.to_owned(),
weak: false,
}
}
#[must_use]
pub fn weak(value: impl Into<String>) -> Self {
Self {
value: value.into(),
weak: true,
}
}
#[must_use]
pub fn to_header_value(&self) -> String {
if self.weak {
format!("W/\"{}\"", self.value)
} else {
format!("\"{}\"", self.value)
}
}
pub fn parse(s: &str) -> Result<Self, CacheError> {
let s = s.trim();
if let Some(rest) = s.strip_prefix("W/\"") {
let value = rest
.strip_suffix('"')
.ok_or_else(|| CacheError::InvalidETag(s.to_owned()))?;
return Ok(Self::weak(value));
}
if let Some(inner) = s.strip_prefix('"') {
let value = inner
.strip_suffix('"')
.ok_or_else(|| CacheError::InvalidETag(s.to_owned()))?;
return Ok(Self::from_str_value(value));
}
Err(CacheError::InvalidETag(s.to_owned()))
}
}
#[derive(Debug, Clone, Default)]
pub struct VaryHeader {
pub fields: Vec<String>,
}
impl VaryHeader {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
#[allow(clippy::should_implement_trait)]
pub fn add(mut self, field: impl Into<String>) -> Self {
self.fields.push(field.into());
self
}
#[must_use]
pub fn accept_encoding() -> Self {
Self::new().add("Accept-Encoding")
}
#[must_use]
pub fn origin_and_encoding() -> Self {
Self::new().add("Origin").add("Accept-Encoding")
}
#[must_use]
pub fn to_header_value(&self) -> String {
self.fields.join(", ")
}
}
#[must_use]
pub fn format_http_date(unix_secs: u64) -> String {
const DAY_NAMES: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
const MONTH_NAMES: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let day_of_week = DAY_NAMES[(unix_secs / 86400 % 7) as usize];
let secs_of_day = unix_secs % 86400;
let hour = secs_of_day / 3600;
let minute = (secs_of_day % 3600) / 60;
let second = secs_of_day % 60;
let z = unix_secs / 86400 + 719_468;
let era = z / 146_097;
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!(
"{}, {:02} {} {:04} {:02}:{:02}:{:02} GMT",
day_of_week,
d,
MONTH_NAMES[(m - 1) as usize],
y,
hour,
minute,
second
)
}
#[derive(Debug, Clone, Default)]
pub struct CacheHeaders {
pub cache_control: String,
pub etag: Option<String>,
pub last_modified: Option<String>,
pub vary: Option<String>,
pub cdn_cache_control: Option<String>,
pub surrogate_control: Option<String>,
}
impl CacheHeaders {
#[must_use]
pub fn new(policy: CachePolicy) -> Self {
Self {
cache_control: policy.to_header_value(),
..Self::default()
}
}
#[must_use]
pub fn with_etag(mut self, etag: ETag) -> Self {
self.etag = Some(etag.to_header_value());
self
}
#[must_use]
pub fn with_last_modified(mut self, unix_secs: u64) -> Self {
self.last_modified = Some(format_http_date(unix_secs));
self
}
#[must_use]
pub fn with_vary(mut self, vary: VaryHeader) -> Self {
self.vary = Some(vary.to_header_value());
self
}
#[must_use]
pub fn with_cdn_override(mut self, cdn_max_age_secs: u32) -> Self {
self.cdn_cache_control = Some(format!("public, max-age={cdn_max_age_secs}"));
self.surrogate_control = Some(format!("max-age={cdn_max_age_secs}"));
self
}
#[must_use]
pub fn is_not_modified(&self, if_none_match: Option<&str>) -> bool {
match (&self.etag, if_none_match) {
(Some(our_etag), Some(client_val)) => our_etag == client_val,
_ => false,
}
}
#[must_use]
pub fn to_header_pairs(&self) -> Vec<(String, String)> {
let mut pairs = vec![("Cache-Control".to_owned(), self.cache_control.clone())];
if let Some(v) = &self.etag {
pairs.push(("ETag".to_owned(), v.clone()));
}
if let Some(v) = &self.last_modified {
pairs.push(("Last-Modified".to_owned(), v.clone()));
}
if let Some(v) = &self.vary {
pairs.push(("Vary".to_owned(), v.clone()));
}
if let Some(v) = &self.cdn_cache_control {
pairs.push(("CDN-Cache-Control".to_owned(), v.clone()));
}
if let Some(v) = &self.surrogate_control {
pairs.push(("Surrogate-Control".to_owned(), v.clone()));
}
pairs
}
}
#[derive(Debug, Clone)]
pub struct TileCacheStrategy {
pub zoom_policies: Vec<(u8, u8, CachePolicy)>,
pub default_policy: CachePolicy,
}
impl TileCacheStrategy {
#[must_use]
pub fn new() -> Self {
Self {
zoom_policies: Vec::new(),
default_policy: CachePolicy::NoCache,
}
}
#[must_use]
pub fn standard_tile_strategy() -> Self {
Self {
zoom_policies: vec![
(
0,
7,
CachePolicy::Public {
max_age_secs: 86400,
stale_while_revalidate_secs: Some(3600),
stale_if_error_secs: Some(604_800),
},
),
(
8,
12,
CachePolicy::Public {
max_age_secs: 3600,
stale_while_revalidate_secs: Some(60),
stale_if_error_secs: Some(86400),
},
),
(
13,
16,
CachePolicy::Public {
max_age_secs: 300,
stale_while_revalidate_secs: Some(30),
stale_if_error_secs: Some(3600),
},
),
(17, 22, CachePolicy::NoCache),
],
default_policy: CachePolicy::NoCache,
}
}
#[must_use]
pub fn policy_for_zoom(&self, zoom: u8) -> &CachePolicy {
for (min, max, policy) in &self.zoom_policies {
if zoom >= *min && zoom <= *max {
return policy;
}
}
&self.default_policy
}
#[must_use]
pub fn headers_for_tile(&self, zoom: u8, tile_data: &[u8]) -> CacheHeaders {
let policy = self.policy_for_zoom(zoom).clone();
CacheHeaders::new(policy)
.with_etag(ETag::from_bytes(tile_data))
.with_vary(VaryHeader::accept_encoding())
}
}
impl Default for TileCacheStrategy {
fn default() -> Self {
Self::new()
}
}