use crate::{HttpRequest, HttpResponse};
use std::collections::{HashMap, HashSet, VecDeque};
use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};
use tokio::sync::RwLock;
#[derive(Debug, Clone, PartialEq)]
pub enum CacheDirective {
Public,
Private,
NoStore,
NoCache,
MaxAge(u64),
SMaxAge(u64),
MustRevalidate,
ProxyRevalidate,
NoTransform,
Immutable,
MaxStale(Option<u64>),
MinFresh(u64),
OnlyIfCached,
Extension(String, Option<String>),
}
impl CacheDirective {
pub fn parse(s: &str) -> Option<Self> {
let s = s.trim().to_lowercase();
if let Some((key, value)) = s.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"');
return match key {
"max-age" => value.parse().ok().map(CacheDirective::MaxAge),
"s-maxage" => value.parse().ok().map(CacheDirective::SMaxAge),
"max-stale" => Some(CacheDirective::MaxStale(value.parse().ok())),
"min-fresh" => value.parse().ok().map(CacheDirective::MinFresh),
_ => Some(CacheDirective::Extension(
key.to_string(),
Some(value.to_string()),
)),
};
}
match s.as_str() {
"public" => Some(CacheDirective::Public),
"private" => Some(CacheDirective::Private),
"no-store" => Some(CacheDirective::NoStore),
"no-cache" => Some(CacheDirective::NoCache),
"must-revalidate" => Some(CacheDirective::MustRevalidate),
"proxy-revalidate" => Some(CacheDirective::ProxyRevalidate),
"no-transform" => Some(CacheDirective::NoTransform),
"immutable" => Some(CacheDirective::Immutable),
"max-stale" => Some(CacheDirective::MaxStale(None)),
"only-if-cached" => Some(CacheDirective::OnlyIfCached),
_ => Some(CacheDirective::Extension(s, None)),
}
}
pub fn to_header_value(&self) -> String {
match self {
CacheDirective::Public => "public".to_string(),
CacheDirective::Private => "private".to_string(),
CacheDirective::NoStore => "no-store".to_string(),
CacheDirective::NoCache => "no-cache".to_string(),
CacheDirective::MaxAge(secs) => format!("max-age={}", secs),
CacheDirective::SMaxAge(secs) => format!("s-maxage={}", secs),
CacheDirective::MustRevalidate => "must-revalidate".to_string(),
CacheDirective::ProxyRevalidate => "proxy-revalidate".to_string(),
CacheDirective::NoTransform => "no-transform".to_string(),
CacheDirective::Immutable => "immutable".to_string(),
CacheDirective::MaxStale(Some(secs)) => format!("max-stale={}", secs),
CacheDirective::MaxStale(None) => "max-stale".to_string(),
CacheDirective::MinFresh(secs) => format!("min-fresh={}", secs),
CacheDirective::OnlyIfCached => "only-if-cached".to_string(),
CacheDirective::Extension(key, Some(value)) => format!("{}={}", key, value),
CacheDirective::Extension(key, None) => key.clone(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CacheControl {
pub directives: Vec<CacheDirective>,
}
impl CacheControl {
pub fn new() -> Self {
Self::default()
}
pub fn parse(header: &str) -> Self {
let directives: Vec<CacheDirective> = header
.split(',')
.filter_map(|s| CacheDirective::parse(s.trim()))
.collect();
Self { directives }
}
pub fn to_header_value(&self) -> String {
self.directives
.iter()
.map(|d| d.to_header_value())
.collect::<Vec<_>>()
.join(", ")
}
pub fn public(mut self) -> Self {
self.directives.push(CacheDirective::Public);
self
}
pub fn private(mut self) -> Self {
self.directives.push(CacheDirective::Private);
self
}
pub fn no_store(mut self) -> Self {
self.directives.push(CacheDirective::NoStore);
self
}
pub fn no_cache(mut self) -> Self {
self.directives.push(CacheDirective::NoCache);
self
}
pub fn max_age(mut self, duration: Duration) -> Self {
self.directives
.push(CacheDirective::MaxAge(duration.as_secs()));
self
}
pub fn s_maxage(mut self, duration: Duration) -> Self {
self.directives
.push(CacheDirective::SMaxAge(duration.as_secs()));
self
}
pub fn must_revalidate(mut self) -> Self {
self.directives.push(CacheDirective::MustRevalidate);
self
}
pub fn proxy_revalidate(mut self) -> Self {
self.directives.push(CacheDirective::ProxyRevalidate);
self
}
pub fn no_transform(mut self) -> Self {
self.directives.push(CacheDirective::NoTransform);
self
}
pub fn immutable(mut self) -> Self {
self.directives.push(CacheDirective::Immutable);
self
}
pub fn directive(mut self, directive: CacheDirective) -> Self {
self.directives.push(directive);
self
}
pub fn is_public(&self) -> bool {
self.directives
.iter()
.any(|d| matches!(d, CacheDirective::Public))
}
pub fn is_private(&self) -> bool {
self.directives
.iter()
.any(|d| matches!(d, CacheDirective::Private))
}
pub fn is_no_store(&self) -> bool {
self.directives
.iter()
.any(|d| matches!(d, CacheDirective::NoStore))
}
pub fn is_no_cache(&self) -> bool {
self.directives
.iter()
.any(|d| matches!(d, CacheDirective::NoCache))
}
pub fn is_must_revalidate(&self) -> bool {
self.directives
.iter()
.any(|d| matches!(d, CacheDirective::MustRevalidate))
}
pub fn is_immutable(&self) -> bool {
self.directives
.iter()
.any(|d| matches!(d, CacheDirective::Immutable))
}
pub fn get_max_age(&self) -> Option<u64> {
self.directives.iter().find_map(|d| match d {
CacheDirective::MaxAge(secs) => Some(*secs),
_ => None,
})
}
pub fn get_s_maxage(&self) -> Option<u64> {
self.directives.iter().find_map(|d| match d {
CacheDirective::SMaxAge(secs) => Some(*secs),
_ => None,
})
}
pub fn is_cacheable(&self) -> bool {
if self.is_no_store() {
return false;
}
self.is_public()
|| self.is_private()
|| self.get_max_age().is_some()
|| self.get_s_maxage().is_some()
}
pub fn freshness_lifetime(&self) -> Option<u64> {
self.get_s_maxage().or_else(|| self.get_max_age())
}
pub fn never() -> Self {
Self::new().no_store().no_cache()
}
pub fn public_max_age(duration: Duration) -> Self {
Self::new().public().max_age(duration)
}
pub fn private_max_age(duration: Duration) -> Self {
Self::new().private().max_age(duration)
}
pub fn immutable_asset(duration: Duration) -> Self {
Self::new().public().max_age(duration).immutable()
}
pub fn revalidate(duration: Duration) -> Self {
Self::new().public().max_age(duration).must_revalidate()
}
}
impl fmt::Display for CacheControl {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_header_value())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey {
pub method: String,
pub path: String,
pub query: String,
pub vary_values: Vec<(String, String)>,
pub body_hash: Option<u64>,
}
impl CacheKey {
pub fn from_request(request: &HttpRequest) -> Self {
Self::from_request_with_vary(request, &[])
}
pub fn from_request_with_vary(request: &HttpRequest, vary_headers: &[&str]) -> Self {
let mut query_params: Vec<_> = request.query_params.iter().collect();
query_params.sort_by(|a, b| a.0.cmp(b.0));
let query = query_params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
let mut vary_values: Vec<(String, String)> = vary_headers
.iter()
.filter_map(|header| {
request
.headers
.get(header)
.or_else(|| request.headers.get(&header.to_lowercase()))
.map(|v| (header.to_lowercase(), v.clone()))
})
.collect();
vary_values.sort_by(|a, b| a.0.cmp(&b.0));
let method = request.method.to_uppercase();
let body_hash = if method == "QUERY" {
use std::hash::{Hash, Hasher};
let mut hasher = std::hash::DefaultHasher::new();
request.body_bytes().hash(&mut hasher);
Some(hasher.finish())
} else {
None
};
Self {
method,
path: request.path.clone(),
query,
vary_values,
body_hash,
}
}
pub fn to_string_key(&self) -> String {
let vary_str = if self.vary_values.is_empty() {
String::new()
} else {
format!(
"|{}",
self.vary_values
.iter()
.map(|(k, v)| format!("{}:{}", k, v))
.collect::<Vec<_>>()
.join(",")
)
};
let body_str = self
.body_hash
.map(|h| format!("|body:{:016x}", h))
.unwrap_or_default();
if self.query.is_empty() {
format!("{}:{}{}{}", self.method, self.path, body_str, vary_str)
} else {
format!(
"{}:{}?{}{}{}",
self.method, self.path, self.query, body_str, vary_str
)
}
}
}
impl fmt::Display for CacheKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.to_string_key())
}
}
#[derive(Debug, Clone)]
pub struct CachedResponse {
pub response: CachedResponseData,
pub cached_at: Instant,
pub expires_at: Instant,
pub etag: Option<String>,
pub last_modified: Option<SystemTime>,
pub vary: Vec<String>,
pub(crate) base_key: Option<String>,
pub(crate) eviction_seq: u64,
}
#[derive(Debug, Clone)]
pub struct CachedResponseData {
pub status: u16,
pub headers: HashMap<String, String>,
pub body: Vec<u8>,
}
impl CachedResponse {
pub fn new(response: &HttpResponse, ttl: Duration) -> Self {
let now = Instant::now();
let etag = response.headers.get("ETag").cloned();
let last_modified = response
.headers
.get("Last-Modified")
.and_then(|s| httpdate::parse_http_date(s).ok());
let vary = response
.headers
.get("Vary")
.map(|v| v.split(',').map(|s| s.trim().to_lowercase()).collect())
.unwrap_or_default();
Self {
response: CachedResponseData {
status: response.status,
headers: response.headers.clone().into(),
body: response.body.clone(),
},
cached_at: now,
expires_at: now + ttl,
etag,
last_modified,
vary,
base_key: None,
eviction_seq: 0,
}
}
pub fn is_fresh(&self) -> bool {
Instant::now() < self.expires_at
}
pub fn is_stale(&self) -> bool {
!self.is_fresh()
}
pub fn age(&self) -> Duration {
self.cached_at.elapsed()
}
pub fn remaining_ttl(&self) -> Option<Duration> {
let now = Instant::now();
if now < self.expires_at {
Some(self.expires_at - now)
} else {
None
}
}
pub fn to_response(&self) -> HttpResponse {
let mut response = HttpResponse::from_parts(
self.response.status,
self.response.headers.clone(),
self.response.body.clone(),
);
response
.headers
.insert("Age".to_string(), self.age().as_secs().to_string());
response
.headers
.insert("X-Cache".to_string(), "HIT".to_string());
response
}
}
#[derive(Debug)]
pub struct ResponseCache {
config: ResponseCacheConfig,
entries: Arc<RwLock<HashMap<String, CachedResponse>>>,
vary_index: Arc<RwLock<HashMap<String, Vec<String>>>>,
eviction: Mutex<EvictionIndex>,
}
#[derive(Debug, Default)]
struct EvictionIndex {
order: VecDeque<(String, u64)>,
base_key_counts: HashMap<String, usize>,
dead: usize,
next_seq: u64,
}
impl EvictionIndex {
fn next_seq(&mut self) -> u64 {
let seq = self.next_seq;
self.next_seq += 1;
seq
}
fn record_insert(&mut self, key: &str, seq: u64, base_key: &str, replaced: bool) {
self.order.push_back((key.to_string(), seq));
if replaced {
self.dead += 1;
} else {
*self
.base_key_counts
.entry(base_key.to_string())
.or_insert(0) += 1;
}
}
fn record_remove(&mut self, base_key: &str) -> bool {
if let Some(count) = self.base_key_counts.get_mut(base_key) {
*count -= 1;
if *count == 0 {
self.base_key_counts.remove(base_key);
return true;
}
}
false
}
fn base_key_live(&self, base_key: &str) -> bool {
self.base_key_counts.contains_key(base_key)
}
fn compact(&mut self, entries: &HashMap<String, CachedResponse>) {
let mut seen = HashSet::with_capacity(entries.len());
let mut compacted = VecDeque::with_capacity(entries.len());
for (key, seq) in self.order.drain(..) {
if entries.get(&key).is_some_and(|e| e.eviction_seq == seq) && seen.insert(key.clone())
{
compacted.push_back((key, seq));
}
}
self.order = compacted;
self.dead = 0;
}
fn compact_if_needed(&mut self, entries: &HashMap<String, CachedResponse>) {
if self.dead > entries.len() {
self.compact(entries);
}
}
fn clear(&mut self) {
self.order.clear();
self.base_key_counts.clear();
self.dead = 0;
}
}
#[derive(Debug, Clone)]
pub struct ResponseCacheConfig {
pub max_entries: usize,
pub default_ttl: Duration,
pub max_body_size: usize,
pub cacheable_status_codes: Vec<u16>,
pub cacheable_methods: Vec<String>,
}
impl Default for ResponseCacheConfig {
fn default() -> Self {
Self {
max_entries: 1000,
default_ttl: Duration::from_secs(300), max_body_size: 1024 * 1024, cacheable_status_codes: vec![200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501],
cacheable_methods: vec![
"GET".to_string(),
"HEAD".to_string(),
"QUERY".to_string(),
],
}
}
}
impl ResponseCacheConfig {
pub fn new() -> Self {
Self::default()
}
pub fn max_entries(mut self, count: usize) -> Self {
self.max_entries = count;
self
}
pub fn default_ttl(mut self, ttl: Duration) -> Self {
self.default_ttl = ttl;
self
}
pub fn max_body_size(mut self, size: usize) -> Self {
self.max_body_size = size;
self
}
}
impl ResponseCache {
pub fn new() -> Self {
Self::with_config(ResponseCacheConfig::default())
}
pub fn with_config(config: ResponseCacheConfig) -> Self {
Self {
config,
entries: Arc::new(RwLock::new(HashMap::new())),
vary_index: Arc::new(RwLock::new(HashMap::new())),
eviction: Mutex::new(EvictionIndex::default()),
}
}
pub async fn get(&self, request: &HttpRequest) -> Option<HttpResponse> {
let base_key = CacheKey::from_request(request).to_string_key();
let vary_headers = {
let vary_index = self.vary_index.read().await;
vary_index.get(&base_key).cloned().unwrap_or_default()
};
let vary_refs: Vec<&str> = vary_headers.iter().map(String::as_str).collect();
self.get_with_vary(request, &vary_refs).await
}
pub async fn get_with_vary(
&self,
request: &HttpRequest,
vary_headers: &[&str],
) -> Option<HttpResponse> {
let key = CacheKey::from_request_with_vary(request, vary_headers);
let key_str = key.to_string_key();
let entries = self.entries.read().await;
if let Some(cached) = entries.get(&key_str)
&& cached.is_fresh()
{
return Some(cached.to_response());
}
None
}
pub async fn store(&self, request: &HttpRequest, response: &HttpResponse) {
let ttl = response
.headers
.get("Cache-Control")
.map(|h| CacheControl::parse(h))
.and_then(|cc| cc.freshness_lifetime())
.map(Duration::from_secs)
.unwrap_or(self.config.default_ttl);
self.store_with_ttl(request, response, ttl).await
}
pub async fn store_with_ttl(
&self,
request: &HttpRequest,
response: &HttpResponse,
ttl: Duration,
) {
if !self.is_cacheable(request, response) {
return;
}
let vary_headers: Vec<&str> = response
.headers
.get("Vary")
.map(|v| v.split(',').map(|s| s.trim()).collect())
.unwrap_or_default();
let key = CacheKey::from_request_with_vary(request, &vary_headers);
let key_str = key.to_string_key();
let base_key = CacheKey::from_request(request).to_string_key();
let mut cached = CachedResponse::new(response, ttl);
cached.base_key = Some(base_key.clone());
let mut evicted_base_key = None;
{
let mut entries = self.entries.write().await;
if entries.len() >= self.config.max_entries {
evicted_base_key = self.evict_oldest(&mut entries);
}
let mut index = self.eviction.lock().unwrap();
let seq = index.next_seq();
cached.eviction_seq = seq;
let replaced = entries.insert(key_str.clone(), cached).is_some();
index.record_insert(&key_str, seq, &base_key, replaced);
index.compact_if_needed(&entries);
}
let mut vary_index = self.vary_index.write().await;
if let Some(evicted) = evicted_base_key
&& evicted != base_key
{
vary_index.remove(&evicted);
}
let entry = vary_index.entry(base_key).or_default();
for header in vary_headers.iter().map(|s| s.to_string()) {
if !entry.contains(&header) {
entry.push(header);
}
}
}
fn is_cacheable(&self, request: &HttpRequest, response: &HttpResponse) -> bool {
if !self
.config
.cacheable_methods
.contains(&request.method.to_uppercase())
{
return false;
}
if !self
.config
.cacheable_status_codes
.contains(&response.status)
{
return false;
}
if response.body.len() > self.config.max_body_size {
return false;
}
let cache_control = response
.headers
.get("Cache-Control")
.map(|h| CacheControl::parse(h));
if let Some(ref cc) = cache_control
&& (cc.is_no_store() || cc.is_private() || cc.is_no_cache())
{
return false;
}
let has_authorization = request
.headers
.get("Authorization")
.or_else(|| request.headers.get("authorization"))
.is_some();
if has_authorization {
let explicitly_allowed = cache_control.as_ref().is_some_and(|cc| {
cc.is_public() || cc.get_s_maxage().is_some() || cc.is_must_revalidate()
});
if !explicitly_allowed {
return false;
}
}
true
}
fn evict_oldest(&self, entries: &mut HashMap<String, CachedResponse>) -> Option<String> {
let mut index = self.eviction.lock().unwrap();
while let Some((oldest_key, seq)) = index.order.pop_front() {
let is_current = entries
.get(&oldest_key)
.is_some_and(|e| e.eviction_seq == seq);
if is_current {
let removed = entries.remove(&oldest_key);
return match removed.and_then(|r| r.base_key) {
Some(base_key) => index.record_remove(&base_key).then_some(base_key),
None => None,
};
}
index.dead = index.dead.saturating_sub(1);
}
None
}
pub async fn invalidate(&self, request: &HttpRequest) {
let base_key = CacheKey::from_request(request).to_string_key();
let variant_prefix = format!("{}|", base_key);
{
let mut entries = self.entries.write().await;
let mut removed_count = 0usize;
entries.retain(|key, _| {
if key == &base_key || key.starts_with(&variant_prefix) {
removed_count += 1;
false
} else {
true
}
});
let mut index = self.eviction.lock().unwrap();
index.base_key_counts.remove(&base_key);
index.dead += removed_count;
index.compact_if_needed(&entries);
}
let mut vary_index = self.vary_index.write().await;
vary_index.remove(&base_key);
}
pub async fn invalidate_prefix(&self, path_prefix: &str) {
let needle = format!(":{}", path_prefix);
let mut entries = self.entries.write().await;
let mut index = self.eviction.lock().unwrap();
entries.retain(|key, v| {
if key.contains(&needle) {
if let Some(bk) = &v.base_key {
index.record_remove(bk);
}
index.dead += 1;
false
} else {
true
}
});
index.compact_if_needed(&entries);
}
pub async fn clear(&self) {
{
let mut entries = self.entries.write().await;
entries.clear();
self.eviction.lock().unwrap().clear();
}
let mut vary_index = self.vary_index.write().await;
vary_index.clear();
}
pub async fn purge_stale(&self) {
let dead_base_keys = {
let mut entries = self.entries.write().await;
let mut index = self.eviction.lock().unwrap();
let mut removed_base_keys: Vec<String> = Vec::new();
entries.retain(|_, v| {
if v.is_fresh() {
true
} else {
if let Some(bk) = &v.base_key {
removed_base_keys.push(bk.clone());
index.record_remove(bk);
}
index.dead += 1;
false
}
});
removed_base_keys.retain(|bk| !index.base_key_live(bk));
index.compact_if_needed(&entries);
removed_base_keys
};
if !dead_base_keys.is_empty() {
let mut vary_index = self.vary_index.write().await;
for bk in dead_base_keys {
vary_index.remove(&bk);
}
}
}
pub async fn stats(&self) -> CacheStats {
let entries = self.entries.read().await;
let fresh_count = entries.values().filter(|e| e.is_fresh()).count();
let stale_count = entries.len() - fresh_count;
let total_size: usize = entries.values().map(|e| e.response.body.len()).sum();
CacheStats {
total_entries: entries.len(),
fresh_entries: fresh_count,
stale_entries: stale_count,
total_size_bytes: total_size,
max_entries: self.config.max_entries,
}
}
}
impl Default for ResponseCache {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub total_entries: usize,
pub fresh_entries: usize,
pub stale_entries: usize,
pub total_size_bytes: usize,
pub max_entries: usize,
}
impl HttpRequest {
pub fn cache_control(&self) -> Option<CacheControl> {
self.headers
.get("Cache-Control")
.or_else(|| self.headers.get("cache-control"))
.map(|h| CacheControl::parse(h))
}
pub fn allows_cached(&self) -> bool {
if let Some(cc) = self.cache_control() {
!cc.is_no_cache() && !cc.is_no_store()
} else {
true
}
}
pub fn max_stale(&self) -> Option<u64> {
self.cache_control().and_then(|cc| {
cc.directives.iter().find_map(|d| match d {
CacheDirective::MaxStale(secs) => Some(secs.unwrap_or(u64::MAX)),
_ => None,
})
})
}
pub fn cache_key(&self) -> CacheKey {
CacheKey::from_request(self)
}
pub fn cache_key_with_vary(&self, vary_headers: &[&str]) -> CacheKey {
CacheKey::from_request_with_vary(self, vary_headers)
}
}
impl HttpResponse {
pub fn with_cache_control(mut self, cache_control: CacheControl) -> Self {
self.headers
.insert("Cache-Control".to_string(), cache_control.to_header_value());
self
}
pub fn cache_public(self, max_age: Duration) -> Self {
self.with_cache_control(CacheControl::public_max_age(max_age))
}
pub fn cache_private(self, max_age: Duration) -> Self {
self.with_cache_control(CacheControl::private_max_age(max_age))
}
pub fn cache_immutable(self, max_age: Duration) -> Self {
self.with_cache_control(CacheControl::immutable_asset(max_age))
}
pub fn with_vary(mut self, headers: &[&str]) -> Self {
let vary = headers.join(", ");
self.headers.insert("Vary".to_string(), vary);
self
}
pub fn get_cache_control(&self) -> Option<CacheControl> {
self.headers
.get("Cache-Control")
.map(|h| CacheControl::parse(h))
}
pub fn is_cacheable(&self) -> bool {
if let Some(cc) = self.get_cache_control() {
cc.is_cacheable()
} else {
self.status == 200
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_directive_parse() {
assert_eq!(
CacheDirective::parse("public"),
Some(CacheDirective::Public)
);
assert_eq!(
CacheDirective::parse("private"),
Some(CacheDirective::Private)
);
assert_eq!(
CacheDirective::parse("no-store"),
Some(CacheDirective::NoStore)
);
assert_eq!(
CacheDirective::parse("max-age=3600"),
Some(CacheDirective::MaxAge(3600))
);
}
#[test]
fn test_cache_control_parse() {
let cc = CacheControl::parse("public, max-age=3600, must-revalidate");
assert!(cc.is_public());
assert_eq!(cc.get_max_age(), Some(3600));
assert!(cc.is_must_revalidate());
}
#[test]
fn test_cache_control_builder() {
let cc = CacheControl::new()
.public()
.max_age(Duration::from_secs(3600))
.must_revalidate();
assert_eq!(
cc.to_header_value(),
"public, max-age=3600, must-revalidate"
);
}
#[test]
fn test_cache_control_presets() {
let never = CacheControl::never();
assert!(never.is_no_store());
assert!(never.is_no_cache());
let public = CacheControl::public_max_age(Duration::from_secs(3600));
assert!(public.is_public());
assert_eq!(public.get_max_age(), Some(3600));
let immutable = CacheControl::immutable_asset(Duration::from_secs(31536000));
assert!(immutable.is_immutable());
}
#[test]
fn test_cache_control_is_cacheable() {
assert!(CacheControl::public_max_age(Duration::from_secs(3600)).is_cacheable());
assert!(CacheControl::private_max_age(Duration::from_secs(3600)).is_cacheable());
assert!(!CacheControl::never().is_cacheable());
}
#[test]
fn test_cache_key_from_request() {
let mut request = HttpRequest::new("GET".to_string(), "/api/users".to_string());
request
.query_params
.insert("page".to_string(), "1".to_string());
request
.query_params
.insert("limit".to_string(), "10".to_string());
let key = CacheKey::from_request(&request);
assert_eq!(key.method, "GET");
assert_eq!(key.path, "/api/users");
assert!(key.query.contains("limit=10"));
assert!(key.query.contains("page=1"));
}
#[test]
fn test_cache_key_with_vary() {
let mut request = HttpRequest::new("GET".to_string(), "/api/users".to_string());
request
.headers
.insert("Accept".to_string(), "application/json".to_string());
let key = CacheKey::from_request_with_vary(&request, &["Accept"]);
assert_eq!(key.vary_values.len(), 1);
assert_eq!(
key.vary_values[0],
("accept".to_string(), "application/json".to_string())
);
}
#[test]
fn test_cached_response() {
let mut response = HttpResponse::ok();
response.body = b"Hello, World!".to_vec();
response
.headers
.insert("ETag".to_string(), "\"abc123\"".to_string());
let cached = CachedResponse::new(&response, Duration::from_secs(300));
assert!(cached.is_fresh());
assert_eq!(cached.etag, Some("\"abc123\"".to_string()));
}
#[tokio::test]
async fn test_response_cache_store_and_get() {
let cache = ResponseCache::new();
let request = HttpRequest::new("GET".to_string(), "/api/users".to_string());
let mut response = HttpResponse::ok();
response.body = b"cached content".to_vec();
cache.store(&request, &response).await;
let cached = cache.get(&request).await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().body, b"cached content");
}
#[tokio::test]
async fn test_query_method_cached_with_body_in_key() {
let cache = ResponseCache::new();
let mut search_a = HttpRequest::new("QUERY".to_string(), "/search".to_string());
search_a.body = b"name=alice".to_vec();
let mut response_a = HttpResponse::ok();
response_a.body = b"results for alice".to_vec();
cache.store(&search_a, &response_a).await;
let cached = cache.get(&search_a).await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().body, b"results for alice");
let mut search_b = HttpRequest::new("QUERY".to_string(), "/search".to_string());
search_b.body = b"name=bob".to_vec();
assert!(cache.get(&search_b).await.is_none());
let mut response_b = HttpResponse::ok();
response_b.body = b"results for bob".to_vec();
cache.store(&search_b, &response_b).await;
assert_eq!(
cache.get(&search_a).await.unwrap().body,
b"results for alice"
);
assert_eq!(cache.get(&search_b).await.unwrap().body, b"results for bob");
}
#[test]
fn test_query_cache_key_includes_body_hash() {
let mut req_a = HttpRequest::new("QUERY".to_string(), "/search".to_string());
req_a.body = b"a".to_vec();
let mut req_b = HttpRequest::new("QUERY".to_string(), "/search".to_string());
req_b.body = b"b".to_vec();
let key_a = CacheKey::from_request(&req_a);
let key_b = CacheKey::from_request(&req_b);
assert!(key_a.body_hash.is_some());
assert_ne!(key_a, key_b);
assert_ne!(key_a.to_string_key(), key_b.to_string_key());
let mut get_req = HttpRequest::new("GET".to_string(), "/search".to_string());
get_req.body = b"ignored".to_vec();
assert!(CacheKey::from_request(&get_req).body_hash.is_none());
}
#[tokio::test]
async fn test_response_cache_invalidate() {
let cache = ResponseCache::new();
let request = HttpRequest::new("GET".to_string(), "/api/users".to_string());
let response = HttpResponse::ok();
cache.store(&request, &response).await;
assert!(cache.get(&request).await.is_some());
cache.invalidate(&request).await;
assert!(cache.get(&request).await.is_none());
}
#[tokio::test]
async fn test_response_cache_respects_no_store() {
let cache = ResponseCache::new();
let request = HttpRequest::new("GET".to_string(), "/api/users".to_string());
let response = HttpResponse::ok().no_cache();
cache.store(&request, &response).await;
assert!(cache.get(&request).await.is_none());
}
#[tokio::test]
async fn test_response_cache_respects_private() {
let cache = ResponseCache::new();
let request = HttpRequest::new("GET".to_string(), "/api/users".to_string());
let response = HttpResponse::ok().cache_private(Duration::from_secs(300));
cache.store(&request, &response).await;
assert!(cache.get(&request).await.is_none());
}
#[tokio::test]
async fn test_response_cache_respects_no_cache_directive() {
let cache = ResponseCache::new();
let request = HttpRequest::new("GET".to_string(), "/api/users".to_string());
let response = HttpResponse::ok().with_cache_control(CacheControl::new().no_cache());
cache.store(&request, &response).await;
assert!(cache.get(&request).await.is_none());
}
#[tokio::test]
async fn test_response_cache_authorization_not_stored() {
let cache = ResponseCache::new();
let mut request = HttpRequest::new("GET".to_string(), "/api/me".to_string());
request
.headers
.insert("Authorization".to_string(), "Bearer user-a".to_string());
let response = HttpResponse::ok();
cache.store(&request, &response).await;
assert!(cache.get(&request).await.is_none());
}
#[tokio::test]
async fn test_response_cache_authorization_stored_when_public() {
let cache = ResponseCache::new();
let mut request = HttpRequest::new("GET".to_string(), "/api/assets".to_string());
request
.headers
.insert("Authorization".to_string(), "Bearer user-a".to_string());
let response = HttpResponse::ok().cache_public(Duration::from_secs(60));
cache.store(&request, &response).await;
assert!(cache.get(&request).await.is_some());
}
#[tokio::test]
async fn test_response_cache_ttl_from_max_age() {
let cache = ResponseCache::new();
let request = HttpRequest::new("GET".to_string(), "/api/users".to_string());
let response = HttpResponse::ok().cache_public(Duration::from_secs(0));
cache.store(&request, &response).await;
assert!(cache.get(&request).await.is_none());
}
#[tokio::test]
async fn test_response_cache_vary_two_phase_lookup() {
let cache = ResponseCache::new();
let mut request = HttpRequest::new("GET".to_string(), "/api/data".to_string());
request
.headers
.insert("Accept".to_string(), "application/json".to_string());
let mut response = HttpResponse::ok().with_vary(&["Accept"]);
response.body = b"json".to_vec();
cache.store(&request, &response).await;
let hit = cache.get(&request).await;
assert!(hit.is_some());
assert_eq!(hit.unwrap().body, b"json");
let mut other = HttpRequest::new("GET".to_string(), "/api/data".to_string());
other
.headers
.insert("Accept".to_string(), "text/xml".to_string());
assert!(cache.get(&other).await.is_none());
}
#[tokio::test]
async fn test_response_cache_invalidate_removes_vary_variants() {
let cache = ResponseCache::new();
let mut request = HttpRequest::new("GET".to_string(), "/api/data".to_string());
request
.headers
.insert("Accept".to_string(), "application/json".to_string());
let response = HttpResponse::ok().with_vary(&["Accept"]);
cache.store(&request, &response).await;
assert!(cache.get(&request).await.is_some());
let plain = HttpRequest::new("GET".to_string(), "/api/data".to_string());
cache.invalidate(&plain).await;
assert!(cache.get(&request).await.is_none());
}
#[test]
fn test_response_cache_control_methods() {
let response = HttpResponse::ok().cache_public(Duration::from_secs(3600));
let cc = response.get_cache_control().unwrap();
assert!(cc.is_public());
assert_eq!(cc.get_max_age(), Some(3600));
}
#[test]
fn test_response_with_vary() {
let response = HttpResponse::ok().with_vary(&["Accept", "Accept-Encoding"]);
assert_eq!(
response.headers.get("Vary"),
Some(&"Accept, Accept-Encoding".to_string())
);
}
#[test]
fn test_request_allows_cached() {
let request = HttpRequest::new("GET".to_string(), "/api/users".to_string());
assert!(request.allows_cached());
let mut request_no_cache = HttpRequest::new("GET".to_string(), "/api/users".to_string());
request_no_cache
.headers
.insert("Cache-Control".to_string(), "no-cache".to_string());
assert!(!request_no_cache.allows_cached());
}
#[tokio::test]
async fn test_vary_index_bounded_after_eviction() {
let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(8));
for i in 0..100 {
let mut req = HttpRequest::new("QUERY".to_string(), "/search".to_string());
req.body = format!("q={}", i).into_bytes();
let mut resp = HttpResponse::ok();
resp.body = format!("result {}", i).into_bytes();
cache.store(&req, &resp).await;
}
let entries_len = cache.entries.read().await.len();
let vary_len = cache.vary_index.read().await.len();
assert!(
entries_len <= 8,
"entries ({}) exceeded max_entries",
entries_len
);
assert!(
vary_len <= entries_len,
"vary_index ({}) must not exceed entries ({}) after eviction",
vary_len,
entries_len,
);
}
#[tokio::test]
async fn test_evicts_in_insertion_order() {
let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(3));
for i in 0..3 {
let req = HttpRequest::new("GET".to_string(), format!("/p{}", i));
cache.store(&req, &HttpResponse::ok()).await;
}
for i in 0..3 {
let req = HttpRequest::new("GET".to_string(), format!("/p{}", i));
assert!(cache.get(&req).await.is_some(), "/p{} should be cached", i);
}
let req = HttpRequest::new("GET".to_string(), "/p3".to_string());
cache.store(&req, &HttpResponse::ok()).await;
let p0 = HttpRequest::new("GET".to_string(), "/p0".to_string());
assert!(
cache.get(&p0).await.is_none(),
"oldest entry (/p0) must be evicted first"
);
for i in 1..4 {
let req = HttpRequest::new("GET".to_string(), format!("/p{}", i));
assert!(cache.get(&req).await.is_some(), "/p{} must remain", i);
}
let entries_len = cache.entries.read().await.len();
let vary_len = cache.vary_index.read().await.len();
assert!(entries_len <= 3);
assert!(vary_len <= entries_len);
}
#[tokio::test]
async fn test_eviction_refresh_resets_recency() {
async fn store(cache: &ResponseCache, path: &str, body: &[u8]) {
let req = HttpRequest::new("GET".to_string(), path.to_string());
let mut resp = HttpResponse::ok();
resp.body = body.to_vec();
cache.store(&req, &resp).await;
}
async fn present(cache: &ResponseCache, path: &str) -> bool {
cache
.get(&HttpRequest::new("GET".to_string(), path.to_string()))
.await
.is_some()
}
let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(3));
store(&cache, "/a", b"a").await;
store(&cache, "/b", b"b").await;
store(&cache, "/a", b"a2").await; store(&cache, "/c", b"c").await;
store(&cache, "/d", b"d").await;
assert!(
!present(&cache, "/b").await,
"B (the oldest untouched entry) must be evicted"
);
assert!(
present(&cache, "/a").await,
"A was refreshed under capacity and must survive"
);
assert!(present(&cache, "/c").await, "/c must remain");
assert!(present(&cache, "/d").await, "/d was just stored");
}
#[tokio::test]
async fn test_purge_stale_shrinks_vary_index() {
let cache = ResponseCache::new();
let mut stale_req = HttpRequest::new("QUERY".to_string(), "/search".to_string());
stale_req.body = b"q=stale".to_vec();
let mut fresh_req = HttpRequest::new("QUERY".to_string(), "/search".to_string());
fresh_req.body = b"q=fresh".to_vec();
let resp = HttpResponse::ok();
cache
.store_with_ttl(&stale_req, &resp, Duration::from_secs(0))
.await;
cache
.store_with_ttl(&fresh_req, &resp, Duration::from_secs(300))
.await;
assert_eq!(cache.vary_index.read().await.len(), 2);
tokio::time::sleep(Duration::from_millis(5)).await;
cache.purge_stale().await;
assert_eq!(
cache.entries.read().await.len(),
1,
"only the fresh entry should survive purge",
);
assert_eq!(
cache.vary_index.read().await.len(),
1,
"vary_index must shrink in lockstep with purged entries",
);
}
#[tokio::test]
async fn test_eviction_order_bounded_under_store_invalidate_churn() {
let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(10_000));
for i in 0..2000 {
let req = HttpRequest::new("GET".to_string(), format!("/churn/{}", i % 5));
cache.store(&req, &HttpResponse::ok()).await;
cache.invalidate(&req).await;
}
let live = cache.entries.read().await.len();
let order_len = cache.eviction.lock().unwrap().order.len();
assert!(
order_len <= 2 * live + 16,
"order ({}) grew unbounded relative to live entries ({}) under store/invalidate churn",
order_len,
live
);
}
#[tokio::test]
async fn test_eviction_order_bounded_under_repeated_restore() {
let cache = ResponseCache::with_config(ResponseCacheConfig::new().max_entries(10_000));
let req = HttpRequest::new("GET".to_string(), "/hot".to_string());
for i in 0..2000 {
let mut resp = HttpResponse::ok();
resp.body = format!("v{}", i).into_bytes();
cache.store(&req, &resp).await;
}
let live = cache.entries.read().await.len();
assert_eq!(live, 1, "only the latest write for the key should be live");
let order_len = cache.eviction.lock().unwrap().order.len();
assert!(
order_len <= 2 * live + 16,
"order ({}) grew unbounded across repeated re-stores of one key (live={})",
order_len,
live
);
let cached = cache.get(&req).await;
assert!(cached.is_some());
assert_eq!(cached.unwrap().body, b"v1999");
}
#[tokio::test]
async fn test_vary_index_merges_distinct_vary_sets() {
let cache = ResponseCache::new();
let mut req_accept = HttpRequest::new("GET".to_string(), "/api/data".to_string());
req_accept
.headers
.insert("Accept".to_string(), "application/json".to_string());
let mut resp_accept = HttpResponse::ok().with_vary(&["Accept"]);
resp_accept.body = b"json-body".to_vec();
cache.store(&req_accept, &resp_accept).await;
let mut req_enc = HttpRequest::new("GET".to_string(), "/api/data".to_string());
req_enc
.headers
.insert("Accept-Encoding".to_string(), "gzip".to_string());
let mut resp_enc = HttpResponse::ok().with_vary(&["Accept-Encoding"]);
resp_enc.body = b"gzip-body".to_vec();
cache.store(&req_enc, &resp_enc).await;
let hit_accept = cache.get(&req_accept).await;
assert!(
hit_accept.is_some(),
"Accept variant lost after second store"
);
assert_eq!(hit_accept.unwrap().body, b"json-body");
let hit_enc = cache.get(&req_enc).await;
assert!(
hit_enc.is_some(),
"Accept-Encoding variant lost after second store",
);
assert_eq!(hit_enc.unwrap().body, b"gzip-body");
}
}