1use serde::{Deserialize, Serialize};
44use std::collections::HashMap;
45use std::time::Duration;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub enum CdnProvider {
50 Cloudflare,
52 Fastly,
54 CloudFront,
56 Akamai,
58 Generic,
60}
61
62impl CdnProvider {
63 pub fn cache_tag_header(&self) -> &'static str {
65 match self {
66 Self::Cloudflare => "Cache-Tag",
67 Self::Fastly => "Surrogate-Key",
68 Self::CloudFront => "x-amz-meta-cache-tags",
69 Self::Akamai => "Edge-Cache-Tag",
70 Self::Generic => "X-Cache-Tags",
71 }
72 }
73
74 pub fn purge_api_pattern(&self) -> &'static str {
76 match self {
77 Self::Cloudflare => "https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache",
78 Self::Fastly => "https://api.fastly.com/service/{service}/purge/{key}",
79 Self::CloudFront => "cloudfront:CreateInvalidation",
80 Self::Akamai => "https://api.akamai.com/ccu/v3/invalidate/tag/{network}",
81 Self::Generic => "/purge",
82 }
83 }
84
85 pub fn supports_cache_tags(&self) -> bool {
87 matches!(
88 self,
89 Self::Cloudflare | Self::Fastly | Self::Akamai | Self::Generic
90 )
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96pub enum CacheDirective {
97 Public,
99 Private,
101 NoCache,
103 NoStore,
105 Immutable,
107 MustRevalidate,
109 ProxyRevalidate,
111}
112
113impl CacheDirective {
114 pub fn as_str(&self) -> &'static str {
116 match self {
117 Self::Public => "public",
118 Self::Private => "private",
119 Self::NoCache => "no-cache",
120 Self::NoStore => "no-store",
121 Self::Immutable => "immutable",
122 Self::MustRevalidate => "must-revalidate",
123 Self::ProxyRevalidate => "proxy-revalidate",
124 }
125 }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct CachePolicy {
131 pub directives: Vec<CacheDirective>,
133 pub max_age: Option<Duration>,
135 pub shared_max_age: Option<Duration>,
137 pub stale_while_revalidate: Option<Duration>,
139 pub stale_if_error: Option<Duration>,
141 pub cache_tags: Vec<String>,
143 pub vary_headers: Vec<String>,
145 pub enable_etag: bool,
147}
148
149impl CachePolicy {
150 pub fn new() -> Self {
152 Self {
153 directives: Vec::new(),
154 max_age: None,
155 shared_max_age: None,
156 stale_while_revalidate: None,
157 stale_if_error: None,
158 cache_tags: Vec::new(),
159 vary_headers: Vec::new(),
160 enable_etag: false,
161 }
162 }
163
164 pub fn public(max_age: Duration) -> Self {
166 Self {
167 directives: vec![CacheDirective::Public],
168 max_age: Some(max_age),
169 shared_max_age: None,
170 stale_while_revalidate: None,
171 stale_if_error: None,
172 cache_tags: Vec::new(),
173 vary_headers: vec!["Accept-Encoding".to_string()],
174 enable_etag: true,
175 }
176 }
177
178 pub fn private(max_age: Duration) -> Self {
180 Self {
181 directives: vec![CacheDirective::Private],
182 max_age: Some(max_age),
183 shared_max_age: None,
184 stale_while_revalidate: None,
185 stale_if_error: None,
186 cache_tags: Vec::new(),
187 vary_headers: vec!["Authorization".to_string(), "Accept-Encoding".to_string()],
188 enable_etag: true,
189 }
190 }
191
192 pub fn no_cache() -> Self {
194 Self {
195 directives: vec![CacheDirective::NoCache, CacheDirective::NoStore],
196 max_age: Some(Duration::from_secs(0)),
197 shared_max_age: None,
198 stale_while_revalidate: None,
199 stale_if_error: None,
200 cache_tags: Vec::new(),
201 vary_headers: Vec::new(),
202 enable_etag: false,
203 }
204 }
205
206 pub fn immutable(max_age: Duration) -> Self {
208 Self {
209 directives: vec![CacheDirective::Public, CacheDirective::Immutable],
210 max_age: Some(max_age),
211 shared_max_age: Some(max_age),
212 stale_while_revalidate: None,
213 stale_if_error: None,
214 cache_tags: Vec::new(),
215 vary_headers: vec!["Accept-Encoding".to_string()],
216 enable_etag: false, }
218 }
219
220 pub fn add_tag(&mut self, tag: String) {
222 if !self.cache_tags.contains(&tag) {
223 self.cache_tags.push(tag);
224 }
225 }
226
227 pub fn add_vary(&mut self, header: String) {
229 if !self.vary_headers.contains(&header) {
230 self.vary_headers.push(header);
231 }
232 }
233
234 pub fn to_cache_control(&self) -> String {
236 let mut parts = Vec::new();
237
238 for directive in &self.directives {
240 parts.push(directive.as_str().to_string());
241 }
242
243 if let Some(max_age) = self.max_age {
245 parts.push(format!("max-age={}", max_age.as_secs()));
246 }
247
248 if let Some(shared_max_age) = self.shared_max_age {
250 parts.push(format!("s-maxage={}", shared_max_age.as_secs()));
251 }
252
253 if let Some(stale) = self.stale_while_revalidate {
255 parts.push(format!("stale-while-revalidate={}", stale.as_secs()));
256 }
257
258 if let Some(stale) = self.stale_if_error {
260 parts.push(format!("stale-if-error={}", stale.as_secs()));
261 }
262
263 parts.join(", ")
264 }
265
266 pub fn to_headers(&self) -> HashMap<String, String> {
268 let mut headers = HashMap::new();
269
270 let cache_control = self.to_cache_control();
272 if !cache_control.is_empty() {
273 headers.insert("Cache-Control".to_string(), cache_control);
274 }
275
276 if !self.vary_headers.is_empty() {
278 headers.insert("Vary".to_string(), self.vary_headers.join(", "));
279 }
280
281 headers
282 }
283
284 pub fn is_cacheable(&self) -> bool {
286 !self.directives.contains(&CacheDirective::NoStore)
287 && !self.directives.contains(&CacheDirective::NoCache)
288 }
289}
290
291impl Default for CachePolicy {
292 fn default() -> Self {
293 Self::new()
294 }
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct QueryCacheability {
300 pub cacheable: bool,
302 pub policy: CachePolicy,
304 pub reasons: Vec<String>,
306 pub entity_types: Vec<String>,
308}
309
310#[derive(Debug, Clone)]
312pub struct EdgeCache {
313 provider: CdnProvider,
315 default_policy: CachePolicy,
317 query_policies: HashMap<String, CachePolicy>,
319}
320
321impl EdgeCache {
322 pub fn new(provider: CdnProvider) -> Self {
324 Self {
325 provider,
326 default_policy: CachePolicy::public(Duration::from_secs(300)), query_policies: HashMap::new(),
328 }
329 }
330
331 pub fn set_default_policy(&mut self, policy: CachePolicy) {
333 self.default_policy = policy;
334 }
335
336 pub fn set_query_policy(&mut self, query_pattern: String, policy: CachePolicy) {
338 self.query_policies.insert(query_pattern, policy);
339 }
340
341 pub fn analyze_query(&self, query: &str) -> QueryCacheability {
343 let mut reasons = Vec::new();
344 let mut entity_types = Vec::new();
345
346 if query.trim().starts_with("mutation") {
348 return QueryCacheability {
349 cacheable: false,
350 policy: CachePolicy::no_cache(),
351 reasons: vec!["Mutations are not cacheable".to_string()],
352 entity_types,
353 };
354 }
355
356 if query.trim().starts_with("subscription") {
358 return QueryCacheability {
359 cacheable: false,
360 policy: CachePolicy::no_cache(),
361 reasons: vec!["Subscriptions are not cacheable".to_string()],
362 entity_types,
363 };
364 }
365
366 entity_types = self.extract_entity_types(query);
368
369 let mut policy = self.default_policy.clone();
371
372 for entity_type in &entity_types {
374 policy.add_tag(entity_type.clone());
375 }
376
377 if self.requires_authentication(query) {
379 policy.directives = vec![CacheDirective::Private];
380 policy.add_vary("Authorization".to_string());
381 reasons.push("Query requires authentication - using private cache".to_string());
382 } else {
383 reasons.push("Public cache policy applied".to_string());
384 }
385
386 if self.has_volatile_fields(query) {
388 policy.max_age = Some(Duration::from_secs(60)); reasons.push("Query contains volatile fields - shorter TTL".to_string());
390 }
391
392 QueryCacheability {
393 cacheable: true,
394 policy,
395 reasons,
396 entity_types,
397 }
398 }
399
400 fn extract_entity_types(&self, query: &str) -> Vec<String> {
402 let mut types = Vec::new();
403
404 for word in query.split_whitespace() {
406 let clean = word.trim_matches(|c: char| !c.is_alphanumeric());
407 if !clean.is_empty()
408 && clean
409 .chars()
410 .next()
411 .expect("clean should not be empty after non-empty check")
412 .is_uppercase()
413 && !["Query", "Mutation", "Subscription"].contains(&clean)
414 {
415 types.push(clean.to_string());
416 }
417 }
418
419 types
420 }
421
422 fn requires_authentication(&self, query: &str) -> bool {
424 let auth_indicators = ["currentUser", "me", "myProfile", "private"];
426 auth_indicators
427 .iter()
428 .any(|&indicator| query.contains(indicator))
429 }
430
431 fn has_volatile_fields(&self, query: &str) -> bool {
433 let volatile_fields = ["timestamp", "now", "current", "random", "live"];
435 volatile_fields.iter().any(|&field| query.contains(field))
436 }
437
438 pub fn generate_etag(response: &str) -> String {
440 use sha2::{Digest, Sha256};
441 let mut hasher = Sha256::new();
442 hasher.update(response.as_bytes());
443 let result = hasher.finalize();
444 format!("\"{}\"", hex::encode(&result[..16])) }
446
447 pub fn etag_matches(etag: &str, if_none_match: &str) -> bool {
449 if_none_match
450 .split(',')
451 .map(|s| s.trim())
452 .any(|tag| tag == etag || tag == "*")
453 }
454
455 pub fn create_purge_request(&self, tags: Vec<String>) -> PurgeRequest {
457 PurgeRequest {
458 provider: self.provider,
459 tags,
460 timestamp: std::time::SystemTime::now(),
461 }
462 }
463
464 pub fn provider(&self) -> CdnProvider {
466 self.provider
467 }
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct PurgeRequest {
473 pub provider: CdnProvider,
475 pub tags: Vec<String>,
477 pub timestamp: std::time::SystemTime,
479}
480
481impl PurgeRequest {
482 pub fn to_api_request(&self, _credentials: &CdnCredentials) -> Result<String, String> {
484 match self.provider {
485 CdnProvider::Cloudflare => {
486 let tags_json = serde_json::to_string(&self.tags)
487 .map_err(|e| format!("Failed to serialize tags: {}", e))?;
488 Ok(format!(r#"{{"tags":{}}}"#, tags_json))
489 }
490 CdnProvider::Fastly => {
491 Ok(self.tags.join(","))
493 }
494 CdnProvider::CloudFront => {
495 let paths: Vec<String> = self.tags.iter().map(|t| format!("/*{}*", t)).collect();
497 let paths_json = serde_json::to_string(&paths)
498 .map_err(|e| format!("Failed to serialize paths: {}", e))?;
499 Ok(format!(
500 r#"{{"Paths":{{"Quantity":{},"Items":{}}}}}"#,
501 paths.len(),
502 paths_json
503 ))
504 }
505 CdnProvider::Akamai => {
506 let tags_json = serde_json::to_string(&self.tags)
507 .map_err(|e| format!("Failed to serialize tags: {}", e))?;
508 Ok(format!(r#"{{"objects":{}}}"#, tags_json))
509 }
510 CdnProvider::Generic => {
511 let tags_json = serde_json::to_string(&self.tags)
512 .map_err(|e| format!("Failed to serialize tags: {}", e))?;
513 Ok(tags_json)
514 }
515 }
516 }
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize)]
521pub struct CdnCredentials {
522 pub api_key: String,
524 pub zone_or_service_id: Option<String>,
526 pub additional: HashMap<String, String>,
528}
529
530#[derive(Debug, Clone, Default, Serialize, Deserialize)]
532pub struct CacheStats {
533 pub total_requests: u64,
535 pub cache_hits: u64,
537 pub cache_misses: u64,
539 pub stale_hits: u64,
541 pub purge_requests: u64,
543 pub bytes_cached: u64,
545}
546
547impl CacheStats {
548 pub fn hit_rate(&self) -> f64 {
550 if self.total_requests == 0 {
551 0.0
552 } else {
553 (self.cache_hits as f64) / (self.total_requests as f64)
554 }
555 }
556
557 pub fn miss_rate(&self) -> f64 {
559 if self.total_requests == 0 {
560 0.0
561 } else {
562 (self.cache_misses as f64) / (self.total_requests as f64)
563 }
564 }
565
566 pub fn record_hit(&mut self, bytes: u64) {
568 self.total_requests += 1;
569 self.cache_hits += 1;
570 self.bytes_cached += bytes;
571 }
572
573 pub fn record_miss(&mut self) {
575 self.total_requests += 1;
576 self.cache_misses += 1;
577 }
578
579 pub fn record_stale_hit(&mut self, bytes: u64) {
581 self.total_requests += 1;
582 self.stale_hits += 1;
583 self.bytes_cached += bytes;
584 }
585
586 pub fn record_purge(&mut self) {
588 self.purge_requests += 1;
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 #[test]
597 fn test_cdn_provider_cache_tag_header() {
598 assert_eq!(CdnProvider::Cloudflare.cache_tag_header(), "Cache-Tag");
599 assert_eq!(CdnProvider::Fastly.cache_tag_header(), "Surrogate-Key");
600 assert_eq!(
601 CdnProvider::CloudFront.cache_tag_header(),
602 "x-amz-meta-cache-tags"
603 );
604 assert_eq!(CdnProvider::Akamai.cache_tag_header(), "Edge-Cache-Tag");
605 }
606
607 #[test]
608 fn test_cdn_provider_supports_cache_tags() {
609 assert!(CdnProvider::Cloudflare.supports_cache_tags());
610 assert!(CdnProvider::Fastly.supports_cache_tags());
611 assert!(!CdnProvider::CloudFront.supports_cache_tags());
612 assert!(CdnProvider::Akamai.supports_cache_tags());
613 }
614
615 #[test]
616 fn test_cache_directive_as_str() {
617 assert_eq!(CacheDirective::Public.as_str(), "public");
618 assert_eq!(CacheDirective::Private.as_str(), "private");
619 assert_eq!(CacheDirective::NoCache.as_str(), "no-cache");
620 assert_eq!(CacheDirective::Immutable.as_str(), "immutable");
621 }
622
623 #[test]
624 fn test_cache_policy_public() {
625 let policy = CachePolicy::public(Duration::from_secs(300));
626 assert!(policy.directives.contains(&CacheDirective::Public));
627 assert_eq!(policy.max_age, Some(Duration::from_secs(300)));
628 assert!(policy.enable_etag);
629 }
630
631 #[test]
632 fn test_cache_policy_private() {
633 let policy = CachePolicy::private(Duration::from_secs(60));
634 assert!(policy.directives.contains(&CacheDirective::Private));
635 assert_eq!(policy.max_age, Some(Duration::from_secs(60)));
636 assert!(policy.vary_headers.contains(&"Authorization".to_string()));
637 }
638
639 #[test]
640 fn test_cache_policy_no_cache() {
641 let policy = CachePolicy::no_cache();
642 assert!(policy.directives.contains(&CacheDirective::NoCache));
643 assert!(policy.directives.contains(&CacheDirective::NoStore));
644 assert!(!policy.enable_etag);
645 }
646
647 #[test]
648 fn test_cache_policy_immutable() {
649 let policy = CachePolicy::immutable(Duration::from_secs(31536000));
650 assert!(policy.directives.contains(&CacheDirective::Immutable));
651 assert_eq!(policy.max_age, Some(Duration::from_secs(31536000)));
652 assert!(!policy.enable_etag); }
654
655 #[test]
656 fn test_cache_policy_to_cache_control() {
657 let policy = CachePolicy::public(Duration::from_secs(300));
658 let cache_control = policy.to_cache_control();
659 assert!(cache_control.contains("public"));
660 assert!(cache_control.contains("max-age=300"));
661 }
662
663 #[test]
664 fn test_cache_policy_with_stale_while_revalidate() {
665 let mut policy = CachePolicy::public(Duration::from_secs(300));
666 policy.stale_while_revalidate = Some(Duration::from_secs(60));
667
668 let cache_control = policy.to_cache_control();
669 assert!(cache_control.contains("stale-while-revalidate=60"));
670 }
671
672 #[test]
673 fn test_cache_policy_add_tag() {
674 let mut policy = CachePolicy::new();
675 policy.add_tag("User".to_string());
676 policy.add_tag("Post".to_string());
677 policy.add_tag("User".to_string()); assert_eq!(policy.cache_tags.len(), 2);
680 assert!(policy.cache_tags.contains(&"User".to_string()));
681 assert!(policy.cache_tags.contains(&"Post".to_string()));
682 }
683
684 #[test]
685 fn test_cache_policy_is_cacheable() {
686 let public_policy = CachePolicy::public(Duration::from_secs(300));
687 assert!(public_policy.is_cacheable());
688
689 let no_cache_policy = CachePolicy::no_cache();
690 assert!(!no_cache_policy.is_cacheable());
691 }
692
693 #[test]
694 fn test_edge_cache_analyze_query_mutation() {
695 let cache = EdgeCache::new(CdnProvider::Cloudflare);
696 let result = cache.analyze_query("mutation { updateUser(id: 1) { name } }");
697
698 assert!(!result.cacheable);
699 assert!(!result.policy.is_cacheable());
700 }
701
702 #[test]
703 fn test_edge_cache_analyze_query_subscription() {
704 let cache = EdgeCache::new(CdnProvider::Fastly);
705 let result = cache.analyze_query("subscription { userUpdated { id name } }");
706
707 assert!(!result.cacheable);
708 }
709
710 #[test]
711 fn test_edge_cache_analyze_query_public() {
712 let cache = EdgeCache::new(CdnProvider::Cloudflare);
713 let result = cache.analyze_query("{ posts { title author } }");
714
715 assert!(result.cacheable);
716 assert!(result.policy.is_cacheable());
717 }
718
719 #[test]
720 fn test_edge_cache_analyze_query_authenticated() {
721 let cache = EdgeCache::new(CdnProvider::Cloudflare);
722 let result = cache.analyze_query("{ currentUser { email } }");
723
724 assert!(result.cacheable);
725 assert!(result.policy.directives.contains(&CacheDirective::Private));
726 }
727
728 #[test]
729 fn test_edge_cache_extract_entity_types() {
730 let cache = EdgeCache::new(CdnProvider::Cloudflare);
731 let types = cache.extract_entity_types("{ User { name } Post { title } }");
732
733 assert!(types.contains(&"User".to_string()));
734 assert!(types.contains(&"Post".to_string()));
735 }
736
737 #[test]
738 fn test_edge_cache_requires_authentication() {
739 let cache = EdgeCache::new(CdnProvider::Cloudflare);
740
741 assert!(cache.requires_authentication("{ currentUser { name } }"));
742 assert!(cache.requires_authentication("{ me { email } }"));
743 assert!(!cache.requires_authentication("{ posts { title } }"));
744 }
745
746 #[test]
747 fn test_edge_cache_has_volatile_fields() {
748 let cache = EdgeCache::new(CdnProvider::Cloudflare);
749
750 assert!(cache.has_volatile_fields("{ posts { timestamp } }"));
751 assert!(cache.has_volatile_fields("{ stats { current } }"));
752 assert!(!cache.has_volatile_fields("{ posts { title } }"));
753 }
754
755 #[test]
756 fn test_edge_cache_generate_etag() {
757 let response1 = r#"{"data":{"user":{"name":"Alice"}}}"#;
758 let response2 = r#"{"data":{"user":{"name":"Bob"}}}"#;
759
760 let etag1 = EdgeCache::generate_etag(response1);
761 let etag2 = EdgeCache::generate_etag(response2);
762
763 assert_ne!(etag1, etag2);
764 assert!(etag1.starts_with('"'));
765 assert!(etag1.ends_with('"'));
766
767 assert_eq!(etag1, EdgeCache::generate_etag(response1));
769 }
770
771 #[test]
772 fn test_edge_cache_etag_matches() {
773 let etag = r#""abc123""#;
774
775 assert!(EdgeCache::etag_matches(etag, r#""abc123""#));
776 assert!(EdgeCache::etag_matches(etag, "*"));
777 assert!(EdgeCache::etag_matches(etag, r#""abc123", "def456""#));
778 assert!(!EdgeCache::etag_matches(etag, r#""def456""#));
779 }
780
781 #[test]
782 fn test_purge_request_cloudflare() {
783 let purge = PurgeRequest {
784 provider: CdnProvider::Cloudflare,
785 tags: vec!["User".to_string(), "Post".to_string()],
786 timestamp: std::time::SystemTime::now(),
787 };
788
789 let credentials = CdnCredentials {
790 api_key: "test".to_string(),
791 zone_or_service_id: Some("zone123".to_string()),
792 additional: HashMap::new(),
793 };
794
795 let request = purge.to_api_request(&credentials).expect("should succeed");
796 assert!(request.contains("tags"));
797 assert!(request.contains("User"));
798 }
799
800 #[test]
801 fn test_cache_stats_hit_rate() {
802 let mut stats = CacheStats::default();
803 stats.record_hit(100);
804 stats.record_hit(200);
805 stats.record_miss();
806
807 assert_eq!(stats.total_requests, 3);
808 assert_eq!(stats.cache_hits, 2);
809 assert_eq!(stats.cache_misses, 1);
810 assert!((stats.hit_rate() - 0.666).abs() < 0.01);
811 }
812
813 #[test]
814 fn test_cache_stats_miss_rate() {
815 let mut stats = CacheStats::default();
816 stats.record_hit(100);
817 stats.record_miss();
818 stats.record_miss();
819
820 assert_eq!(stats.total_requests, 3);
821 assert!((stats.miss_rate() - 0.666).abs() < 0.01);
822 }
823
824 #[test]
825 fn test_cache_stats_empty() {
826 let stats = CacheStats::default();
827 assert_eq!(stats.hit_rate(), 0.0);
828 assert_eq!(stats.miss_rate(), 0.0);
829 }
830}