1use std::collections::HashMap;
47
48use chrono::{DateTime, Utc};
49use serde::{Deserialize, Serialize};
50
51use crate::clients::RestClient;
52use crate::rest::{
53 build_path, get_path, ResourceError, ResourceOperation, ResourcePath, RestResource,
54};
55use crate::HttpMethod;
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
62pub struct ArticleImage {
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub src: Option<String>,
66
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub alt: Option<String>,
70
71 #[serde(skip_serializing)]
73 pub width: Option<i64>,
74
75 #[serde(skip_serializing)]
77 pub height: Option<i64>,
78
79 #[serde(skip_serializing)]
82 pub created_at: Option<DateTime<Utc>>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
118pub struct Article {
119 #[serde(skip_serializing)]
122 pub id: Option<u64>,
123
124 #[serde(skip_serializing_if = "Option::is_none")]
127 pub blog_id: Option<u64>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub title: Option<String>,
132
133 #[serde(skip_serializing_if = "Option::is_none")]
136 pub handle: Option<String>,
137
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub body_html: Option<String>,
141
142 #[serde(skip_serializing_if = "Option::is_none")]
144 pub author: Option<String>,
145
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub summary_html: Option<String>,
149
150 #[serde(skip_serializing_if = "Option::is_none")]
154 pub template_suffix: Option<String>,
155
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub tags: Option<String>,
159
160 #[serde(skip_serializing_if = "Option::is_none")]
162 pub image: Option<ArticleImage>,
163
164 #[serde(skip_serializing_if = "Option::is_none")]
168 pub published_at: Option<DateTime<Utc>>,
169
170 #[serde(skip_serializing)]
173 pub user_id: Option<u64>,
174
175 #[serde(skip_serializing)]
178 pub created_at: Option<DateTime<Utc>>,
179
180 #[serde(skip_serializing)]
183 pub updated_at: Option<DateTime<Utc>>,
184
185 #[serde(skip_serializing)]
188 pub admin_graphql_api_id: Option<String>,
189}
190
191impl Article {
192 pub async fn count_with_parent<ParentId: std::fmt::Display + Send>(
216 client: &RestClient,
217 parent_id_name: &str,
218 parent_id: ParentId,
219 params: Option<ArticleCountParams>,
220 ) -> Result<u64, ResourceError> {
221 let mut ids: HashMap<&str, String> = HashMap::new();
222 ids.insert(parent_id_name, parent_id.to_string());
223
224 let available_ids: Vec<&str> = ids.keys().copied().collect();
225 let path = get_path(Self::PATHS, ResourceOperation::Count, &available_ids).ok_or(
226 ResourceError::PathResolutionFailed {
227 resource: Self::NAME,
228 operation: "count",
229 },
230 )?;
231
232 let url = build_path(path.template, &ids);
233
234 let query = params
236 .map(|p| {
237 let value = serde_json::to_value(&p).map_err(|e| {
238 ResourceError::Http(crate::clients::HttpError::Response(
239 crate::clients::HttpResponseError {
240 code: 400,
241 message: format!("Failed to serialize params: {e}"),
242 error_reference: None,
243 },
244 ))
245 })?;
246
247 let mut query = HashMap::new();
248 if let serde_json::Value::Object(map) = value {
249 for (key, val) in map {
250 match val {
251 serde_json::Value::String(s) => {
252 query.insert(key, s);
253 }
254 serde_json::Value::Number(n) => {
255 query.insert(key, n.to_string());
256 }
257 serde_json::Value::Bool(b) => {
258 query.insert(key, b.to_string());
259 }
260 _ => {}
262 }
263 }
264 }
265 Ok::<_, ResourceError>(query)
266 })
267 .transpose()?
268 .filter(|q| !q.is_empty());
269
270 let response = client.get(&url, query).await?;
271
272 if !response.is_ok() {
273 return Err(ResourceError::from_http_response(
274 response.code,
275 &response.body,
276 Self::NAME,
277 None,
278 response.request_id(),
279 ));
280 }
281
282 let count = response
284 .body
285 .get("count")
286 .and_then(serde_json::Value::as_u64)
287 .ok_or_else(|| {
288 ResourceError::Http(crate::clients::HttpError::Response(
289 crate::clients::HttpResponseError {
290 code: response.code,
291 message: "Missing 'count' in response".to_string(),
292 error_reference: response.request_id().map(ToString::to_string),
293 },
294 ))
295 })?;
296
297 Ok(count)
298 }
299}
300
301impl RestResource for Article {
302 type Id = u64;
303 type FindParams = ArticleFindParams;
304 type AllParams = ArticleListParams;
305 type CountParams = ArticleCountParams;
306
307 const NAME: &'static str = "Article";
308 const PLURAL: &'static str = "articles";
309
310 const PATHS: &'static [ResourcePath] = &[
315 ResourcePath::new(
317 HttpMethod::Get,
318 ResourceOperation::Find,
319 &["blog_id", "id"],
320 "blogs/{blog_id}/articles/{id}",
321 ),
322 ResourcePath::new(
323 HttpMethod::Get,
324 ResourceOperation::All,
325 &["blog_id"],
326 "blogs/{blog_id}/articles",
327 ),
328 ResourcePath::new(
329 HttpMethod::Get,
330 ResourceOperation::Count,
331 &["blog_id"],
332 "blogs/{blog_id}/articles/count",
333 ),
334 ResourcePath::new(
335 HttpMethod::Post,
336 ResourceOperation::Create,
337 &["blog_id"],
338 "blogs/{blog_id}/articles",
339 ),
340 ResourcePath::new(
341 HttpMethod::Put,
342 ResourceOperation::Update,
343 &["blog_id", "id"],
344 "blogs/{blog_id}/articles/{id}",
345 ),
346 ResourcePath::new(
347 HttpMethod::Delete,
348 ResourceOperation::Delete,
349 &["blog_id", "id"],
350 "blogs/{blog_id}/articles/{id}",
351 ),
352 ];
353
354 fn get_id(&self) -> Option<Self::Id> {
355 self.id
356 }
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
361pub struct ArticleFindParams {
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub fields: Option<String>,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
369pub struct ArticleListParams {
370 #[serde(skip_serializing_if = "Option::is_none")]
372 pub author: Option<String>,
373
374 #[serde(skip_serializing_if = "Option::is_none")]
376 pub handle: Option<String>,
377
378 #[serde(skip_serializing_if = "Option::is_none")]
380 pub tag: Option<String>,
381
382 #[serde(skip_serializing_if = "Option::is_none")]
385 pub published_status: Option<String>,
386
387 #[serde(skip_serializing_if = "Option::is_none")]
389 pub created_at_min: Option<DateTime<Utc>>,
390
391 #[serde(skip_serializing_if = "Option::is_none")]
393 pub created_at_max: Option<DateTime<Utc>>,
394
395 #[serde(skip_serializing_if = "Option::is_none")]
397 pub updated_at_min: Option<DateTime<Utc>>,
398
399 #[serde(skip_serializing_if = "Option::is_none")]
401 pub updated_at_max: Option<DateTime<Utc>>,
402
403 #[serde(skip_serializing_if = "Option::is_none")]
405 pub published_at_min: Option<DateTime<Utc>>,
406
407 #[serde(skip_serializing_if = "Option::is_none")]
409 pub published_at_max: Option<DateTime<Utc>>,
410
411 #[serde(skip_serializing_if = "Option::is_none")]
413 pub limit: Option<u32>,
414
415 #[serde(skip_serializing_if = "Option::is_none")]
417 pub since_id: Option<u64>,
418
419 #[serde(skip_serializing_if = "Option::is_none")]
421 pub page_info: Option<String>,
422
423 #[serde(skip_serializing_if = "Option::is_none")]
425 pub fields: Option<String>,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
430pub struct ArticleCountParams {
431 #[serde(skip_serializing_if = "Option::is_none")]
433 pub author: Option<String>,
434
435 #[serde(skip_serializing_if = "Option::is_none")]
437 pub tag: Option<String>,
438
439 #[serde(skip_serializing_if = "Option::is_none")]
442 pub published_status: Option<String>,
443
444 #[serde(skip_serializing_if = "Option::is_none")]
446 pub created_at_min: Option<DateTime<Utc>>,
447
448 #[serde(skip_serializing_if = "Option::is_none")]
450 pub created_at_max: Option<DateTime<Utc>>,
451
452 #[serde(skip_serializing_if = "Option::is_none")]
454 pub updated_at_min: Option<DateTime<Utc>>,
455
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub updated_at_max: Option<DateTime<Utc>>,
459
460 #[serde(skip_serializing_if = "Option::is_none")]
462 pub published_at_min: Option<DateTime<Utc>>,
463
464 #[serde(skip_serializing_if = "Option::is_none")]
466 pub published_at_max: Option<DateTime<Utc>>,
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use crate::rest::{get_path, ResourceOperation};
473
474 #[test]
475 fn test_article_struct_serialization() {
476 let article = Article {
477 id: Some(12345),
478 blog_id: Some(67890),
479 title: Some("New Blog Post".to_string()),
480 handle: Some("new-blog-post".to_string()),
481 body_html: Some("<p>This is the article content.</p>".to_string()),
482 author: Some("Jane Doe".to_string()),
483 summary_html: Some("<p>Article summary.</p>".to_string()),
484 template_suffix: Some("custom".to_string()),
485 tags: Some("tech, rust, web".to_string()),
486 image: Some(ArticleImage {
487 src: Some("https://cdn.shopify.com/article.jpg".to_string()),
488 alt: Some("Article image".to_string()),
489 width: Some(1200),
490 height: Some(800),
491 created_at: None,
492 }),
493 published_at: Some(
494 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
495 .unwrap()
496 .with_timezone(&Utc),
497 ),
498 user_id: Some(111222),
499 created_at: Some(
500 DateTime::parse_from_rfc3339("2024-01-10T08:00:00Z")
501 .unwrap()
502 .with_timezone(&Utc),
503 ),
504 updated_at: Some(
505 DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
506 .unwrap()
507 .with_timezone(&Utc),
508 ),
509 admin_graphql_api_id: Some("gid://shopify/OnlineStoreArticle/12345".to_string()),
510 };
511
512 let json = serde_json::to_string(&article).unwrap();
513 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
514
515 assert_eq!(parsed["blog_id"], 67890);
517 assert_eq!(parsed["title"], "New Blog Post");
518 assert_eq!(parsed["handle"], "new-blog-post");
519 assert_eq!(parsed["body_html"], "<p>This is the article content.</p>");
520 assert_eq!(parsed["author"], "Jane Doe");
521 assert_eq!(parsed["summary_html"], "<p>Article summary.</p>");
522 assert_eq!(parsed["template_suffix"], "custom");
523 assert_eq!(parsed["tags"], "tech, rust, web");
524 assert!(parsed["published_at"].as_str().is_some());
525 assert_eq!(
526 parsed["image"]["src"],
527 "https://cdn.shopify.com/article.jpg"
528 );
529 assert_eq!(parsed["image"]["alt"], "Article image");
530
531 assert!(parsed.get("id").is_none());
533 assert!(parsed.get("user_id").is_none());
534 assert!(parsed.get("created_at").is_none());
535 assert!(parsed.get("updated_at").is_none());
536 assert!(parsed.get("admin_graphql_api_id").is_none());
537
538 assert!(parsed["image"].get("width").is_none());
540 assert!(parsed["image"].get("height").is_none());
541 }
542
543 #[test]
544 fn test_article_deserialization_from_api_response() {
545 let json = r#"{
546 "id": 134645308,
547 "blog_id": 241253187,
548 "title": "My new blog post",
549 "handle": "my-new-blog-post",
550 "body_html": "<p>This is the content of the article.</p>",
551 "author": "John Smith",
552 "summary_html": "<p>Summary here.</p>",
553 "template_suffix": null,
554 "tags": "tech, news",
555 "image": {
556 "src": "https://cdn.shopify.com/s/files/1/article.jpg",
557 "alt": "Blog image",
558 "width": 1200,
559 "height": 800,
560 "created_at": "2024-01-15T10:30:00Z"
561 },
562 "published_at": "2024-01-15T10:30:00Z",
563 "user_id": 799407056,
564 "created_at": "2024-01-10T08:00:00Z",
565 "updated_at": "2024-06-20T15:45:00Z",
566 "admin_graphql_api_id": "gid://shopify/OnlineStoreArticle/134645308"
567 }"#;
568
569 let article: Article = serde_json::from_str(json).unwrap();
570
571 assert_eq!(article.id, Some(134645308));
572 assert_eq!(article.blog_id, Some(241253187));
573 assert_eq!(article.title, Some("My new blog post".to_string()));
574 assert_eq!(article.handle, Some("my-new-blog-post".to_string()));
575 assert_eq!(
576 article.body_html,
577 Some("<p>This is the content of the article.</p>".to_string())
578 );
579 assert_eq!(article.author, Some("John Smith".to_string()));
580 assert_eq!(
581 article.summary_html,
582 Some("<p>Summary here.</p>".to_string())
583 );
584 assert!(article.template_suffix.is_none());
585 assert_eq!(article.tags, Some("tech, news".to_string()));
586 assert!(article.image.is_some());
587 let image = article.image.unwrap();
588 assert_eq!(
589 image.src,
590 Some("https://cdn.shopify.com/s/files/1/article.jpg".to_string())
591 );
592 assert_eq!(image.alt, Some("Blog image".to_string()));
593 assert_eq!(image.width, Some(1200));
594 assert_eq!(image.height, Some(800));
595 assert!(image.created_at.is_some());
596 assert!(article.published_at.is_some());
597 assert_eq!(article.user_id, Some(799407056));
598 assert!(article.created_at.is_some());
599 assert!(article.updated_at.is_some());
600 assert_eq!(
601 article.admin_graphql_api_id,
602 Some("gid://shopify/OnlineStoreArticle/134645308".to_string())
603 );
604 }
605
606 #[test]
607 fn test_article_nested_paths_require_blog_id() {
608 let find_path = get_path(Article::PATHS, ResourceOperation::Find, &["blog_id", "id"]);
612 assert!(find_path.is_some());
613 assert_eq!(find_path.unwrap().template, "blogs/{blog_id}/articles/{id}");
614
615 let find_without_blog = get_path(Article::PATHS, ResourceOperation::Find, &["id"]);
617 assert!(find_without_blog.is_none());
618
619 let all_path = get_path(Article::PATHS, ResourceOperation::All, &["blog_id"]);
621 assert!(all_path.is_some());
622 assert_eq!(all_path.unwrap().template, "blogs/{blog_id}/articles");
623
624 let all_without_blog = get_path(Article::PATHS, ResourceOperation::All, &[]);
626 assert!(all_without_blog.is_none());
627
628 let count_path = get_path(Article::PATHS, ResourceOperation::Count, &["blog_id"]);
630 assert!(count_path.is_some());
631 assert_eq!(
632 count_path.unwrap().template,
633 "blogs/{blog_id}/articles/count"
634 );
635
636 let create_path = get_path(Article::PATHS, ResourceOperation::Create, &["blog_id"]);
638 assert!(create_path.is_some());
639 assert_eq!(create_path.unwrap().template, "blogs/{blog_id}/articles");
640
641 let update_path = get_path(
643 Article::PATHS,
644 ResourceOperation::Update,
645 &["blog_id", "id"],
646 );
647 assert!(update_path.is_some());
648 assert_eq!(
649 update_path.unwrap().template,
650 "blogs/{blog_id}/articles/{id}"
651 );
652
653 let delete_path = get_path(
655 Article::PATHS,
656 ResourceOperation::Delete,
657 &["blog_id", "id"],
658 );
659 assert!(delete_path.is_some());
660 assert_eq!(
661 delete_path.unwrap().template,
662 "blogs/{blog_id}/articles/{id}"
663 );
664 }
665
666 #[test]
667 fn test_article_list_params_serialization() {
668 let params = ArticleListParams {
669 author: Some("Jane Doe".to_string()),
670 handle: Some("my-post".to_string()),
671 tag: Some("tech".to_string()),
672 published_status: Some("published".to_string()),
673 limit: Some(50),
674 since_id: Some(100),
675 ..Default::default()
676 };
677
678 let json = serde_json::to_value(¶ms).unwrap();
679
680 assert_eq!(json["author"], "Jane Doe");
681 assert_eq!(json["handle"], "my-post");
682 assert_eq!(json["tag"], "tech");
683 assert_eq!(json["published_status"], "published");
684 assert_eq!(json["limit"], 50);
685 assert_eq!(json["since_id"], 100);
686
687 assert!(json.get("created_at_min").is_none());
689 assert!(json.get("page_info").is_none());
690 }
691
692 #[test]
693 fn test_article_count_params_serialization() {
694 let params = ArticleCountParams {
695 author: Some("Jane Doe".to_string()),
696 tag: Some("tech".to_string()),
697 published_status: Some("any".to_string()),
698 ..Default::default()
699 };
700
701 let json = serde_json::to_value(¶ms).unwrap();
702
703 assert_eq!(json["author"], "Jane Doe");
704 assert_eq!(json["tag"], "tech");
705 assert_eq!(json["published_status"], "any");
706
707 let empty_params = ArticleCountParams::default();
709 let empty_json = serde_json::to_value(&empty_params).unwrap();
710 assert_eq!(empty_json, serde_json::json!({}));
711 }
712
713 #[test]
714 fn test_article_tags_field_handling() {
715 let article = Article {
717 title: Some("Tech Article".to_string()),
718 tags: Some("rust, programming, web, api".to_string()),
719 ..Default::default()
720 };
721
722 let json = serde_json::to_value(&article).unwrap();
723 assert_eq!(json["tags"], "rust, programming, web, api");
724
725 let deserialized: Article = serde_json::from_value(json).unwrap();
727 assert_eq!(
728 deserialized.tags,
729 Some("rust, programming, web, api".to_string())
730 );
731 }
732
733 #[test]
734 fn test_article_get_id_returns_correct_value() {
735 let article_with_id = Article {
737 id: Some(123456789),
738 blog_id: Some(987654321),
739 title: Some("Test Article".to_string()),
740 ..Default::default()
741 };
742 assert_eq!(article_with_id.get_id(), Some(123456789));
743
744 let article_without_id = Article {
746 id: None,
747 blog_id: Some(987654321),
748 title: Some("New Article".to_string()),
749 ..Default::default()
750 };
751 assert_eq!(article_without_id.get_id(), None);
752 }
753
754 #[test]
755 fn test_article_image_struct() {
756 let image = ArticleImage {
757 src: Some("https://cdn.shopify.com/article-img.jpg".to_string()),
758 alt: Some("Featured image".to_string()),
759 width: Some(1920),
760 height: Some(1080),
761 created_at: Some(
762 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
763 .unwrap()
764 .with_timezone(&Utc),
765 ),
766 };
767
768 let json = serde_json::to_value(&image).unwrap();
769
770 assert_eq!(json["src"], "https://cdn.shopify.com/article-img.jpg");
772 assert_eq!(json["alt"], "Featured image");
773
774 assert!(json.get("width").is_none());
776 assert!(json.get("height").is_none());
777 assert!(json.get("created_at").is_none());
778 }
779
780 #[test]
781 fn test_article_constants() {
782 assert_eq!(Article::NAME, "Article");
783 assert_eq!(Article::PLURAL, "articles");
784 }
785}