Skip to main content

reinhardt_utils/staticfiles/
cdn.rs

1//! CDN integration helpers
2//!
3//! Provides utilities for integrating with Content Delivery Networks (CDNs)
4//! such as CloudFront, Fastly, and Cloudflare.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// CDN provider type
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
11pub enum CdnProvider {
12	/// Amazon CloudFront
13	CloudFront,
14	/// Fastly
15	Fastly,
16	/// Cloudflare
17	Cloudflare,
18	/// Custom CDN provider
19	Custom(String),
20}
21
22/// CDN configuration
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct CdnConfig {
25	/// Whether CDN integration is enabled
26	pub enabled: bool,
27	/// CDN provider
28	pub provider: CdnProvider,
29	/// CDN base URL (e.g., `"https://d1234567890.cloudfront.net"`)
30	pub base_url: String,
31	/// Path prefix on CDN (e.g., "/static")
32	pub path_prefix: Option<String>,
33	/// Custom headers to add to CDN requests
34	pub custom_headers: HashMap<String, String>,
35	/// Whether to use HTTPS
36	pub use_https: bool,
37}
38
39impl CdnConfig {
40	/// Create a new CDN configuration
41	///
42	/// # Examples
43	///
44	/// ```
45	/// use reinhardt_utils::staticfiles::cdn::{CdnConfig, CdnProvider};
46	///
47	/// let config = CdnConfig::new(
48	///     CdnProvider::CloudFront,
49	///     "d1234567890.cloudfront.net".to_string(),
50	/// );
51	/// ```
52	pub fn new(provider: CdnProvider, base_url: String) -> Self {
53		Self {
54			enabled: true,
55			provider,
56			base_url: base_url.trim_end_matches('/').to_string(),
57			path_prefix: None,
58			custom_headers: HashMap::new(),
59			use_https: true,
60		}
61	}
62
63	/// Disable CDN integration
64	pub fn disabled() -> Self {
65		Self {
66			enabled: false,
67			provider: CdnProvider::Custom("none".to_string()),
68			base_url: String::new(),
69			path_prefix: None,
70			custom_headers: HashMap::new(),
71			use_https: true,
72		}
73	}
74
75	/// Set path prefix
76	pub fn with_path_prefix(mut self, prefix: String) -> Self {
77		self.path_prefix = Some(prefix.trim_start_matches('/').to_string());
78		self
79	}
80
81	/// Add custom header
82	pub fn with_custom_header(mut self, key: String, value: String) -> Self {
83		self.custom_headers.insert(key, value);
84		self
85	}
86
87	/// Disable HTTPS
88	pub fn without_https(mut self) -> Self {
89		self.use_https = false;
90		self
91	}
92}
93
94/// CDN URL generator
95pub struct CdnUrlGenerator {
96	config: CdnConfig,
97}
98
99impl CdnUrlGenerator {
100	/// Create a new CDN URL generator
101	///
102	/// # Examples
103	///
104	/// ```
105	/// use reinhardt_utils::staticfiles::cdn::{CdnUrlGenerator, CdnConfig, CdnProvider};
106	///
107	/// let config = CdnConfig::new(
108	///     CdnProvider::CloudFront,
109	///     "d1234567890.cloudfront.net".to_string(),
110	/// );
111	/// let generator = CdnUrlGenerator::new(config);
112	/// ```
113	pub fn new(config: CdnConfig) -> Self {
114		Self { config }
115	}
116
117	/// Generate a CDN URL for a given path
118	///
119	/// # Examples
120	///
121	/// ```
122	/// use reinhardt_utils::staticfiles::cdn::{CdnUrlGenerator, CdnConfig, CdnProvider};
123	///
124	/// let config = CdnConfig::new(
125	///     CdnProvider::CloudFront,
126	///     "d1234567890.cloudfront.net".to_string(),
127	/// ).with_path_prefix("static".to_string());
128	///
129	/// let generator = CdnUrlGenerator::new(config);
130	/// let url = generator.generate_url("/css/style.css");
131	///
132	/// assert_eq!(url, "https://d1234567890.cloudfront.net/static/css/style.css");
133	/// ```
134	pub fn generate_url(&self, path: &str) -> String {
135		if !self.config.enabled {
136			return path.to_string();
137		}
138
139		let scheme = if self.config.use_https {
140			"https"
141		} else {
142			"http"
143		};
144		let path = path.trim_start_matches('/');
145
146		let full_path = if let Some(prefix) = &self.config.path_prefix {
147			format!("{}/{}", prefix.trim_end_matches('/'), path)
148		} else {
149			path.to_string()
150		};
151
152		format!("{}://{}/{}", scheme, self.config.base_url, full_path)
153	}
154
155	/// Generate URLs for multiple paths
156	pub fn generate_urls(&self, paths: &[&str]) -> Vec<String> {
157		paths.iter().map(|p| self.generate_url(p)).collect()
158	}
159
160	/// Generate a versioned URL with query parameter
161	///
162	/// # Examples
163	///
164	/// ```
165	/// use reinhardt_utils::staticfiles::cdn::{CdnUrlGenerator, CdnConfig, CdnProvider};
166	///
167	/// let config = CdnConfig::new(
168	///     CdnProvider::CloudFront,
169	///     "d1234567890.cloudfront.net".to_string(),
170	/// );
171	///
172	/// let generator = CdnUrlGenerator::new(config);
173	/// let url = generator.generate_versioned_url("/css/style.css", "v1.2.3");
174	///
175	/// assert_eq!(url, "https://d1234567890.cloudfront.net/css/style.css?v=v1.2.3");
176	/// ```
177	pub fn generate_versioned_url(&self, path: &str, version: &str) -> String {
178		let base_url = self.generate_url(path);
179		format!("{}?v={}", base_url, version)
180	}
181}
182
183/// CDN cache invalidation request
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct CdnInvalidationRequest {
186	/// Paths to invalidate
187	pub paths: Vec<String>,
188	/// Caller reference (unique identifier for this request)
189	pub caller_reference: Option<String>,
190}
191
192impl CdnInvalidationRequest {
193	/// Create a new invalidation request
194	///
195	/// # Examples
196	///
197	/// ```
198	/// use reinhardt_utils::staticfiles::cdn::CdnInvalidationRequest;
199	///
200	/// let request = CdnInvalidationRequest::new(vec![
201	///     "/css/style.css".to_string(),
202	///     "/js/app.js".to_string(),
203	/// ]);
204	/// ```
205	pub fn new(paths: Vec<String>) -> Self {
206		Self {
207			paths,
208			caller_reference: None,
209		}
210	}
211
212	/// Set caller reference
213	pub fn with_caller_reference(mut self, reference: String) -> Self {
214		self.caller_reference = Some(reference);
215		self
216	}
217
218	/// Add path to invalidate
219	pub fn add_path(&mut self, path: String) {
220		self.paths.push(path);
221	}
222
223	/// Add multiple paths
224	pub fn add_paths(&mut self, paths: Vec<String>) {
225		self.paths.extend(paths);
226	}
227}
228
229/// CDN purge helper (simplified API for common operations)
230pub struct CdnPurgeHelper {
231	config: CdnConfig,
232}
233
234impl CdnPurgeHelper {
235	/// Create a new purge helper
236	///
237	/// # Examples
238	///
239	/// ```
240	/// use reinhardt_utils::staticfiles::cdn::{CdnPurgeHelper, CdnConfig, CdnProvider};
241	///
242	/// let config = CdnConfig::new(
243	///     CdnProvider::CloudFront,
244	///     "d1234567890.cloudfront.net".to_string(),
245	/// );
246	/// let helper = CdnPurgeHelper::new(config);
247	/// ```
248	pub fn new(config: CdnConfig) -> Self {
249		Self { config }
250	}
251
252	/// Create an invalidation request for given paths
253	///
254	/// # Examples
255	///
256	/// ```
257	/// use reinhardt_utils::staticfiles::cdn::{CdnPurgeHelper, CdnConfig, CdnProvider};
258	///
259	/// let config = CdnConfig::new(
260	///     CdnProvider::CloudFront,
261	///     "d1234567890.cloudfront.net".to_string(),
262	/// );
263	/// let helper = CdnPurgeHelper::new(config);
264	///
265	/// let request = helper.create_invalidation_request(vec![
266	///     "/css/style.css".to_string(),
267	///     "/js/app.js".to_string(),
268	/// ]);
269	/// ```
270	pub fn create_invalidation_request(&self, paths: Vec<String>) -> CdnInvalidationRequest {
271		CdnInvalidationRequest::new(paths)
272	}
273
274	/// Create an invalidation request for all files matching a pattern
275	pub fn create_wildcard_invalidation(&self, pattern: &str) -> CdnInvalidationRequest {
276		CdnInvalidationRequest::new(vec![pattern.to_string()])
277	}
278
279	/// Get the CDN purge endpoint URL
280	///
281	/// # Implementation Status
282	///
283	/// Returns the API endpoint URL for each CDN provider.
284	/// For production use with actual purging, integrate with the CDN provider's SDK:
285	/// - CloudFront: Use `aws-sdk-cloudfront` crate
286	/// - Fastly: Use `fastly` crate or HTTP API
287	/// - Cloudflare: Use `cloudflare` crate or HTTP API
288	///
289	/// # Note
290	///
291	/// The returned URLs contain placeholders (e.g., `{distribution-id}`, `{service-id}`)
292	/// that must be replaced with actual values from your CDN configuration.
293	pub fn get_purge_endpoint(&self) -> String {
294		match &self.config.provider {
295			CdnProvider::CloudFront => {
296				// CloudFront invalidation endpoint
297				// Replace {distribution-id} with your actual distribution ID
298				"https://cloudfront.amazonaws.com/2020-05-31/distribution/{distribution-id}/invalidation".to_string()
299			}
300			CdnProvider::Fastly => {
301				// Fastly purge endpoint
302				// Replace {service-id} with your actual service ID
303				"https://api.fastly.com/service/{service-id}/purge".to_string()
304			}
305			CdnProvider::Cloudflare => {
306				// Cloudflare purge cache endpoint
307				// Replace {zone-id} with your actual zone ID
308				"https://api.cloudflare.com/client/v4/zones/{zone-id}/purge_cache".to_string()
309			}
310			CdnProvider::Custom(name) => {
311				// Custom CDN endpoint
312				format!("custom://{}/purge", name)
313			}
314		}
315	}
316}
317
318#[cfg(test)]
319mod tests {
320	use super::*;
321
322	#[test]
323	fn test_cdn_config_creation() {
324		let config = CdnConfig::new(
325			CdnProvider::CloudFront,
326			"d1234567890.cloudfront.net".to_string(),
327		);
328
329		assert!(config.enabled);
330		assert_eq!(config.provider, CdnProvider::CloudFront);
331		assert_eq!(config.base_url, "d1234567890.cloudfront.net");
332		assert!(config.use_https);
333	}
334
335	#[test]
336	fn test_cdn_url_generation() {
337		let config = CdnConfig::new(
338			CdnProvider::CloudFront,
339			"d1234567890.cloudfront.net".to_string(),
340		);
341		let generator = CdnUrlGenerator::new(config);
342
343		let url = generator.generate_url("/css/style.css");
344		assert_eq!(url, "https://d1234567890.cloudfront.net/css/style.css");
345	}
346
347	#[test]
348	fn test_cdn_url_generation_with_prefix() {
349		let config = CdnConfig::new(
350			CdnProvider::CloudFront,
351			"d1234567890.cloudfront.net".to_string(),
352		)
353		.with_path_prefix("static".to_string());
354
355		let generator = CdnUrlGenerator::new(config);
356		let url = generator.generate_url("/css/style.css");
357
358		assert_eq!(
359			url,
360			"https://d1234567890.cloudfront.net/static/css/style.css"
361		);
362	}
363
364	#[test]
365	fn test_cdn_url_generation_without_https() {
366		let config =
367			CdnConfig::new(CdnProvider::Fastly, "example.fastly.net".to_string()).without_https();
368
369		let generator = CdnUrlGenerator::new(config);
370		let url = generator.generate_url("/image.png");
371
372		assert_eq!(url, "http://example.fastly.net/image.png");
373	}
374
375	#[test]
376	fn test_versioned_url_generation() {
377		let config = CdnConfig::new(
378			CdnProvider::CloudFront,
379			"d1234567890.cloudfront.net".to_string(),
380		);
381		let generator = CdnUrlGenerator::new(config);
382
383		let url = generator.generate_versioned_url("/css/style.css", "v1.2.3");
384		assert_eq!(
385			url,
386			"https://d1234567890.cloudfront.net/css/style.css?v=v1.2.3"
387		);
388	}
389
390	#[test]
391	fn test_multiple_urls_generation() {
392		let config = CdnConfig::new(CdnProvider::Cloudflare, "cdn.example.com".to_string());
393		let generator = CdnUrlGenerator::new(config);
394
395		let paths = vec!["/css/style.css", "/js/app.js", "/img/logo.png"];
396		let urls = generator.generate_urls(&paths);
397
398		assert_eq!(urls.len(), 3);
399		assert_eq!(urls[0], "https://cdn.example.com/css/style.css");
400		assert_eq!(urls[1], "https://cdn.example.com/js/app.js");
401		assert_eq!(urls[2], "https://cdn.example.com/img/logo.png");
402	}
403
404	#[test]
405	fn test_disabled_cdn_returns_original_path() {
406		let config = CdnConfig::disabled();
407		let generator = CdnUrlGenerator::new(config);
408
409		let url = generator.generate_url("/css/style.css");
410		assert_eq!(url, "/css/style.css");
411	}
412
413	#[test]
414	fn test_invalidation_request_creation() {
415		let request = CdnInvalidationRequest::new(vec![
416			"/css/style.css".to_string(),
417			"/js/app.js".to_string(),
418		]);
419
420		assert_eq!(request.paths.len(), 2);
421		assert!(request.caller_reference.is_none());
422	}
423
424	#[test]
425	fn test_invalidation_request_with_caller_reference() {
426		let request = CdnInvalidationRequest::new(vec!["/css/style.css".to_string()])
427			.with_caller_reference("unique-id-123".to_string());
428
429		assert_eq!(request.caller_reference, Some("unique-id-123".to_string()));
430	}
431
432	#[test]
433	fn test_add_paths_to_invalidation_request() {
434		let mut request = CdnInvalidationRequest::new(vec!["/css/style.css".to_string()]);
435		request.add_path("/js/app.js".to_string());
436		request.add_paths(vec![
437			"/img/logo.png".to_string(),
438			"/fonts/font.woff2".to_string(),
439		]);
440
441		assert_eq!(request.paths.len(), 4);
442	}
443
444	#[test]
445	fn test_purge_helper_creates_request() {
446		let config = CdnConfig::new(
447			CdnProvider::CloudFront,
448			"d1234567890.cloudfront.net".to_string(),
449		);
450		let helper = CdnPurgeHelper::new(config);
451
452		let request = helper.create_invalidation_request(vec![
453			"/css/style.css".to_string(),
454			"/js/app.js".to_string(),
455		]);
456
457		assert_eq!(request.paths.len(), 2);
458	}
459
460	#[test]
461	fn test_wildcard_invalidation() {
462		let config = CdnConfig::new(
463			CdnProvider::CloudFront,
464			"d1234567890.cloudfront.net".to_string(),
465		);
466		let helper = CdnPurgeHelper::new(config);
467
468		let request = helper.create_wildcard_invalidation("/css/*");
469		assert_eq!(request.paths, vec!["/css/*"]);
470	}
471
472	#[test]
473	fn test_purge_endpoints() {
474		// Test CloudFront endpoint
475		let config = CdnConfig::new(CdnProvider::CloudFront, "example.com".to_string());
476		let helper = CdnPurgeHelper::new(config);
477		let endpoint = helper.get_purge_endpoint();
478		assert!(endpoint.contains("cloudfront.amazonaws.com"));
479		assert!(endpoint.contains("invalidation"));
480
481		// Test Fastly endpoint
482		let config = CdnConfig::new(CdnProvider::Fastly, "example.com".to_string());
483		let helper = CdnPurgeHelper::new(config);
484		let endpoint = helper.get_purge_endpoint();
485		assert!(endpoint.contains("api.fastly.com"));
486		assert!(endpoint.contains("purge"));
487
488		// Test Cloudflare endpoint
489		let config = CdnConfig::new(CdnProvider::Cloudflare, "example.com".to_string());
490		let helper = CdnPurgeHelper::new(config);
491		let endpoint = helper.get_purge_endpoint();
492		assert!(endpoint.contains("api.cloudflare.com"));
493		assert!(endpoint.contains("purge_cache"));
494
495		// Test Custom provider
496		let config = CdnConfig::new(
497			CdnProvider::Custom("mycdn".to_string()),
498			"example.com".to_string(),
499		);
500		let helper = CdnPurgeHelper::new(config);
501		let endpoint = helper.get_purge_endpoint();
502		assert!(endpoint.contains("custom://mycdn/purge"));
503	}
504
505	#[test]
506	fn test_custom_provider() {
507		let config = CdnConfig::new(
508			CdnProvider::Custom("my-cdn".to_string()),
509			"cdn.mycompany.com".to_string(),
510		);
511
512		assert_eq!(config.provider, CdnProvider::Custom("my-cdn".to_string()));
513	}
514
515	#[test]
516	fn test_custom_headers() {
517		let config = CdnConfig::new(
518			CdnProvider::CloudFront,
519			"d1234567890.cloudfront.net".to_string(),
520		)
521		.with_custom_header("X-Custom-Header".to_string(), "value".to_string())
522		.with_custom_header("Authorization".to_string(), "Bearer token".to_string());
523
524		assert_eq!(config.custom_headers.len(), 2);
525		assert_eq!(
526			config.custom_headers.get("X-Custom-Header"),
527			Some(&"value".to_string())
528		);
529	}
530}