use crate::{HttpRequest, HttpResponse};
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
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)>,
}
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));
Self {
method: request.method.to_uppercase(),
path: request.path.clone(),
query,
vary_values,
}
}
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(",")
)
};
if self.query.is_empty() {
format!("{}:{}{}", self.method, self.path, vary_str)
} else {
format!("{}:{}?{}{}", self.method, self.path, self.query, 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>,
}
#[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(),
body: response.body.clone(),
},
cached_at: now,
expires_at: now + ttl,
etag,
last_modified,
vary,
}
}
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>>>,
}
#[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()],
}
}
}
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())),
}
}
pub async fn get(&self, request: &HttpRequest) -> Option<HttpResponse> {
self.get_with_vary(request, &[]).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) {
if cached.is_fresh() {
return Some(cached.to_response());
}
}
None
}
pub async fn store(&self, request: &HttpRequest, response: &HttpResponse) {
self.store_with_ttl(request, response, self.config.default_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 cached = CachedResponse::new(response, ttl);
let mut entries = self.entries.write().await;
if entries.len() >= self.config.max_entries {
self.evict_oldest(&mut entries);
}
entries.insert(key_str, cached);
}
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;
}
if let Some(cc_header) = response.headers.get("Cache-Control") {
let cc = CacheControl::parse(cc_header);
if cc.is_no_store() {
return false;
}
}
true
}
fn evict_oldest(&self, entries: &mut HashMap<String, CachedResponse>) {
if let Some((oldest_key, _)) = entries
.iter()
.min_by_key(|(_, v)| v.cached_at)
.map(|(k, v)| (k.clone(), v.clone()))
{
entries.remove(&oldest_key);
}
}
pub async fn invalidate(&self, request: &HttpRequest) {
let key = CacheKey::from_request(request);
let key_str = key.to_string_key();
let mut entries = self.entries.write().await;
entries.remove(&key_str);
}
pub async fn invalidate_prefix(&self, path_prefix: &str) {
let mut entries = self.entries.write().await;
entries.retain(|key, _| !key.contains(&format!(":{}", path_prefix)));
}
pub async fn clear(&self) {
let mut entries = self.entries.write().await;
entries.clear();
}
pub async fn purge_stale(&self) {
let mut entries = self.entries.write().await;
entries.retain(|_, v| v.is_fresh());
}
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 no_cache(self) -> Self {
self.with_cache_control(CacheControl::never())
}
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_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());
}
#[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());
}
}