1use std::{collections::BTreeMap, time::Duration};
4
5use bytes::Bytes;
6use rustrails_support::runtime;
7use serde_json::{Map, Value, json};
8use thiserror::Error;
9use url::Url;
10
11use crate::{
12 blob::Blob,
13 detect_content_type, replace_extension,
14 service::{StorageError, StorageService},
15 sha256_hex,
16};
17
18#[derive(Debug, Error)]
20pub enum VariantError {
21 #[error("blob is not variable: {0}")]
23 Invariable(String),
24 #[error("invalid transformations: {0}")]
26 InvalidTransformations(String),
27 #[error(transparent)]
29 Storage(#[from] StorageError),
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct Variant {
35 blob: Blob,
36 transformations: BTreeMap<String, Value>,
37 key: String,
38 filename: String,
39 content_type: Option<String>,
40}
41
42impl Variant {
43 #[must_use]
45 pub fn new(blob: Blob, transformations: BTreeMap<String, Value>) -> Self {
46 let content_type = determine_content_type(&blob, &transformations);
47 let extension = content_type
48 .as_deref()
49 .and_then(|value| value.rsplit('/').next())
50 .unwrap_or_else(|| blob.extension().unwrap_or("bin"));
51 let filename = replace_extension(blob.filename(), extension);
52 let digest = sha256_hex(canonicalize_transformations(&transformations).to_string());
53 let key = format!("variants/{}/{digest}", blob.key());
54 Self {
55 blob,
56 transformations,
57 key,
58 filename,
59 content_type,
60 }
61 }
62
63 #[must_use]
65 pub fn blob(&self) -> &Blob {
66 &self.blob
67 }
68
69 #[must_use]
71 pub fn key(&self) -> &str {
72 &self.key
73 }
74
75 #[must_use]
77 pub fn filename(&self) -> &str {
78 &self.filename
79 }
80
81 #[must_use]
83 pub fn content_type(&self) -> Option<&str> {
84 self.content_type.as_deref()
85 }
86
87 #[must_use]
89 pub fn transformations(&self) -> &BTreeMap<String, Value> {
90 &self.transformations
91 }
92
93 #[must_use]
95 pub fn is_variable(&self) -> bool {
96 self.blob.is_image()
97 }
98
99 pub async fn is_processed<S: StorageService + ?Sized>(
105 &self,
106 service: &S,
107 ) -> Result<bool, VariantError> {
108 Ok(service.exists(&self.key).await?)
109 }
110
111 pub fn is_processed_sync<S: StorageService + ?Sized>(
117 &self,
118 service: &S,
119 ) -> Result<bool, VariantError> {
120 runtime::block_on(self.is_processed(service))
121 }
122
123 pub async fn processed<S: StorageService + ?Sized>(
129 &self,
130 service: &S,
131 ) -> Result<Self, VariantError> {
132 if !self.is_variable() {
133 return Err(VariantError::Invariable(
134 self.blob.content_type().unwrap_or("unknown").to_owned(),
135 ));
136 }
137 if !service.exists(&self.key).await? {
138 let payload = json!({
139 "source_key": self.blob.key(),
140 "filename": self.filename,
141 "content_type": self.content_type,
142 "transformations": canonicalize_transformations(&self.transformations),
143 });
144 service
145 .upload(&self.key, Bytes::from(payload.to_string().into_bytes()))
146 .await?;
147 }
148 Ok(self.clone())
149 }
150
151 pub fn processed_sync<S: StorageService + ?Sized>(
157 &self,
158 service: &S,
159 ) -> Result<Self, VariantError> {
160 runtime::block_on(self.processed(service))
161 }
162
163 pub async fn url<S: StorageService + ?Sized>(
169 &self,
170 service: &S,
171 expires_in: Duration,
172 ) -> Result<Url, VariantError> {
173 Ok(service.url(&self.key, expires_in).await?)
174 }
175
176 pub fn url_sync<S: StorageService + ?Sized>(
182 &self,
183 service: &S,
184 expires_in: Duration,
185 ) -> Result<Url, VariantError> {
186 runtime::block_on(self.url(service, expires_in))
187 }
188}
189
190fn canonicalize_transformations(transformations: &BTreeMap<String, Value>) -> Value {
191 let mut map = Map::new();
192 for (key, value) in transformations {
193 map.insert(key.clone(), canonical_value(value));
194 }
195 Value::Object(map)
196}
197
198fn canonical_value(value: &Value) -> Value {
199 match value {
200 Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()),
201 Value::Object(values) => {
202 let mut map = Map::new();
203 let mut keys: Vec<_> = values.keys().cloned().collect();
204 keys.sort();
205 for key in keys {
206 map.insert(key.clone(), canonical_value(&values[&key]));
207 }
208 Value::Object(map)
209 }
210 _ => value.clone(),
211 }
212}
213
214fn determine_content_type(
215 blob: &Blob,
216 transformations: &BTreeMap<String, Value>,
217) -> Option<String> {
218 transformations
219 .get("format")
220 .and_then(Value::as_str)
221 .and_then(|format| detect_content_type(&format!("file.{format}"), None))
222 .or_else(|| blob.content_type().map(ToOwned::to_owned))
223}
224
225#[cfg(test)]
226mod tests {
227 use bytes::Bytes;
228 use rustrails_support::runtime;
229 use serde_json::json;
230
231 use super::*;
232 use crate::{blob::Blob, service::memory::MemoryService, test_support::run_sync_test};
233
234 fn blob(filename: &str, content_type: Option<&str>) -> Blob {
235 Blob::create(
236 Bytes::from(filename.as_bytes().to_vec()),
237 filename.to_owned(),
238 content_type,
239 BTreeMap::new(),
240 "memory",
241 )
242 .expect("blob should build")
243 }
244
245 fn map(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
246 pairs
247 .iter()
248 .map(|(key, value)| ((*key).to_owned(), value.clone()))
249 .collect()
250 }
251
252 #[test]
253 fn test_same_transformations_hash_to_same_key() {
254 let blob = blob("racecar.jpg", Some("image/jpeg"));
255 let first = Variant::new(blob.clone(), map(&[("resize_to_limit", json!([100, 100]))]));
256 let second = Variant::new(blob, map(&[("resize_to_limit", json!([100, 100]))]));
257 assert_eq!(first.key(), second.key());
258 }
259
260 #[test]
261 fn test_transformations_order_does_not_change_key() {
262 let blob = blob("racecar.jpg", Some("image/jpeg"));
263 let first = Variant::new(blob.clone(), map(&[("a", json!(1)), ("b", json!(2))]));
264 let second = Variant::new(blob, map(&[("b", json!(2)), ("a", json!(1))]));
265 assert_eq!(first.key(), second.key());
266 }
267
268 #[test]
269 fn test_nested_transformation_order_does_not_change_key() {
270 let blob = blob("racecar.jpg", Some("image/jpeg"));
271 let first = Variant::new(
272 blob.clone(),
273 map(&[("resize", json!({"width": 100, "height": 200}))]),
274 );
275 let second = Variant::new(
276 blob,
277 map(&[("resize", json!({"height": 200, "width": 100}))]),
278 );
279 assert_eq!(first.key(), second.key());
280 }
281
282 #[test]
283 fn test_different_transformations_change_key() {
284 let blob = blob("racecar.jpg", Some("image/jpeg"));
285 let first = Variant::new(blob.clone(), map(&[("resize_to_limit", json!([100, 100]))]));
286 let second = Variant::new(blob, map(&[("resize_to_limit", json!([200, 200]))]));
287 assert_ne!(first.key(), second.key());
288 }
289
290 #[test]
291 fn test_variant_key_is_scoped_to_source_blob_key() {
292 let blob = blob("racecar.jpg", Some("image/jpeg"));
293 let prefix = format!("variants/{}/", blob.key());
294
295 let variant = Variant::new(blob, BTreeMap::new());
296
297 assert!(variant.key().starts_with(&prefix));
298 }
299
300 #[test]
301 fn test_format_transformation_updates_filename() {
302 let variant = Variant::new(
303 blob("racecar.jpg", Some("image/jpeg")),
304 map(&[("format", json!("png"))]),
305 );
306 assert_eq!(variant.filename(), "racecar.png");
307 assert_eq!(variant.content_type(), Some("image/png"));
308 }
309
310 #[test]
311 fn test_format_transformation_normalizes_uppercase_format() {
312 let variant = Variant::new(
313 blob("racecar.jpg", Some("image/jpeg")),
314 map(&[("format", json!("PNG"))]),
315 );
316
317 assert_eq!(variant.filename(), "racecar.png");
318 assert_eq!(variant.content_type(), Some("image/png"));
319 }
320
321 #[test]
322 fn test_variant_defaults_to_blob_content_type() {
323 let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
324 assert_eq!(variant.content_type(), Some("image/jpeg"));
325 }
326
327 #[test]
328 fn test_variant_uses_blob_content_type_extension_for_extensionless_filename() {
329 let variant = Variant::new(blob("image", Some("image/gif")), BTreeMap::new());
330
331 assert_eq!(variant.filename(), "image.gif");
332 assert_eq!(variant.content_type(), Some("image/gif"));
333 }
334
335 #[test]
336 fn test_variant_falls_back_to_bin_extension_without_content_type() {
337 let variant = Variant::new(blob("mystery", None), BTreeMap::new());
338
339 assert_eq!(variant.filename(), "mystery.bin");
340 assert_eq!(variant.content_type(), None);
341 }
342
343 #[tokio::test]
344 async fn test_processed_uploads_variant_placeholder() {
345 let service = MemoryService::new("memory").expect("service should build");
346 let variant = Variant::new(
347 blob("racecar.jpg", Some("image/jpeg")),
348 map(&[("resize_to_limit", json!([100, 100]))]),
349 );
350 let processed = variant
351 .processed(&service)
352 .await
353 .expect("processing should succeed");
354 assert!(
355 service
356 .exists(processed.key())
357 .await
358 .expect("exists should succeed")
359 );
360 }
361
362 #[tokio::test]
363 async fn test_processed_payload_records_canonicalized_fields() {
364 let service = MemoryService::new("memory").expect("service should build");
365 let source = blob("racecar.jpg", Some("image/jpeg"));
366 let variant = Variant::new(
367 source.clone(),
368 map(&[
369 ("resize", json!({"width": 100, "height": 200})),
370 ("format", json!("PNG")),
371 ]),
372 );
373
374 let processed = variant
375 .processed(&service)
376 .await
377 .expect("processing should succeed");
378 let payload = service
379 .download(processed.key())
380 .await
381 .expect("download should succeed");
382 let payload: Value = serde_json::from_slice(&payload).expect("payload should decode");
383
384 assert_eq!(processed, variant);
385 assert_eq!(
386 payload,
387 json!({
388 "source_key": source.key(),
389 "filename": "racecar.png",
390 "content_type": "image/png",
391 "transformations": {
392 "format": "PNG",
393 "resize": {
394 "height": 200,
395 "width": 100
396 }
397 }
398 })
399 );
400 }
401
402 #[tokio::test]
403 async fn test_processed_is_idempotent() {
404 let service = MemoryService::new("memory").expect("service should build");
405 let variant = Variant::new(
406 blob("racecar.jpg", Some("image/jpeg")),
407 map(&[("resize_to_limit", json!([100, 100]))]),
408 );
409 let _ = variant
410 .processed(&service)
411 .await
412 .expect("processing should succeed");
413 let count_before = service.len();
414 let _ = variant
415 .processed(&service)
416 .await
417 .expect("processing should succeed");
418 assert_eq!(service.len(), count_before);
419 }
420
421 #[tokio::test]
422 async fn test_processed_rejects_invariable_blob() {
423 let service = MemoryService::new("memory").expect("service should build");
424 let variant = Variant::new(blob("report.pdf", Some("application/pdf")), BTreeMap::new());
425 let error = variant
426 .processed(&service)
427 .await
428 .expect_err("processing should fail");
429 assert!(
430 matches!(error, VariantError::Invariable(content_type) if content_type == "application/pdf")
431 );
432 }
433
434 #[tokio::test]
435 async fn test_is_processed_reports_state() {
436 let service = MemoryService::new("memory").expect("service should build");
437 let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
438 assert!(
439 !variant
440 .is_processed(&service)
441 .await
442 .expect("status should succeed")
443 );
444 let _ = variant
445 .processed(&service)
446 .await
447 .expect("processing should succeed");
448 assert!(
449 variant
450 .is_processed(&service)
451 .await
452 .expect("status should succeed")
453 );
454 }
455
456 #[test]
457 fn test_is_processed_sync_reports_state() {
458 run_sync_test(|| {
459 let service = MemoryService::new("memory").expect("service should build");
460 let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
461 assert!(
462 !variant
463 .is_processed_sync(&service)
464 .expect("status should succeed")
465 );
466 runtime::block_on(service.upload(variant.key(), Bytes::from_static(b"variant")))
467 .expect("upload should succeed");
468 assert!(
469 variant
470 .is_processed_sync(&service)
471 .expect("status should succeed")
472 );
473 });
474 }
475
476 #[test]
477 fn test_processed_sync_uploads_variant_placeholder() {
478 run_sync_test(|| {
479 let service = MemoryService::new("memory").expect("service should build");
480 let variant = Variant::new(
481 blob("racecar.jpg", Some("image/jpeg")),
482 map(&[("resize_to_limit", json!([100, 100]))]),
483 );
484
485 let processed = variant
486 .processed_sync(&service)
487 .expect("processing should succeed");
488
489 assert!(
490 runtime::block_on(service.exists(processed.key())).expect("exists should succeed")
491 );
492 });
493 }
494
495 #[tokio::test]
496 async fn test_url_delegates_to_storage_service() {
497 let service = MemoryService::new("memory").expect("service should build");
498 let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
499 let url = variant
500 .url(&service, Duration::from_secs(60))
501 .await
502 .expect("url should build");
503 assert!(url.as_str().contains("expires_in=60"));
504 }
505
506 #[test]
507 fn test_url_sync_delegates_to_storage_service() {
508 run_sync_test(|| {
509 let service = MemoryService::new("memory").expect("service should build");
510 let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
511 let url = variant
512 .url_sync(&service, Duration::from_secs(60))
513 .expect("url should build");
514 assert!(url.as_str().contains("expires_in=60"));
515 });
516 }
517}