use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::clients::RestClient;
use crate::rest::{
build_path, get_path, ResourceError, ResourceOperation, ResourcePath, RestResource,
};
use crate::HttpMethod;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ArticleImage {
#[serde(skip_serializing_if = "Option::is_none")]
pub src: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alt: Option<String>,
#[serde(skip_serializing)]
pub width: Option<i64>,
#[serde(skip_serializing)]
pub height: Option<i64>,
#[serde(skip_serializing)]
pub created_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct Article {
#[serde(skip_serializing)]
pub id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub blog_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body_html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary_html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub template_suffix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tags: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<ArticleImage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub user_id: Option<u64>,
#[serde(skip_serializing)]
pub created_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub updated_at: Option<DateTime<Utc>>,
#[serde(skip_serializing)]
pub admin_graphql_api_id: Option<String>,
}
impl Article {
pub async fn count_with_parent<ParentId: std::fmt::Display + Send>(
client: &RestClient,
parent_id_name: &str,
parent_id: ParentId,
params: Option<ArticleCountParams>,
) -> Result<u64, ResourceError> {
let mut ids: HashMap<&str, String> = HashMap::new();
ids.insert(parent_id_name, parent_id.to_string());
let available_ids: Vec<&str> = ids.keys().copied().collect();
let path = get_path(Self::PATHS, ResourceOperation::Count, &available_ids).ok_or(
ResourceError::PathResolutionFailed {
resource: Self::NAME,
operation: "count",
},
)?;
let url = build_path(path.template, &ids);
let query = params
.map(|p| {
let value = serde_json::to_value(&p).map_err(|e| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: 400,
message: format!("Failed to serialize params: {e}"),
error_reference: None,
},
))
})?;
let mut query = HashMap::new();
if let serde_json::Value::Object(map) = value {
for (key, val) in map {
match val {
serde_json::Value::String(s) => {
query.insert(key, s);
}
serde_json::Value::Number(n) => {
query.insert(key, n.to_string());
}
serde_json::Value::Bool(b) => {
query.insert(key, b.to_string());
}
_ => {}
}
}
}
Ok::<_, ResourceError>(query)
})
.transpose()?
.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,
Self::NAME,
None,
response.request_id(),
));
}
let count = response
.body
.get("count")
.and_then(serde_json::Value::as_u64)
.ok_or_else(|| {
ResourceError::Http(crate::clients::HttpError::Response(
crate::clients::HttpResponseError {
code: response.code,
message: "Missing 'count' in response".to_string(),
error_reference: response.request_id().map(ToString::to_string),
},
))
})?;
Ok(count)
}
}
impl RestResource for Article {
type Id = u64;
type FindParams = ArticleFindParams;
type AllParams = ArticleListParams;
type CountParams = ArticleCountParams;
const NAME: &'static str = "Article";
const PLURAL: &'static str = "articles";
const PATHS: &'static [ResourcePath] = &[
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Find,
&["blog_id", "id"],
"blogs/{blog_id}/articles/{id}",
),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::All,
&["blog_id"],
"blogs/{blog_id}/articles",
),
ResourcePath::new(
HttpMethod::Get,
ResourceOperation::Count,
&["blog_id"],
"blogs/{blog_id}/articles/count",
),
ResourcePath::new(
HttpMethod::Post,
ResourceOperation::Create,
&["blog_id"],
"blogs/{blog_id}/articles",
),
ResourcePath::new(
HttpMethod::Put,
ResourceOperation::Update,
&["blog_id", "id"],
"blogs/{blog_id}/articles/{id}",
),
ResourcePath::new(
HttpMethod::Delete,
ResourceOperation::Delete,
&["blog_id", "id"],
"blogs/{blog_id}/articles/{id}",
),
];
fn get_id(&self) -> Option<Self::Id> {
self.id
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ArticleFindParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ArticleListParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at_max: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at_max: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at_max: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub since_id: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page_info: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct ArticleCountParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at_max: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated_at_max: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at_min: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub published_at_max: Option<DateTime<Utc>>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rest::{get_path, ResourceOperation};
#[test]
fn test_article_struct_serialization() {
let article = Article {
id: Some(12345),
blog_id: Some(67890),
title: Some("New Blog Post".to_string()),
handle: Some("new-blog-post".to_string()),
body_html: Some("<p>This is the article content.</p>".to_string()),
author: Some("Jane Doe".to_string()),
summary_html: Some("<p>Article summary.</p>".to_string()),
template_suffix: Some("custom".to_string()),
tags: Some("tech, rust, web".to_string()),
image: Some(ArticleImage {
src: Some("https://cdn.shopify.com/article.jpg".to_string()),
alt: Some("Article image".to_string()),
width: Some(1200),
height: Some(800),
created_at: None,
}),
published_at: Some(
DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
.unwrap()
.with_timezone(&Utc),
),
user_id: Some(111222),
created_at: Some(
DateTime::parse_from_rfc3339("2024-01-10T08:00:00Z")
.unwrap()
.with_timezone(&Utc),
),
updated_at: Some(
DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
.unwrap()
.with_timezone(&Utc),
),
admin_graphql_api_id: Some("gid://shopify/OnlineStoreArticle/12345".to_string()),
};
let json = serde_json::to_string(&article).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["blog_id"], 67890);
assert_eq!(parsed["title"], "New Blog Post");
assert_eq!(parsed["handle"], "new-blog-post");
assert_eq!(parsed["body_html"], "<p>This is the article content.</p>");
assert_eq!(parsed["author"], "Jane Doe");
assert_eq!(parsed["summary_html"], "<p>Article summary.</p>");
assert_eq!(parsed["template_suffix"], "custom");
assert_eq!(parsed["tags"], "tech, rust, web");
assert!(parsed["published_at"].as_str().is_some());
assert_eq!(
parsed["image"]["src"],
"https://cdn.shopify.com/article.jpg"
);
assert_eq!(parsed["image"]["alt"], "Article image");
assert!(parsed.get("id").is_none());
assert!(parsed.get("user_id").is_none());
assert!(parsed.get("created_at").is_none());
assert!(parsed.get("updated_at").is_none());
assert!(parsed.get("admin_graphql_api_id").is_none());
assert!(parsed["image"].get("width").is_none());
assert!(parsed["image"].get("height").is_none());
}
#[test]
fn test_article_deserialization_from_api_response() {
let json = r#"{
"id": 134645308,
"blog_id": 241253187,
"title": "My new blog post",
"handle": "my-new-blog-post",
"body_html": "<p>This is the content of the article.</p>",
"author": "John Smith",
"summary_html": "<p>Summary here.</p>",
"template_suffix": null,
"tags": "tech, news",
"image": {
"src": "https://cdn.shopify.com/s/files/1/article.jpg",
"alt": "Blog image",
"width": 1200,
"height": 800,
"created_at": "2024-01-15T10:30:00Z"
},
"published_at": "2024-01-15T10:30:00Z",
"user_id": 799407056,
"created_at": "2024-01-10T08:00:00Z",
"updated_at": "2024-06-20T15:45:00Z",
"admin_graphql_api_id": "gid://shopify/OnlineStoreArticle/134645308"
}"#;
let article: Article = serde_json::from_str(json).unwrap();
assert_eq!(article.id, Some(134645308));
assert_eq!(article.blog_id, Some(241253187));
assert_eq!(article.title, Some("My new blog post".to_string()));
assert_eq!(article.handle, Some("my-new-blog-post".to_string()));
assert_eq!(
article.body_html,
Some("<p>This is the content of the article.</p>".to_string())
);
assert_eq!(article.author, Some("John Smith".to_string()));
assert_eq!(
article.summary_html,
Some("<p>Summary here.</p>".to_string())
);
assert!(article.template_suffix.is_none());
assert_eq!(article.tags, Some("tech, news".to_string()));
assert!(article.image.is_some());
let image = article.image.unwrap();
assert_eq!(
image.src,
Some("https://cdn.shopify.com/s/files/1/article.jpg".to_string())
);
assert_eq!(image.alt, Some("Blog image".to_string()));
assert_eq!(image.width, Some(1200));
assert_eq!(image.height, Some(800));
assert!(image.created_at.is_some());
assert!(article.published_at.is_some());
assert_eq!(article.user_id, Some(799407056));
assert!(article.created_at.is_some());
assert!(article.updated_at.is_some());
assert_eq!(
article.admin_graphql_api_id,
Some("gid://shopify/OnlineStoreArticle/134645308".to_string())
);
}
#[test]
fn test_article_nested_paths_require_blog_id() {
let find_path = get_path(Article::PATHS, ResourceOperation::Find, &["blog_id", "id"]);
assert!(find_path.is_some());
assert_eq!(find_path.unwrap().template, "blogs/{blog_id}/articles/{id}");
let find_without_blog = get_path(Article::PATHS, ResourceOperation::Find, &["id"]);
assert!(find_without_blog.is_none());
let all_path = get_path(Article::PATHS, ResourceOperation::All, &["blog_id"]);
assert!(all_path.is_some());
assert_eq!(all_path.unwrap().template, "blogs/{blog_id}/articles");
let all_without_blog = get_path(Article::PATHS, ResourceOperation::All, &[]);
assert!(all_without_blog.is_none());
let count_path = get_path(Article::PATHS, ResourceOperation::Count, &["blog_id"]);
assert!(count_path.is_some());
assert_eq!(
count_path.unwrap().template,
"blogs/{blog_id}/articles/count"
);
let create_path = get_path(Article::PATHS, ResourceOperation::Create, &["blog_id"]);
assert!(create_path.is_some());
assert_eq!(create_path.unwrap().template, "blogs/{blog_id}/articles");
let update_path = get_path(
Article::PATHS,
ResourceOperation::Update,
&["blog_id", "id"],
);
assert!(update_path.is_some());
assert_eq!(
update_path.unwrap().template,
"blogs/{blog_id}/articles/{id}"
);
let delete_path = get_path(
Article::PATHS,
ResourceOperation::Delete,
&["blog_id", "id"],
);
assert!(delete_path.is_some());
assert_eq!(
delete_path.unwrap().template,
"blogs/{blog_id}/articles/{id}"
);
}
#[test]
fn test_article_list_params_serialization() {
let params = ArticleListParams {
author: Some("Jane Doe".to_string()),
handle: Some("my-post".to_string()),
tag: Some("tech".to_string()),
published_status: Some("published".to_string()),
limit: Some(50),
since_id: Some(100),
..Default::default()
};
let json = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["author"], "Jane Doe");
assert_eq!(json["handle"], "my-post");
assert_eq!(json["tag"], "tech");
assert_eq!(json["published_status"], "published");
assert_eq!(json["limit"], 50);
assert_eq!(json["since_id"], 100);
assert!(json.get("created_at_min").is_none());
assert!(json.get("page_info").is_none());
}
#[test]
fn test_article_count_params_serialization() {
let params = ArticleCountParams {
author: Some("Jane Doe".to_string()),
tag: Some("tech".to_string()),
published_status: Some("any".to_string()),
..Default::default()
};
let json = serde_json::to_value(¶ms).unwrap();
assert_eq!(json["author"], "Jane Doe");
assert_eq!(json["tag"], "tech");
assert_eq!(json["published_status"], "any");
let empty_params = ArticleCountParams::default();
let empty_json = serde_json::to_value(&empty_params).unwrap();
assert_eq!(empty_json, serde_json::json!({}));
}
#[test]
fn test_article_tags_field_handling() {
let article = Article {
title: Some("Tech Article".to_string()),
tags: Some("rust, programming, web, api".to_string()),
..Default::default()
};
let json = serde_json::to_value(&article).unwrap();
assert_eq!(json["tags"], "rust, programming, web, api");
let deserialized: Article = serde_json::from_value(json).unwrap();
assert_eq!(
deserialized.tags,
Some("rust, programming, web, api".to_string())
);
}
#[test]
fn test_article_get_id_returns_correct_value() {
let article_with_id = Article {
id: Some(123456789),
blog_id: Some(987654321),
title: Some("Test Article".to_string()),
..Default::default()
};
assert_eq!(article_with_id.get_id(), Some(123456789));
let article_without_id = Article {
id: None,
blog_id: Some(987654321),
title: Some("New Article".to_string()),
..Default::default()
};
assert_eq!(article_without_id.get_id(), None);
}
#[test]
fn test_article_image_struct() {
let image = ArticleImage {
src: Some("https://cdn.shopify.com/article-img.jpg".to_string()),
alt: Some("Featured image".to_string()),
width: Some(1920),
height: Some(1080),
created_at: Some(
DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
.unwrap()
.with_timezone(&Utc),
),
};
let json = serde_json::to_value(&image).unwrap();
assert_eq!(json["src"], "https://cdn.shopify.com/article-img.jpg");
assert_eq!(json["alt"], "Featured image");
assert!(json.get("width").is_none());
assert!(json.get("height").is_none());
assert!(json.get("created_at").is_none());
}
#[test]
fn test_article_constants() {
assert_eq!(Article::NAME, "Article");
assert_eq!(Article::PLURAL, "articles");
}
}