use std::{collections::BTreeMap, time::Duration};
use bytes::Bytes;
use rustrails_support::runtime;
use serde_json::{Map, Value, json};
use thiserror::Error;
use url::Url;
use crate::{
blob::Blob,
detect_content_type, replace_extension,
service::{StorageError, StorageService},
sha256_hex,
};
#[derive(Debug, Error)]
pub enum VariantError {
#[error("blob is not variable: {0}")]
Invariable(String),
#[error("invalid transformations: {0}")]
InvalidTransformations(String),
#[error(transparent)]
Storage(#[from] StorageError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Variant {
blob: Blob,
transformations: BTreeMap<String, Value>,
key: String,
filename: String,
content_type: Option<String>,
}
impl Variant {
#[must_use]
pub fn new(blob: Blob, transformations: BTreeMap<String, Value>) -> Self {
let content_type = determine_content_type(&blob, &transformations);
let extension = content_type
.as_deref()
.and_then(|value| value.rsplit('/').next())
.unwrap_or_else(|| blob.extension().unwrap_or("bin"));
let filename = replace_extension(blob.filename(), extension);
let digest = sha256_hex(canonicalize_transformations(&transformations).to_string());
let key = format!("variants/{}/{digest}", blob.key());
Self {
blob,
transformations,
key,
filename,
content_type,
}
}
#[must_use]
pub fn blob(&self) -> &Blob {
&self.blob
}
#[must_use]
pub fn key(&self) -> &str {
&self.key
}
#[must_use]
pub fn filename(&self) -> &str {
&self.filename
}
#[must_use]
pub fn content_type(&self) -> Option<&str> {
self.content_type.as_deref()
}
#[must_use]
pub fn transformations(&self) -> &BTreeMap<String, Value> {
&self.transformations
}
#[must_use]
pub fn is_variable(&self) -> bool {
self.blob.is_image()
}
pub async fn is_processed<S: StorageService + ?Sized>(
&self,
service: &S,
) -> Result<bool, VariantError> {
Ok(service.exists(&self.key).await?)
}
pub fn is_processed_sync<S: StorageService + ?Sized>(
&self,
service: &S,
) -> Result<bool, VariantError> {
runtime::block_on(self.is_processed(service))
}
pub async fn processed<S: StorageService + ?Sized>(
&self,
service: &S,
) -> Result<Self, VariantError> {
if !self.is_variable() {
return Err(VariantError::Invariable(
self.blob.content_type().unwrap_or("unknown").to_owned(),
));
}
if !service.exists(&self.key).await? {
let payload = json!({
"source_key": self.blob.key(),
"filename": self.filename,
"content_type": self.content_type,
"transformations": canonicalize_transformations(&self.transformations),
});
service
.upload(&self.key, Bytes::from(payload.to_string().into_bytes()))
.await?;
}
Ok(self.clone())
}
pub fn processed_sync<S: StorageService + ?Sized>(
&self,
service: &S,
) -> Result<Self, VariantError> {
runtime::block_on(self.processed(service))
}
pub async fn url<S: StorageService + ?Sized>(
&self,
service: &S,
expires_in: Duration,
) -> Result<Url, VariantError> {
Ok(service.url(&self.key, expires_in).await?)
}
pub fn url_sync<S: StorageService + ?Sized>(
&self,
service: &S,
expires_in: Duration,
) -> Result<Url, VariantError> {
runtime::block_on(self.url(service, expires_in))
}
}
fn canonicalize_transformations(transformations: &BTreeMap<String, Value>) -> Value {
let mut map = Map::new();
for (key, value) in transformations {
map.insert(key.clone(), canonical_value(value));
}
Value::Object(map)
}
fn canonical_value(value: &Value) -> Value {
match value {
Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()),
Value::Object(values) => {
let mut map = Map::new();
let mut keys: Vec<_> = values.keys().cloned().collect();
keys.sort();
for key in keys {
map.insert(key.clone(), canonical_value(&values[&key]));
}
Value::Object(map)
}
_ => value.clone(),
}
}
fn determine_content_type(
blob: &Blob,
transformations: &BTreeMap<String, Value>,
) -> Option<String> {
transformations
.get("format")
.and_then(Value::as_str)
.and_then(|format| detect_content_type(&format!("file.{format}"), None))
.or_else(|| blob.content_type().map(ToOwned::to_owned))
}
#[cfg(test)]
mod tests {
use bytes::Bytes;
use rustrails_support::runtime;
use serde_json::json;
use super::*;
use crate::{blob::Blob, service::memory::MemoryService, test_support::run_sync_test};
fn blob(filename: &str, content_type: Option<&str>) -> Blob {
Blob::create(
Bytes::from(filename.as_bytes().to_vec()),
filename.to_owned(),
content_type,
BTreeMap::new(),
"memory",
)
.expect("blob should build")
}
fn map(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
pairs
.iter()
.map(|(key, value)| ((*key).to_owned(), value.clone()))
.collect()
}
#[test]
fn test_same_transformations_hash_to_same_key() {
let blob = blob("racecar.jpg", Some("image/jpeg"));
let first = Variant::new(blob.clone(), map(&[("resize_to_limit", json!([100, 100]))]));
let second = Variant::new(blob, map(&[("resize_to_limit", json!([100, 100]))]));
assert_eq!(first.key(), second.key());
}
#[test]
fn test_transformations_order_does_not_change_key() {
let blob = blob("racecar.jpg", Some("image/jpeg"));
let first = Variant::new(blob.clone(), map(&[("a", json!(1)), ("b", json!(2))]));
let second = Variant::new(blob, map(&[("b", json!(2)), ("a", json!(1))]));
assert_eq!(first.key(), second.key());
}
#[test]
fn test_nested_transformation_order_does_not_change_key() {
let blob = blob("racecar.jpg", Some("image/jpeg"));
let first = Variant::new(
blob.clone(),
map(&[("resize", json!({"width": 100, "height": 200}))]),
);
let second = Variant::new(
blob,
map(&[("resize", json!({"height": 200, "width": 100}))]),
);
assert_eq!(first.key(), second.key());
}
#[test]
fn test_different_transformations_change_key() {
let blob = blob("racecar.jpg", Some("image/jpeg"));
let first = Variant::new(blob.clone(), map(&[("resize_to_limit", json!([100, 100]))]));
let second = Variant::new(blob, map(&[("resize_to_limit", json!([200, 200]))]));
assert_ne!(first.key(), second.key());
}
#[test]
fn test_variant_key_is_scoped_to_source_blob_key() {
let blob = blob("racecar.jpg", Some("image/jpeg"));
let prefix = format!("variants/{}/", blob.key());
let variant = Variant::new(blob, BTreeMap::new());
assert!(variant.key().starts_with(&prefix));
}
#[test]
fn test_format_transformation_updates_filename() {
let variant = Variant::new(
blob("racecar.jpg", Some("image/jpeg")),
map(&[("format", json!("png"))]),
);
assert_eq!(variant.filename(), "racecar.png");
assert_eq!(variant.content_type(), Some("image/png"));
}
#[test]
fn test_format_transformation_normalizes_uppercase_format() {
let variant = Variant::new(
blob("racecar.jpg", Some("image/jpeg")),
map(&[("format", json!("PNG"))]),
);
assert_eq!(variant.filename(), "racecar.png");
assert_eq!(variant.content_type(), Some("image/png"));
}
#[test]
fn test_variant_defaults_to_blob_content_type() {
let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
assert_eq!(variant.content_type(), Some("image/jpeg"));
}
#[test]
fn test_variant_uses_blob_content_type_extension_for_extensionless_filename() {
let variant = Variant::new(blob("image", Some("image/gif")), BTreeMap::new());
assert_eq!(variant.filename(), "image.gif");
assert_eq!(variant.content_type(), Some("image/gif"));
}
#[test]
fn test_variant_falls_back_to_bin_extension_without_content_type() {
let variant = Variant::new(blob("mystery", None), BTreeMap::new());
assert_eq!(variant.filename(), "mystery.bin");
assert_eq!(variant.content_type(), None);
}
#[tokio::test]
async fn test_processed_uploads_variant_placeholder() {
let service = MemoryService::new("memory").expect("service should build");
let variant = Variant::new(
blob("racecar.jpg", Some("image/jpeg")),
map(&[("resize_to_limit", json!([100, 100]))]),
);
let processed = variant
.processed(&service)
.await
.expect("processing should succeed");
assert!(
service
.exists(processed.key())
.await
.expect("exists should succeed")
);
}
#[tokio::test]
async fn test_processed_payload_records_canonicalized_fields() {
let service = MemoryService::new("memory").expect("service should build");
let source = blob("racecar.jpg", Some("image/jpeg"));
let variant = Variant::new(
source.clone(),
map(&[
("resize", json!({"width": 100, "height": 200})),
("format", json!("PNG")),
]),
);
let processed = variant
.processed(&service)
.await
.expect("processing should succeed");
let payload = service
.download(processed.key())
.await
.expect("download should succeed");
let payload: Value = serde_json::from_slice(&payload).expect("payload should decode");
assert_eq!(processed, variant);
assert_eq!(
payload,
json!({
"source_key": source.key(),
"filename": "racecar.png",
"content_type": "image/png",
"transformations": {
"format": "PNG",
"resize": {
"height": 200,
"width": 100
}
}
})
);
}
#[tokio::test]
async fn test_processed_is_idempotent() {
let service = MemoryService::new("memory").expect("service should build");
let variant = Variant::new(
blob("racecar.jpg", Some("image/jpeg")),
map(&[("resize_to_limit", json!([100, 100]))]),
);
let _ = variant
.processed(&service)
.await
.expect("processing should succeed");
let count_before = service.len();
let _ = variant
.processed(&service)
.await
.expect("processing should succeed");
assert_eq!(service.len(), count_before);
}
#[tokio::test]
async fn test_processed_rejects_invariable_blob() {
let service = MemoryService::new("memory").expect("service should build");
let variant = Variant::new(blob("report.pdf", Some("application/pdf")), BTreeMap::new());
let error = variant
.processed(&service)
.await
.expect_err("processing should fail");
assert!(
matches!(error, VariantError::Invariable(content_type) if content_type == "application/pdf")
);
}
#[tokio::test]
async fn test_is_processed_reports_state() {
let service = MemoryService::new("memory").expect("service should build");
let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
assert!(
!variant
.is_processed(&service)
.await
.expect("status should succeed")
);
let _ = variant
.processed(&service)
.await
.expect("processing should succeed");
assert!(
variant
.is_processed(&service)
.await
.expect("status should succeed")
);
}
#[test]
fn test_is_processed_sync_reports_state() {
run_sync_test(|| {
let service = MemoryService::new("memory").expect("service should build");
let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
assert!(
!variant
.is_processed_sync(&service)
.expect("status should succeed")
);
runtime::block_on(service.upload(variant.key(), Bytes::from_static(b"variant")))
.expect("upload should succeed");
assert!(
variant
.is_processed_sync(&service)
.expect("status should succeed")
);
});
}
#[test]
fn test_processed_sync_uploads_variant_placeholder() {
run_sync_test(|| {
let service = MemoryService::new("memory").expect("service should build");
let variant = Variant::new(
blob("racecar.jpg", Some("image/jpeg")),
map(&[("resize_to_limit", json!([100, 100]))]),
);
let processed = variant
.processed_sync(&service)
.expect("processing should succeed");
assert!(
runtime::block_on(service.exists(processed.key())).expect("exists should succeed")
);
});
}
#[tokio::test]
async fn test_url_delegates_to_storage_service() {
let service = MemoryService::new("memory").expect("service should build");
let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
let url = variant
.url(&service, Duration::from_secs(60))
.await
.expect("url should build");
assert!(url.as_str().contains("expires_in=60"));
}
#[test]
fn test_url_sync_delegates_to_storage_service() {
run_sync_test(|| {
let service = MemoryService::new("memory").expect("service should build");
let variant = Variant::new(blob("racecar.jpg", Some("image/jpeg")), BTreeMap::new());
let url = variant
.url_sync(&service, Duration::from_secs(60))
.expect("url should build");
assert!(url.as_str().contains("expires_in=60"));
});
}
}