use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CdnProvider {
Cloudflare,
Fastly,
CloudFront,
Akamai,
Generic,
}
impl CdnProvider {
pub fn cache_tag_header(&self) -> &'static str {
match self {
Self::Cloudflare => "Cache-Tag",
Self::Fastly => "Surrogate-Key",
Self::CloudFront => "x-amz-meta-cache-tags",
Self::Akamai => "Edge-Cache-Tag",
Self::Generic => "X-Cache-Tags",
}
}
pub fn purge_api_pattern(&self) -> &'static str {
match self {
Self::Cloudflare => "https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache",
Self::Fastly => "https://api.fastly.com/service/{service}/purge/{key}",
Self::CloudFront => "cloudfront:CreateInvalidation",
Self::Akamai => "https://api.akamai.com/ccu/v3/invalidate/tag/{network}",
Self::Generic => "/purge",
}
}
pub fn supports_cache_tags(&self) -> bool {
matches!(
self,
Self::Cloudflare | Self::Fastly | Self::Akamai | Self::Generic
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CacheDirective {
Public,
Private,
NoCache,
NoStore,
Immutable,
MustRevalidate,
ProxyRevalidate,
}
impl CacheDirective {
pub fn as_str(&self) -> &'static str {
match self {
Self::Public => "public",
Self::Private => "private",
Self::NoCache => "no-cache",
Self::NoStore => "no-store",
Self::Immutable => "immutable",
Self::MustRevalidate => "must-revalidate",
Self::ProxyRevalidate => "proxy-revalidate",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachePolicy {
pub directives: Vec<CacheDirective>,
pub max_age: Option<Duration>,
pub shared_max_age: Option<Duration>,
pub stale_while_revalidate: Option<Duration>,
pub stale_if_error: Option<Duration>,
pub cache_tags: Vec<String>,
pub vary_headers: Vec<String>,
pub enable_etag: bool,
}
impl CachePolicy {
pub fn new() -> Self {
Self {
directives: Vec::new(),
max_age: None,
shared_max_age: None,
stale_while_revalidate: None,
stale_if_error: None,
cache_tags: Vec::new(),
vary_headers: Vec::new(),
enable_etag: false,
}
}
pub fn public(max_age: Duration) -> Self {
Self {
directives: vec![CacheDirective::Public],
max_age: Some(max_age),
shared_max_age: None,
stale_while_revalidate: None,
stale_if_error: None,
cache_tags: Vec::new(),
vary_headers: vec!["Accept-Encoding".to_string()],
enable_etag: true,
}
}
pub fn private(max_age: Duration) -> Self {
Self {
directives: vec![CacheDirective::Private],
max_age: Some(max_age),
shared_max_age: None,
stale_while_revalidate: None,
stale_if_error: None,
cache_tags: Vec::new(),
vary_headers: vec!["Authorization".to_string(), "Accept-Encoding".to_string()],
enable_etag: true,
}
}
pub fn no_cache() -> Self {
Self {
directives: vec![CacheDirective::NoCache, CacheDirective::NoStore],
max_age: Some(Duration::from_secs(0)),
shared_max_age: None,
stale_while_revalidate: None,
stale_if_error: None,
cache_tags: Vec::new(),
vary_headers: Vec::new(),
enable_etag: false,
}
}
pub fn immutable(max_age: Duration) -> Self {
Self {
directives: vec![CacheDirective::Public, CacheDirective::Immutable],
max_age: Some(max_age),
shared_max_age: Some(max_age),
stale_while_revalidate: None,
stale_if_error: None,
cache_tags: Vec::new(),
vary_headers: vec!["Accept-Encoding".to_string()],
enable_etag: false, }
}
pub fn add_tag(&mut self, tag: String) {
if !self.cache_tags.contains(&tag) {
self.cache_tags.push(tag);
}
}
pub fn add_vary(&mut self, header: String) {
if !self.vary_headers.contains(&header) {
self.vary_headers.push(header);
}
}
pub fn to_cache_control(&self) -> String {
let mut parts = Vec::new();
for directive in &self.directives {
parts.push(directive.as_str().to_string());
}
if let Some(max_age) = self.max_age {
parts.push(format!("max-age={}", max_age.as_secs()));
}
if let Some(shared_max_age) = self.shared_max_age {
parts.push(format!("s-maxage={}", shared_max_age.as_secs()));
}
if let Some(stale) = self.stale_while_revalidate {
parts.push(format!("stale-while-revalidate={}", stale.as_secs()));
}
if let Some(stale) = self.stale_if_error {
parts.push(format!("stale-if-error={}", stale.as_secs()));
}
parts.join(", ")
}
pub fn to_headers(&self) -> HashMap<String, String> {
let mut headers = HashMap::new();
let cache_control = self.to_cache_control();
if !cache_control.is_empty() {
headers.insert("Cache-Control".to_string(), cache_control);
}
if !self.vary_headers.is_empty() {
headers.insert("Vary".to_string(), self.vary_headers.join(", "));
}
headers
}
pub fn is_cacheable(&self) -> bool {
!self.directives.contains(&CacheDirective::NoStore)
&& !self.directives.contains(&CacheDirective::NoCache)
}
}
impl Default for CachePolicy {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryCacheability {
pub cacheable: bool,
pub policy: CachePolicy,
pub reasons: Vec<String>,
pub entity_types: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct EdgeCache {
provider: CdnProvider,
default_policy: CachePolicy,
query_policies: HashMap<String, CachePolicy>,
}
impl EdgeCache {
pub fn new(provider: CdnProvider) -> Self {
Self {
provider,
default_policy: CachePolicy::public(Duration::from_secs(300)), query_policies: HashMap::new(),
}
}
pub fn set_default_policy(&mut self, policy: CachePolicy) {
self.default_policy = policy;
}
pub fn set_query_policy(&mut self, query_pattern: String, policy: CachePolicy) {
self.query_policies.insert(query_pattern, policy);
}
pub fn analyze_query(&self, query: &str) -> QueryCacheability {
let mut reasons = Vec::new();
let mut entity_types = Vec::new();
if query.trim().starts_with("mutation") {
return QueryCacheability {
cacheable: false,
policy: CachePolicy::no_cache(),
reasons: vec!["Mutations are not cacheable".to_string()],
entity_types,
};
}
if query.trim().starts_with("subscription") {
return QueryCacheability {
cacheable: false,
policy: CachePolicy::no_cache(),
reasons: vec!["Subscriptions are not cacheable".to_string()],
entity_types,
};
}
entity_types = self.extract_entity_types(query);
let mut policy = self.default_policy.clone();
for entity_type in &entity_types {
policy.add_tag(entity_type.clone());
}
if self.requires_authentication(query) {
policy.directives = vec![CacheDirective::Private];
policy.add_vary("Authorization".to_string());
reasons.push("Query requires authentication - using private cache".to_string());
} else {
reasons.push("Public cache policy applied".to_string());
}
if self.has_volatile_fields(query) {
policy.max_age = Some(Duration::from_secs(60)); reasons.push("Query contains volatile fields - shorter TTL".to_string());
}
QueryCacheability {
cacheable: true,
policy,
reasons,
entity_types,
}
}
fn extract_entity_types(&self, query: &str) -> Vec<String> {
let mut types = Vec::new();
for word in query.split_whitespace() {
let clean = word.trim_matches(|c: char| !c.is_alphanumeric());
if !clean.is_empty()
&& clean
.chars()
.next()
.expect("clean should not be empty after non-empty check")
.is_uppercase()
&& !["Query", "Mutation", "Subscription"].contains(&clean)
{
types.push(clean.to_string());
}
}
types
}
fn requires_authentication(&self, query: &str) -> bool {
let auth_indicators = ["currentUser", "me", "myProfile", "private"];
auth_indicators
.iter()
.any(|&indicator| query.contains(indicator))
}
fn has_volatile_fields(&self, query: &str) -> bool {
let volatile_fields = ["timestamp", "now", "current", "random", "live"];
volatile_fields.iter().any(|&field| query.contains(field))
}
pub fn generate_etag(response: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(response.as_bytes());
let result = hasher.finalize();
format!("\"{}\"", hex::encode(&result[..16])) }
pub fn etag_matches(etag: &str, if_none_match: &str) -> bool {
if_none_match
.split(',')
.map(|s| s.trim())
.any(|tag| tag == etag || tag == "*")
}
pub fn create_purge_request(&self, tags: Vec<String>) -> PurgeRequest {
PurgeRequest {
provider: self.provider,
tags,
timestamp: std::time::SystemTime::now(),
}
}
pub fn provider(&self) -> CdnProvider {
self.provider
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PurgeRequest {
pub provider: CdnProvider,
pub tags: Vec<String>,
pub timestamp: std::time::SystemTime,
}
impl PurgeRequest {
pub fn to_api_request(&self, _credentials: &CdnCredentials) -> Result<String, String> {
match self.provider {
CdnProvider::Cloudflare => {
let tags_json = serde_json::to_string(&self.tags)
.map_err(|e| format!("Failed to serialize tags: {}", e))?;
Ok(format!(r#"{{"tags":{}}}"#, tags_json))
}
CdnProvider::Fastly => {
Ok(self.tags.join(","))
}
CdnProvider::CloudFront => {
let paths: Vec<String> = self.tags.iter().map(|t| format!("/*{}*", t)).collect();
let paths_json = serde_json::to_string(&paths)
.map_err(|e| format!("Failed to serialize paths: {}", e))?;
Ok(format!(
r#"{{"Paths":{{"Quantity":{},"Items":{}}}}}"#,
paths.len(),
paths_json
))
}
CdnProvider::Akamai => {
let tags_json = serde_json::to_string(&self.tags)
.map_err(|e| format!("Failed to serialize tags: {}", e))?;
Ok(format!(r#"{{"objects":{}}}"#, tags_json))
}
CdnProvider::Generic => {
let tags_json = serde_json::to_string(&self.tags)
.map_err(|e| format!("Failed to serialize tags: {}", e))?;
Ok(tags_json)
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CdnCredentials {
pub api_key: String,
pub zone_or_service_id: Option<String>,
pub additional: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CacheStats {
pub total_requests: u64,
pub cache_hits: u64,
pub cache_misses: u64,
pub stale_hits: u64,
pub purge_requests: u64,
pub bytes_cached: u64,
}
impl CacheStats {
pub fn hit_rate(&self) -> f64 {
if self.total_requests == 0 {
0.0
} else {
(self.cache_hits as f64) / (self.total_requests as f64)
}
}
pub fn miss_rate(&self) -> f64 {
if self.total_requests == 0 {
0.0
} else {
(self.cache_misses as f64) / (self.total_requests as f64)
}
}
pub fn record_hit(&mut self, bytes: u64) {
self.total_requests += 1;
self.cache_hits += 1;
self.bytes_cached += bytes;
}
pub fn record_miss(&mut self) {
self.total_requests += 1;
self.cache_misses += 1;
}
pub fn record_stale_hit(&mut self, bytes: u64) {
self.total_requests += 1;
self.stale_hits += 1;
self.bytes_cached += bytes;
}
pub fn record_purge(&mut self) {
self.purge_requests += 1;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cdn_provider_cache_tag_header() {
assert_eq!(CdnProvider::Cloudflare.cache_tag_header(), "Cache-Tag");
assert_eq!(CdnProvider::Fastly.cache_tag_header(), "Surrogate-Key");
assert_eq!(
CdnProvider::CloudFront.cache_tag_header(),
"x-amz-meta-cache-tags"
);
assert_eq!(CdnProvider::Akamai.cache_tag_header(), "Edge-Cache-Tag");
}
#[test]
fn test_cdn_provider_supports_cache_tags() {
assert!(CdnProvider::Cloudflare.supports_cache_tags());
assert!(CdnProvider::Fastly.supports_cache_tags());
assert!(!CdnProvider::CloudFront.supports_cache_tags());
assert!(CdnProvider::Akamai.supports_cache_tags());
}
#[test]
fn test_cache_directive_as_str() {
assert_eq!(CacheDirective::Public.as_str(), "public");
assert_eq!(CacheDirective::Private.as_str(), "private");
assert_eq!(CacheDirective::NoCache.as_str(), "no-cache");
assert_eq!(CacheDirective::Immutable.as_str(), "immutable");
}
#[test]
fn test_cache_policy_public() {
let policy = CachePolicy::public(Duration::from_secs(300));
assert!(policy.directives.contains(&CacheDirective::Public));
assert_eq!(policy.max_age, Some(Duration::from_secs(300)));
assert!(policy.enable_etag);
}
#[test]
fn test_cache_policy_private() {
let policy = CachePolicy::private(Duration::from_secs(60));
assert!(policy.directives.contains(&CacheDirective::Private));
assert_eq!(policy.max_age, Some(Duration::from_secs(60)));
assert!(policy.vary_headers.contains(&"Authorization".to_string()));
}
#[test]
fn test_cache_policy_no_cache() {
let policy = CachePolicy::no_cache();
assert!(policy.directives.contains(&CacheDirective::NoCache));
assert!(policy.directives.contains(&CacheDirective::NoStore));
assert!(!policy.enable_etag);
}
#[test]
fn test_cache_policy_immutable() {
let policy = CachePolicy::immutable(Duration::from_secs(31536000));
assert!(policy.directives.contains(&CacheDirective::Immutable));
assert_eq!(policy.max_age, Some(Duration::from_secs(31536000)));
assert!(!policy.enable_etag); }
#[test]
fn test_cache_policy_to_cache_control() {
let policy = CachePolicy::public(Duration::from_secs(300));
let cache_control = policy.to_cache_control();
assert!(cache_control.contains("public"));
assert!(cache_control.contains("max-age=300"));
}
#[test]
fn test_cache_policy_with_stale_while_revalidate() {
let mut policy = CachePolicy::public(Duration::from_secs(300));
policy.stale_while_revalidate = Some(Duration::from_secs(60));
let cache_control = policy.to_cache_control();
assert!(cache_control.contains("stale-while-revalidate=60"));
}
#[test]
fn test_cache_policy_add_tag() {
let mut policy = CachePolicy::new();
policy.add_tag("User".to_string());
policy.add_tag("Post".to_string());
policy.add_tag("User".to_string());
assert_eq!(policy.cache_tags.len(), 2);
assert!(policy.cache_tags.contains(&"User".to_string()));
assert!(policy.cache_tags.contains(&"Post".to_string()));
}
#[test]
fn test_cache_policy_is_cacheable() {
let public_policy = CachePolicy::public(Duration::from_secs(300));
assert!(public_policy.is_cacheable());
let no_cache_policy = CachePolicy::no_cache();
assert!(!no_cache_policy.is_cacheable());
}
#[test]
fn test_edge_cache_analyze_query_mutation() {
let cache = EdgeCache::new(CdnProvider::Cloudflare);
let result = cache.analyze_query("mutation { updateUser(id: 1) { name } }");
assert!(!result.cacheable);
assert!(!result.policy.is_cacheable());
}
#[test]
fn test_edge_cache_analyze_query_subscription() {
let cache = EdgeCache::new(CdnProvider::Fastly);
let result = cache.analyze_query("subscription { userUpdated { id name } }");
assert!(!result.cacheable);
}
#[test]
fn test_edge_cache_analyze_query_public() {
let cache = EdgeCache::new(CdnProvider::Cloudflare);
let result = cache.analyze_query("{ posts { title author } }");
assert!(result.cacheable);
assert!(result.policy.is_cacheable());
}
#[test]
fn test_edge_cache_analyze_query_authenticated() {
let cache = EdgeCache::new(CdnProvider::Cloudflare);
let result = cache.analyze_query("{ currentUser { email } }");
assert!(result.cacheable);
assert!(result.policy.directives.contains(&CacheDirective::Private));
}
#[test]
fn test_edge_cache_extract_entity_types() {
let cache = EdgeCache::new(CdnProvider::Cloudflare);
let types = cache.extract_entity_types("{ User { name } Post { title } }");
assert!(types.contains(&"User".to_string()));
assert!(types.contains(&"Post".to_string()));
}
#[test]
fn test_edge_cache_requires_authentication() {
let cache = EdgeCache::new(CdnProvider::Cloudflare);
assert!(cache.requires_authentication("{ currentUser { name } }"));
assert!(cache.requires_authentication("{ me { email } }"));
assert!(!cache.requires_authentication("{ posts { title } }"));
}
#[test]
fn test_edge_cache_has_volatile_fields() {
let cache = EdgeCache::new(CdnProvider::Cloudflare);
assert!(cache.has_volatile_fields("{ posts { timestamp } }"));
assert!(cache.has_volatile_fields("{ stats { current } }"));
assert!(!cache.has_volatile_fields("{ posts { title } }"));
}
#[test]
fn test_edge_cache_generate_etag() {
let response1 = r#"{"data":{"user":{"name":"Alice"}}}"#;
let response2 = r#"{"data":{"user":{"name":"Bob"}}}"#;
let etag1 = EdgeCache::generate_etag(response1);
let etag2 = EdgeCache::generate_etag(response2);
assert_ne!(etag1, etag2);
assert!(etag1.starts_with('"'));
assert!(etag1.ends_with('"'));
assert_eq!(etag1, EdgeCache::generate_etag(response1));
}
#[test]
fn test_edge_cache_etag_matches() {
let etag = r#""abc123""#;
assert!(EdgeCache::etag_matches(etag, r#""abc123""#));
assert!(EdgeCache::etag_matches(etag, "*"));
assert!(EdgeCache::etag_matches(etag, r#""abc123", "def456""#));
assert!(!EdgeCache::etag_matches(etag, r#""def456""#));
}
#[test]
fn test_purge_request_cloudflare() {
let purge = PurgeRequest {
provider: CdnProvider::Cloudflare,
tags: vec!["User".to_string(), "Post".to_string()],
timestamp: std::time::SystemTime::now(),
};
let credentials = CdnCredentials {
api_key: "test".to_string(),
zone_or_service_id: Some("zone123".to_string()),
additional: HashMap::new(),
};
let request = purge.to_api_request(&credentials).expect("should succeed");
assert!(request.contains("tags"));
assert!(request.contains("User"));
}
#[test]
fn test_cache_stats_hit_rate() {
let mut stats = CacheStats::default();
stats.record_hit(100);
stats.record_hit(200);
stats.record_miss();
assert_eq!(stats.total_requests, 3);
assert_eq!(stats.cache_hits, 2);
assert_eq!(stats.cache_misses, 1);
assert!((stats.hit_rate() - 0.666).abs() < 0.01);
}
#[test]
fn test_cache_stats_miss_rate() {
let mut stats = CacheStats::default();
stats.record_hit(100);
stats.record_miss();
stats.record_miss();
assert_eq!(stats.total_requests, 3);
assert!((stats.miss_rate() - 0.666).abs() < 0.01);
}
#[test]
fn test_cache_stats_empty() {
let stats = CacheStats::default();
assert_eq!(stats.hit_rate(), 0.0);
assert_eq!(stats.miss_rate(), 0.0);
}
}