use base64::prelude::*;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::clients::RestClient;
use crate::rest::{build_path, get_path, ResourceError, ResourceOperation, ResourcePath};
use crate::HttpMethod;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct Asset {
pub key: String,
#[serde(skip_serializing)]
pub public_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachment: Option<String>,
#[serde(skip_serializing)]
pub content_type: Option<String>,
#[serde(skip_serializing)]
pub size: Option<i64>,
#[serde(skip_serializing)]
pub checksum: Option<String>,
#[serde(skip_serializing)]
pub theme_id: Option<u64>,
#[serde(skip_serializing)]
pub created_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub updated_at: Option<DateTime<Utc>>,
}
impl Asset {
const PATHS: &'static [ResourcePath] = &[
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::All,
&["theme_id"],
"themes/{theme_id}/assets",
),
ResourcePath::new(
HttpMethod::Put,
ResourceOperation::Create,
&["theme_id"],
"themes/{theme_id}/assets",
),
ResourcePath::new(
HttpMethod::Put,
ResourceOperation::Update,
&["theme_id"],
"themes/{theme_id}/assets",
),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Find,
&["theme_id"],
"themes/{theme_id}/assets",
),
ResourcePath::new(
HttpMethod::Delete,
ResourceOperation::Delete,
&["theme_id"],
"themes/{theme_id}/assets",
),
];
#[must_use]
pub fn upload_from_bytes(key: &str, bytes: &[u8]) -> Self {
Self {
key: key.to_string(),
attachment: Some(BASE64_STANDARD.encode(bytes)),
value: None,
..Default::default()
}
}
pub fn download_content(&self) -> Result<Vec<u8>, ResourceError> {
if let Some(value) = &self.value {
return Ok(value.as_bytes().to_vec());
}
if let Some(attachment) = &self.attachment {
return BASE64_STANDARD.decode(attachment).map_err(|e| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: 400,
message: format!("Failed to decode base64 attachment: {e}"),
error_reference: None,
},
))
});
}
Err(ResourceError::PathResolutionFailed {
resource: "Asset",
operation: "download_content (no value or attachment)",
})
}
#[must_use]
pub const fn is_binary(&self) -> bool {
self.attachment.is_some()
}
pub async fn all_for_theme(
client: &RestClient,
theme_id: u64,
params: Option<AssetListParams>,
) -> Result<Vec<Self>, ResourceError> {
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert("theme_id", theme_id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::All, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: "Asset",
operation: "all",
},
)?;
let url = build_path(path.template, &ids);
let query = params
.map(|p| {
let mut query = HashMap::new();
if let Some(fields) = p.fields {
query.insert("fields".to_string(), fields);
}
query
})
.filter(|q| !q.is_empty());
let response = client.get(&url, query).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
"Asset",
None,
response.request_id(),
));
}
let assets: Vec<Self> = response
.body
.get("assets")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.ok_or_else(|| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: "Missing 'assets' in response".to_string(),
error_reference: response.request_id().map(ToString::to_string),
},
))
})?;
Ok(assets)
}
pub async fn find_by_key(
client: &RestClient,
theme_id: u64,
key: &str,
) -> Result<Self, ResourceError> {
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert("theme_id", theme_id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::Find, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: "Asset",
operation: "find",
},
)?;
let url = build_path(path.template, &ids);
let mut query = HashMap::new();
query.insert("asset[key]".to_string(), key.to_string());
let response = client.get(&url, Some(query)).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
"Asset",
Some(key),
response.request_id(),
));
}
let asset: Self = response
.body
.get("asset")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.ok_or_else(|| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: "Missing 'asset' in response".to_string(),
error_reference: response.request_id().map(ToString::to_string),
},
))
})?;
Ok(asset)
}
pub async fn save_to_theme(
client: &RestClient,
theme_id: u64,
asset: &Self,
) -> Result<Self, ResourceError> {
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert("theme_id", theme_id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::Update, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: "Asset",
operation: "save",
},
)?;
let url = build_path(path.template, &ids);
let mut body_map = serde_json::Map::new();
body_map.insert(
"asset".to_string(),
serde_json::to_value(asset).map_err(|e| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: 400,
message: format!("Failed to serialize asset: {e}"),
error_reference: None,
},
))
})?,
);
let body = serde_json::Value::Object(body_map);
let response = client.put(&url, body, None).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
"Asset",
Some(&asset.key),
response.request_id(),
));
}
let saved_asset: Self = response
.body
.get("asset")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.ok_or_else(|| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: "Missing 'asset' in response".to_string(),
error_reference: response.request_id().map(ToString::to_string),
},
))
})?;
Ok(saved_asset)
}
pub async fn delete_from_theme(
client: &RestClient,
theme_id: u64,
key: &str,
) -> Result<(), ResourceError> {
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert("theme_id", theme_id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::Delete, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: "Asset",
operation: "delete",
},
)?;
let url = build_path(path.template, &ids);
let mut query = HashMap::new();
query.insert("asset[key]".to_string(), key.to_string());
let response = client.delete(&url, Some(query)).await?;
if !response.is_ok() {
return Err(ResourceError::from_http_response(
response.code,
&response.body,
"Asset",
Some(key),
response.request_id(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct AssetListParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_asset_struct_serialization() {
let asset = Asset {
key: "templates/index.liquid".to_string(),
public_url: Some("https://cdn.shopify.com/asset.liquid".to_string()),
value: Some("<div>Hello World</div>".to_string()),
attachment: None,
content_type: Some("text/x-liquid".to_string()),
size: Some(1234),
checksum: Some("abc123".to_string()),
theme_id: Some(67890),
created_at: Some(
DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
.unwrap()
.with_timezone(&Utc),
),
updated_at: Some(
DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
.unwrap()
.with_timezone(&Utc),
),
};
let json = serde_json::to_string(&asset).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["key"], "templates/index.liquid");
assert_eq!(parsed["value"], "<div>Hello World</div>");
assert!(parsed.get("public_url").is_none());
assert!(parsed.get("content_type").is_none());
assert!(parsed.get("size").is_none());
assert!(parsed.get("checksum").is_none());
assert!(parsed.get("theme_id").is_none());
assert!(parsed.get("created_at").is_none());
assert!(parsed.get("updated_at").is_none());
}
#[test]
fn test_asset_deserialization_from_api_response() {
let json = r#"{
"key": "templates/index.liquid",
"public_url": "https://cdn.shopify.com/s/files/1/0001/asset.liquid",
"value": "<div>Hello World</div>",
"content_type": "text/x-liquid",
"size": 1234,
"checksum": "abc123def456",
"theme_id": 828155753,
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-06-20T15:45:00Z"
}"#;
let asset: Asset = serde_json::from_str(json).unwrap();
assert_eq!(asset.key, "templates/index.liquid");
assert_eq!(
asset.public_url,
Some("https://cdn.shopify.com/s/files/1/0001/asset.liquid".to_string())
);
assert_eq!(asset.value, Some("<div>Hello World</div>".to_string()));
assert!(asset.attachment.is_none());
assert_eq!(asset.content_type, Some("text/x-liquid".to_string()));
assert_eq!(asset.size, Some(1234));
assert_eq!(asset.checksum, Some("abc123def456".to_string()));
assert_eq!(asset.theme_id, Some(828155753));
assert!(asset.created_at.is_some());
assert!(asset.updated_at.is_some());
}
#[test]
fn test_asset_with_binary_attachment() {
let json = r#"{
"key": "assets/logo.png",
"public_url": "https://cdn.shopify.com/s/files/1/0001/logo.png",
"attachment": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
"content_type": "image/png",
"size": 85,
"checksum": "png123checksum"
}"#;
let asset: Asset = serde_json::from_str(json).unwrap();
assert_eq!(asset.key, "assets/logo.png");
assert!(asset.attachment.is_some());
assert!(asset.value.is_none());
assert!(asset.is_binary());
}
#[test]
fn test_asset_upload_from_bytes() {
let data = b"Hello, World!";
let asset = Asset::upload_from_bytes("assets/test.txt", data);
assert_eq!(asset.key, "assets/test.txt");
assert!(asset.attachment.is_some());
assert!(asset.value.is_none());
assert!(asset.is_binary());
let expected_base64 = BASE64_STANDARD.encode(data);
assert_eq!(asset.attachment, Some(expected_base64));
}
#[test]
fn test_asset_download_content_text() {
let asset = Asset {
key: "templates/index.liquid".to_string(),
value: Some("<div>Hello World</div>".to_string()),
..Default::default()
};
let content = asset.download_content().unwrap();
assert_eq!(content, b"<div>Hello World</div>");
}
#[test]
fn test_asset_download_content_binary() {
let asset = Asset {
key: "assets/test.bin".to_string(),
attachment: Some("SGVsbG8=".to_string()),
..Default::default()
};
let content = asset.download_content().unwrap();
assert_eq!(content, b"Hello");
}
#[test]
fn test_asset_download_content_no_content() {
let asset = Asset {
key: "assets/empty.txt".to_string(),
value: None,
attachment: None,
..Default::default()
};
let result = asset.download_content();
assert!(result.is_err());
}
#[test]
fn test_asset_is_binary() {
let binary_asset = Asset {
key: "assets/logo.png".to_string(),
attachment: Some("SGVsbG8=".to_string()),
..Default::default()
};
assert!(binary_asset.is_binary());
let text_asset = Asset {
key: "templates/index.liquid".to_string(),
value: Some("<div>Hello</div>".to_string()),
..Default::default()
};
assert!(!text_asset.is_binary());
let empty_asset = Asset {
key: "assets/empty".to_string(),
..Default::default()
};
assert!(!empty_asset.is_binary());
}
#[test]
fn test_asset_upload_and_download_roundtrip() {
let original_data = b"Binary data with special chars: \x00\x01\x02\xFF";
let asset = Asset::upload_from_bytes("assets/test.bin", original_data);
let downloaded = asset.download_content().unwrap();
assert_eq!(downloaded, original_data);
}
#[test]
fn test_asset_paths_are_nested_under_theme() {
let all_path = get_path(Asset::PATHS, ResourceOperation::All, &["theme_id"]);
assert!(all_path.is_some());
assert_eq!(all_path.unwrap().template, "themes/{theme_id}/assets");
let create_path = get_path(Asset::PATHS, ResourceOperation::Create, &["theme_id"]);
assert!(create_path.is_some());
assert_eq!(create_path.unwrap().template, "themes/{theme_id}/assets");
assert_eq!(create_path.unwrap().http_method, HttpMethod::Put);
let update_path = get_path(Asset::PATHS, ResourceOperation::Update, &["theme_id"]);
assert!(update_path.is_some());
assert_eq!(update_path.unwrap().template, "themes/{theme_id}/assets");
assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
let find_path = get_path(Asset::PATHS, ResourceOperation::Find, &["theme_id"]);
assert!(find_path.is_some());
assert_eq!(find_path.unwrap().template, "themes/{theme_id}/assets");
let delete_path = get_path(Asset::PATHS, ResourceOperation::Delete, &["theme_id"]);
assert!(delete_path.is_some());
assert_eq!(delete_path.unwrap().template, "themes/{theme_id}/assets");
let standalone = get_path(Asset::PATHS, ResourceOperation::All, &[]);
assert!(standalone.is_none());
}
#[test]
fn test_asset_list_params_serialization() {
let params = AssetListParams {
fields: Some("key,content_type,size".to_string()),
};
let json = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["fields"], "key,content_type,size");
let empty_params = AssetListParams::default();
let empty_json = serde_json::to_value(&empty_params).unwrap();
assert_eq!(empty_json, serde_json::json!({}));
}
#[test]
fn test_asset_key_based_identification() {
let asset = Asset {
key: "templates/product.liquid".to_string(),
value: Some("{{ product.title }}".to_string()),
..Default::default()
};
let json = serde_json::to_value(&asset).unwrap();
assert_eq!(json["key"], "templates/product.liquid");
}
}