Skip to main content

rustrails_storage/
variant.rs

1//! Variant transformation requests and caching.
2
3use 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/// Errors returned by variant operations.
19#[derive(Debug, Error)]
20pub enum VariantError {
21    /// The blob content type cannot be transformed.
22    #[error("blob is not variable: {0}")]
23    Invariable(String),
24    /// A serialization or transformation error occurred.
25    #[error("invalid transformations: {0}")]
26    InvalidTransformations(String),
27    /// The backing storage service failed.
28    #[error(transparent)]
29    Storage(#[from] StorageError),
30}
31
32/// Describes a transformable blob representation.
33#[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    /// Creates a new variant request.
44    #[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    /// Returns the source blob.
64    #[must_use]
65    pub fn blob(&self) -> &Blob {
66        &self.blob
67    }
68
69    /// Returns the variant cache key.
70    #[must_use]
71    pub fn key(&self) -> &str {
72        &self.key
73    }
74
75    /// Returns the normalized output filename.
76    #[must_use]
77    pub fn filename(&self) -> &str {
78        &self.filename
79    }
80
81    /// Returns the output content type.
82    #[must_use]
83    pub fn content_type(&self) -> Option<&str> {
84        self.content_type.as_deref()
85    }
86
87    /// Returns the canonical transformations.
88    #[must_use]
89    pub fn transformations(&self) -> &BTreeMap<String, Value> {
90        &self.transformations
91    }
92
93    /// Returns whether the source blob can be transformed.
94    #[must_use]
95    pub fn is_variable(&self) -> bool {
96        self.blob.is_image()
97    }
98
99    /// Returns whether the variant has already been processed.
100    ///
101    /// # Errors
102    ///
103    /// Returns an error when the storage backend existence check fails.
104    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    /// Returns whether the variant has already been processed using the thread-local runtime.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error when the storage backend existence check fails.
116    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    /// Processes the variant and stores a cached placeholder object when missing.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error when the blob cannot be transformed or the storage backend fails.
128    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    /// Processes the variant and stores a cached placeholder object when missing using the thread-local runtime.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error when the blob cannot be transformed or the storage backend fails.
156    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    /// Generates a storage-backed URL for the processed variant.
164    ///
165    /// # Errors
166    ///
167    /// Returns an error when the storage backend cannot generate the URL.
168    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    /// Generates a storage-backed URL for the processed variant using the thread-local runtime.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error when the storage backend cannot generate the URL.
181    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}