1use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36
37use crate::rest::{ResourceOperation, ResourcePath, RestResource};
38use crate::HttpMethod;
39
40use super::common::{ProductImage, ProductOption};
41
42#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
46#[serde(rename_all = "lowercase")]
47pub enum ProductStatus {
48 #[default]
50 Active,
51 Archived,
53 Draft,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
64pub struct ProductVariant {
65 #[serde(skip_serializing)]
67 pub id: Option<u64>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub product_id: Option<u64>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub title: Option<String>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub price: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub compare_at_price: Option<String>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub sku: Option<String>,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub position: Option<i64>,
92
93 #[serde(skip_serializing)]
96 pub inventory_quantity: Option<i64>,
97
98 #[serde(skip_serializing_if = "Option::is_none")]
100 pub option1: Option<String>,
101
102 #[serde(skip_serializing_if = "Option::is_none")]
104 pub option2: Option<String>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub option3: Option<String>,
109
110 #[serde(skip_serializing_if = "Option::is_none")]
112 pub image_id: Option<u64>,
113
114 #[serde(skip_serializing)]
117 pub created_at: Option<DateTime<Utc>>,
118
119 #[serde(skip_serializing)]
122 pub updated_at: Option<DateTime<Utc>>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
156pub struct Product {
157 #[serde(skip_serializing)]
160 pub id: Option<u64>,
161
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub title: Option<String>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub body_html: Option<String>,
169
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub vendor: Option<String>,
173
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub product_type: Option<String>,
177
178 #[serde(skip_serializing)]
181 pub handle: Option<String>,
182
183 #[serde(skip_serializing)]
186 pub created_at: Option<DateTime<Utc>>,
187
188 #[serde(skip_serializing)]
191 pub updated_at: Option<DateTime<Utc>>,
192
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub published_at: Option<DateTime<Utc>>,
196
197 #[serde(skip_serializing_if = "Option::is_none")]
200 pub published_scope: Option<String>,
201
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub status: Option<ProductStatus>,
205
206 #[serde(skip_serializing_if = "Option::is_none")]
208 pub tags: Option<String>,
209
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub template_suffix: Option<String>,
213
214 #[serde(skip_serializing)]
217 pub admin_graphql_api_id: Option<String>,
218
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub variants: Option<Vec<ProductVariant>>,
222
223 #[serde(skip_serializing_if = "Option::is_none")]
225 pub options: Option<Vec<ProductOption>>,
226
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub images: Option<Vec<ProductImage>>,
230
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub image: Option<ProductImage>,
234}
235
236impl RestResource for Product {
237 type Id = u64;
238 type FindParams = ProductFindParams;
239 type AllParams = ProductListParams;
240 type CountParams = ProductCountParams;
241
242 const NAME: &'static str = "Product";
243 const PLURAL: &'static str = "products";
244
245 const PATHS: &'static [ResourcePath] = &[
246 ResourcePath::new(
247 HttpMethod::Get,
248 ResourceOperation::Find,
249 &["id"],
250 "products/{id}",
251 ),
252 ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "products"),
253 ResourcePath::new(
254 HttpMethod::Get,
255 ResourceOperation::Count,
256 &[],
257 "products/count",
258 ),
259 ResourcePath::new(HttpMethod::Post, ResourceOperation::Create, &[], "products"),
260 ResourcePath::new(
261 HttpMethod::Put,
262 ResourceOperation::Update,
263 &["id"],
264 "products/{id}",
265 ),
266 ResourcePath::new(
267 HttpMethod::Delete,
268 ResourceOperation::Delete,
269 &["id"],
270 "products/{id}",
271 ),
272 ];
273
274 fn get_id(&self) -> Option<Self::Id> {
275 self.id
276 }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
281pub struct ProductFindParams {
282 #[serde(skip_serializing_if = "Option::is_none")]
284 pub fields: Option<String>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
289pub struct ProductListParams {
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub ids: Option<Vec<u64>>,
293
294 #[serde(skip_serializing_if = "Option::is_none")]
296 pub limit: Option<u32>,
297
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub since_id: Option<u64>,
301
302 #[serde(skip_serializing_if = "Option::is_none")]
304 pub title: Option<String>,
305
306 #[serde(skip_serializing_if = "Option::is_none")]
308 pub vendor: Option<String>,
309
310 #[serde(skip_serializing_if = "Option::is_none")]
312 pub handle: Option<String>,
313
314 #[serde(skip_serializing_if = "Option::is_none")]
316 pub product_type: Option<String>,
317
318 #[serde(skip_serializing_if = "Option::is_none")]
320 pub collection_id: Option<u64>,
321
322 #[serde(skip_serializing_if = "Option::is_none")]
324 pub created_at_min: Option<DateTime<Utc>>,
325
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub created_at_max: Option<DateTime<Utc>>,
329
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub updated_at_min: Option<DateTime<Utc>>,
333
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub updated_at_max: Option<DateTime<Utc>>,
337
338 #[serde(skip_serializing_if = "Option::is_none")]
340 pub published_at_min: Option<DateTime<Utc>>,
341
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub published_at_max: Option<DateTime<Utc>>,
345
346 #[serde(skip_serializing_if = "Option::is_none")]
349 pub published_status: Option<String>,
350
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub status: Option<ProductStatus>,
354
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub fields: Option<String>,
358
359 #[serde(skip_serializing_if = "Option::is_none")]
361 pub page_info: Option<String>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
366pub struct ProductCountParams {
367 #[serde(skip_serializing_if = "Option::is_none")]
369 pub vendor: Option<String>,
370
371 #[serde(skip_serializing_if = "Option::is_none")]
373 pub product_type: Option<String>,
374
375 #[serde(skip_serializing_if = "Option::is_none")]
377 pub collection_id: Option<u64>,
378
379 #[serde(skip_serializing_if = "Option::is_none")]
381 pub created_at_min: Option<DateTime<Utc>>,
382
383 #[serde(skip_serializing_if = "Option::is_none")]
385 pub created_at_max: Option<DateTime<Utc>>,
386
387 #[serde(skip_serializing_if = "Option::is_none")]
389 pub updated_at_min: Option<DateTime<Utc>>,
390
391 #[serde(skip_serializing_if = "Option::is_none")]
393 pub updated_at_max: Option<DateTime<Utc>>,
394
395 #[serde(skip_serializing_if = "Option::is_none")]
397 pub published_at_min: Option<DateTime<Utc>>,
398
399 #[serde(skip_serializing_if = "Option::is_none")]
401 pub published_at_max: Option<DateTime<Utc>>,
402
403 #[serde(skip_serializing_if = "Option::is_none")]
406 pub published_status: Option<String>,
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use crate::rest::{get_path, ResourceOperation};
413
414 #[test]
415 fn test_product_serialization_with_all_fields() {
416 let product = Product {
417 id: Some(12345), title: Some("Test Product".to_string()),
419 body_html: Some("<p>Description</p>".to_string()),
420 vendor: Some("Test Vendor".to_string()),
421 product_type: Some("T-Shirts".to_string()),
422 handle: Some("test-product".to_string()), created_at: Some(
424 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
425 .unwrap()
426 .with_timezone(&Utc),
427 ), updated_at: Some(
429 DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
430 .unwrap()
431 .with_timezone(&Utc),
432 ), published_at: Some(
434 DateTime::parse_from_rfc3339("2024-01-20T12:00:00Z")
435 .unwrap()
436 .with_timezone(&Utc),
437 ),
438 published_scope: Some("global".to_string()),
439 status: Some(ProductStatus::Active),
440 tags: Some("summer, sale, featured".to_string()),
441 template_suffix: Some("custom".to_string()),
442 admin_graphql_api_id: Some("gid://shopify/Product/12345".to_string()), variants: Some(vec![ProductVariant {
444 id: Some(111),
445 title: Some("Default".to_string()),
446 price: Some("29.99".to_string()),
447 ..Default::default()
448 }]),
449 options: Some(vec![ProductOption {
450 name: Some("Size".to_string()),
451 values: Some(vec!["Small".to_string(), "Medium".to_string()]),
452 ..Default::default()
453 }]),
454 images: Some(vec![]),
455 image: None,
456 };
457
458 let json = serde_json::to_string(&product).unwrap();
459 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
460
461 assert_eq!(parsed["title"], "Test Product");
463 assert_eq!(parsed["body_html"], "<p>Description</p>");
464 assert_eq!(parsed["vendor"], "Test Vendor");
465 assert_eq!(parsed["product_type"], "T-Shirts");
466 assert_eq!(parsed["published_scope"], "global");
467 assert_eq!(parsed["status"], "active");
468 assert_eq!(parsed["tags"], "summer, sale, featured");
469 assert_eq!(parsed["template_suffix"], "custom");
470
471 assert!(parsed.get("id").is_none());
473 assert!(parsed.get("handle").is_none());
474 assert!(parsed.get("created_at").is_none());
475 assert!(parsed.get("updated_at").is_none());
476 assert!(parsed.get("admin_graphql_api_id").is_none());
477 }
478
479 #[test]
480 fn test_product_deserialization_from_api_response() {
481 let json = r#"{
482 "id": 788032119674292922,
483 "title": "Example T-Shirt",
484 "body_html": "<strong>Good cotton T-shirt</strong>",
485 "vendor": "Acme",
486 "product_type": "Shirts",
487 "handle": "example-t-shirt",
488 "created_at": "2024-01-15T10:30:00Z",
489 "updated_at": "2024-06-20T15:45:00Z",
490 "published_at": "2024-01-20T12:00:00Z",
491 "published_scope": "global",
492 "status": "active",
493 "tags": "cotton, summer",
494 "template_suffix": null,
495 "admin_graphql_api_id": "gid://shopify/Product/788032119674292922",
496 "variants": [
497 {
498 "id": 39072856,
499 "product_id": 788032119674292922,
500 "title": "Small",
501 "price": "19.99",
502 "compare_at_price": "24.99",
503 "sku": "SHIRT-SM",
504 "position": 1,
505 "inventory_quantity": 100,
506 "option1": "Small",
507 "option2": null,
508 "option3": null,
509 "image_id": null,
510 "created_at": "2024-01-15T10:30:00Z",
511 "updated_at": "2024-06-20T15:45:00Z"
512 }
513 ],
514 "options": [
515 {
516 "id": 594680422,
517 "product_id": 788032119674292922,
518 "name": "Size",
519 "position": 1,
520 "values": ["Small", "Medium", "Large"]
521 }
522 ],
523 "images": [],
524 "image": null
525 }"#;
526
527 let product: Product = serde_json::from_str(json).unwrap();
528
529 assert_eq!(product.id, Some(788032119674292922));
531 assert_eq!(product.title, Some("Example T-Shirt".to_string()));
532 assert_eq!(
533 product.body_html,
534 Some("<strong>Good cotton T-shirt</strong>".to_string())
535 );
536 assert_eq!(product.vendor, Some("Acme".to_string()));
537 assert_eq!(product.product_type, Some("Shirts".to_string()));
538 assert_eq!(product.handle, Some("example-t-shirt".to_string()));
539 assert!(product.created_at.is_some());
540 assert!(product.updated_at.is_some());
541 assert!(product.published_at.is_some());
542 assert_eq!(product.published_scope, Some("global".to_string()));
543 assert_eq!(product.status, Some(ProductStatus::Active));
544 assert_eq!(product.tags, Some("cotton, summer".to_string()));
545 assert_eq!(product.template_suffix, None);
546 assert_eq!(
547 product.admin_graphql_api_id,
548 Some("gid://shopify/Product/788032119674292922".to_string())
549 );
550
551 let variants = product.variants.unwrap();
553 assert_eq!(variants.len(), 1);
554 assert_eq!(variants[0].id, Some(39072856));
555 assert_eq!(variants[0].title, Some("Small".to_string()));
556 assert_eq!(variants[0].price, Some("19.99".to_string()));
557 assert_eq!(variants[0].compare_at_price, Some("24.99".to_string()));
558 assert_eq!(variants[0].sku, Some("SHIRT-SM".to_string()));
559 assert_eq!(variants[0].inventory_quantity, Some(100));
560
561 let options = product.options.unwrap();
563 assert_eq!(options.len(), 1);
564 assert_eq!(options[0].name, Some("Size".to_string()));
565 assert_eq!(
566 options[0].values,
567 Some(vec![
568 "Small".to_string(),
569 "Medium".to_string(),
570 "Large".to_string()
571 ])
572 );
573 }
574
575 #[test]
576 fn test_product_list_params_serialization() {
577 let params = ProductListParams {
578 ids: Some(vec![123, 456, 789]),
579 limit: Some(50),
580 vendor: Some("Acme".to_string()),
581 status: Some(ProductStatus::Active),
582 published_status: Some("published".to_string()),
583 ..Default::default()
584 };
585
586 let json = serde_json::to_value(¶ms).unwrap();
587
588 assert_eq!(json["ids"], serde_json::json!([123, 456, 789]));
589 assert_eq!(json["limit"], 50);
590 assert_eq!(json["vendor"], "Acme");
591 assert_eq!(json["status"], "active");
592 assert_eq!(json["published_status"], "published");
593
594 assert!(json.get("title").is_none());
596 assert!(json.get("handle").is_none());
597 assert!(json.get("created_at_min").is_none());
598 }
599
600 #[test]
601 fn test_product_status_enum_serialization() {
602 let active = ProductStatus::Active;
604 let archived = ProductStatus::Archived;
605 let draft = ProductStatus::Draft;
606
607 assert_eq!(serde_json::to_string(&active).unwrap(), "\"active\"");
608 assert_eq!(serde_json::to_string(&archived).unwrap(), "\"archived\"");
609 assert_eq!(serde_json::to_string(&draft).unwrap(), "\"draft\"");
610
611 let active: ProductStatus = serde_json::from_str("\"active\"").unwrap();
613 let archived: ProductStatus = serde_json::from_str("\"archived\"").unwrap();
614 let draft: ProductStatus = serde_json::from_str("\"draft\"").unwrap();
615
616 assert_eq!(active, ProductStatus::Active);
617 assert_eq!(archived, ProductStatus::Archived);
618 assert_eq!(draft, ProductStatus::Draft);
619 }
620
621 #[test]
622 fn test_product_get_id_returns_correct_value() {
623 let product_with_id = Product {
625 id: Some(123456789),
626 title: Some("Test".to_string()),
627 ..Default::default()
628 };
629 assert_eq!(product_with_id.get_id(), Some(123456789));
630
631 let product_without_id = Product {
633 id: None,
634 title: Some("New Product".to_string()),
635 ..Default::default()
636 };
637 assert_eq!(product_without_id.get_id(), None);
638 }
639
640 #[test]
641 fn test_product_path_constants_are_correct() {
642 let find_path = get_path(Product::PATHS, ResourceOperation::Find, &["id"]);
644 assert!(find_path.is_some());
645 assert_eq!(find_path.unwrap().template, "products/{id}");
646 assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
647
648 let all_path = get_path(Product::PATHS, ResourceOperation::All, &[]);
650 assert!(all_path.is_some());
651 assert_eq!(all_path.unwrap().template, "products");
652 assert_eq!(all_path.unwrap().http_method, HttpMethod::Get);
653
654 let count_path = get_path(Product::PATHS, ResourceOperation::Count, &[]);
656 assert!(count_path.is_some());
657 assert_eq!(count_path.unwrap().template, "products/count");
658 assert_eq!(count_path.unwrap().http_method, HttpMethod::Get);
659
660 let create_path = get_path(Product::PATHS, ResourceOperation::Create, &[]);
662 assert!(create_path.is_some());
663 assert_eq!(create_path.unwrap().template, "products");
664 assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
665
666 let update_path = get_path(Product::PATHS, ResourceOperation::Update, &["id"]);
668 assert!(update_path.is_some());
669 assert_eq!(update_path.unwrap().template, "products/{id}");
670 assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
671
672 let delete_path = get_path(Product::PATHS, ResourceOperation::Delete, &["id"]);
674 assert!(delete_path.is_some());
675 assert_eq!(delete_path.unwrap().template, "products/{id}");
676 assert_eq!(delete_path.unwrap().http_method, HttpMethod::Delete);
677
678 assert_eq!(Product::NAME, "Product");
680 assert_eq!(Product::PLURAL, "products");
681 }
682
683 #[test]
684 fn test_product_variant_embedded_struct() {
685 let variant = ProductVariant {
686 id: Some(111222333),
687 product_id: Some(444555666),
688 title: Some("Large / Blue".to_string()),
689 price: Some("39.99".to_string()),
690 compare_at_price: Some("49.99".to_string()),
691 sku: Some("PROD-LG-BL".to_string()),
692 position: Some(2),
693 inventory_quantity: Some(50),
694 option1: Some("Large".to_string()),
695 option2: Some("Blue".to_string()),
696 option3: None,
697 image_id: Some(999888777),
698 created_at: Some(
699 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
700 .unwrap()
701 .with_timezone(&Utc),
702 ),
703 updated_at: Some(
704 DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
705 .unwrap()
706 .with_timezone(&Utc),
707 ),
708 };
709
710 let json = serde_json::to_string(&variant).unwrap();
712 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
713
714 assert_eq!(parsed["product_id"], 444555666);
716 assert_eq!(parsed["title"], "Large / Blue");
717 assert_eq!(parsed["price"], "39.99");
718 assert_eq!(parsed["compare_at_price"], "49.99");
719 assert_eq!(parsed["sku"], "PROD-LG-BL");
720 assert_eq!(parsed["position"], 2);
721 assert_eq!(parsed["option1"], "Large");
722 assert_eq!(parsed["option2"], "Blue");
723 assert_eq!(parsed["image_id"], 999888777);
724
725 assert!(parsed.get("id").is_none());
727 assert!(parsed.get("inventory_quantity").is_none());
728 assert!(parsed.get("created_at").is_none());
729 assert!(parsed.get("updated_at").is_none());
730 }
731
732 #[test]
733 fn test_product_count_params_serialization() {
734 let params = ProductCountParams {
735 vendor: Some("Acme".to_string()),
736 product_type: Some("Shirts".to_string()),
737 collection_id: Some(123456),
738 published_status: Some("published".to_string()),
739 ..Default::default()
740 };
741
742 let json = serde_json::to_value(¶ms).unwrap();
743
744 assert_eq!(json["vendor"], "Acme");
745 assert_eq!(json["product_type"], "Shirts");
746 assert_eq!(json["collection_id"], 123456);
747 assert_eq!(json["published_status"], "published");
748 }
749}