1use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43use crate::rest::{ResourceOperation, ResourcePath, RestResource};
44use crate::HttpMethod;
45
46#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
50#[serde(rename_all = "lowercase")]
51pub enum WeightUnit {
52 #[default]
54 Kg,
55 G,
57 Lb,
59 Oz,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
107pub struct Variant {
108 #[serde(skip_serializing)]
111 pub id: Option<u64>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
115 pub product_id: Option<u64>,
116
117 #[serde(skip_serializing_if = "Option::is_none")]
119 pub title: Option<String>,
120
121 #[serde(skip_serializing_if = "Option::is_none")]
124 pub price: Option<String>,
125
126 #[serde(skip_serializing_if = "Option::is_none")]
129 pub compare_at_price: Option<String>,
130
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub sku: Option<String>,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub barcode: Option<String>,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub position: Option<i64>,
142
143 #[serde(skip_serializing_if = "Option::is_none")]
146 pub grams: Option<i64>,
147
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub weight: Option<f64>,
151
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub weight_unit: Option<WeightUnit>,
155
156 #[serde(skip_serializing)]
159 pub inventory_item_id: Option<u64>,
160
161 #[serde(skip_serializing)]
164 pub inventory_quantity: Option<i64>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
169 pub inventory_management: Option<String>,
170
171 #[serde(skip_serializing_if = "Option::is_none")]
174 pub inventory_policy: Option<String>,
175
176 #[serde(skip_serializing_if = "Option::is_none")]
178 pub fulfillment_service: Option<String>,
179
180 #[serde(skip_serializing_if = "Option::is_none")]
182 pub option1: Option<String>,
183
184 #[serde(skip_serializing_if = "Option::is_none")]
186 pub option2: Option<String>,
187
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub option3: Option<String>,
191
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub image_id: Option<u64>,
195
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub taxable: Option<bool>,
199
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub tax_code: Option<String>,
203
204 #[serde(skip_serializing_if = "Option::is_none")]
206 pub requires_shipping: Option<bool>,
207
208 #[serde(skip_serializing)]
211 pub created_at: Option<DateTime<Utc>>,
212
213 #[serde(skip_serializing)]
216 pub updated_at: Option<DateTime<Utc>>,
217
218 #[serde(skip_serializing)]
221 pub admin_graphql_api_id: Option<String>,
222}
223
224impl RestResource for Variant {
225 type Id = u64;
226 type FindParams = VariantFindParams;
227 type AllParams = VariantListParams;
228 type CountParams = VariantCountParams;
229
230 const NAME: &'static str = "Variant";
231 const PLURAL: &'static str = "variants";
232
233 const PATHS: &'static [ResourcePath] = &[
241 ResourcePath::new(
243 HttpMethod::Get,
244 ResourceOperation::Find,
245 &["product_id", "id"],
246 "products/{product_id}/variants/{id}",
247 ),
248 ResourcePath::new(
249 HttpMethod::Get,
250 ResourceOperation::All,
251 &["product_id"],
252 "products/{product_id}/variants",
253 ),
254 ResourcePath::new(
255 HttpMethod::Get,
256 ResourceOperation::Count,
257 &["product_id"],
258 "products/{product_id}/variants/count",
259 ),
260 ResourcePath::new(
261 HttpMethod::Post,
262 ResourceOperation::Create,
263 &["product_id"],
264 "products/{product_id}/variants",
265 ),
266 ResourcePath::new(
267 HttpMethod::Put,
268 ResourceOperation::Update,
269 &["product_id", "id"],
270 "products/{product_id}/variants/{id}",
271 ),
272 ResourcePath::new(
273 HttpMethod::Delete,
274 ResourceOperation::Delete,
275 &["product_id", "id"],
276 "products/{product_id}/variants/{id}",
277 ),
278 ResourcePath::new(
280 HttpMethod::Get,
281 ResourceOperation::Find,
282 &["id"],
283 "variants/{id}",
284 ),
285 ResourcePath::new(
286 HttpMethod::Put,
287 ResourceOperation::Update,
288 &["id"],
289 "variants/{id}",
290 ),
291 ];
292
293 fn get_id(&self) -> Option<Self::Id> {
294 self.id
295 }
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
300pub struct VariantFindParams {
301 #[serde(skip_serializing_if = "Option::is_none")]
303 pub fields: Option<String>,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
308pub struct VariantListParams {
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub limit: Option<u32>,
312
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub since_id: Option<u64>,
316
317 #[serde(skip_serializing_if = "Option::is_none")]
319 pub fields: Option<String>,
320
321 #[serde(skip_serializing_if = "Option::is_none")]
323 pub page_info: Option<String>,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
328pub struct VariantCountParams {
329 }
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335 use crate::rest::{get_path, ResourceOperation};
336
337 #[test]
338 fn test_variant_struct_serialization() {
339 let variant = Variant {
340 id: Some(12345), product_id: Some(67890), title: Some("Large / Blue".to_string()),
343 price: Some("29.99".to_string()),
344 compare_at_price: Some("39.99".to_string()),
345 sku: Some("PROD-LG-BL".to_string()),
346 barcode: Some("1234567890123".to_string()),
347 position: Some(2),
348 grams: Some(500),
349 weight: Some(0.5),
350 weight_unit: Some(WeightUnit::Kg),
351 inventory_item_id: Some(111222), inventory_quantity: Some(100), inventory_management: Some("shopify".to_string()),
354 inventory_policy: Some("deny".to_string()),
355 fulfillment_service: Some("manual".to_string()),
356 option1: Some("Large".to_string()),
357 option2: Some("Blue".to_string()),
358 option3: None,
359 image_id: Some(999888),
360 taxable: Some(true),
361 tax_code: None,
362 requires_shipping: Some(true),
363 created_at: Some(
364 DateTime::parse_from_rfc3339("2024-01-15T10:30:00Z")
365 .unwrap()
366 .with_timezone(&Utc),
367 ), updated_at: Some(
369 DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
370 .unwrap()
371 .with_timezone(&Utc),
372 ), admin_graphql_api_id: Some("gid://shopify/ProductVariant/12345".to_string()), };
375
376 let json = serde_json::to_string(&variant).unwrap();
377 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
378
379 assert_eq!(parsed["product_id"], 67890);
381 assert_eq!(parsed["title"], "Large / Blue");
382 assert_eq!(parsed["price"], "29.99");
383 assert_eq!(parsed["compare_at_price"], "39.99");
384 assert_eq!(parsed["sku"], "PROD-LG-BL");
385 assert_eq!(parsed["barcode"], "1234567890123");
386 assert_eq!(parsed["position"], 2);
387 assert_eq!(parsed["grams"], 500);
388 assert_eq!(parsed["weight"], 0.5);
389 assert_eq!(parsed["weight_unit"], "kg");
390 assert_eq!(parsed["inventory_management"], "shopify");
391 assert_eq!(parsed["inventory_policy"], "deny");
392 assert_eq!(parsed["fulfillment_service"], "manual");
393 assert_eq!(parsed["option1"], "Large");
394 assert_eq!(parsed["option2"], "Blue");
395 assert_eq!(parsed["image_id"], 999888);
396 assert_eq!(parsed["taxable"], true);
397 assert_eq!(parsed["requires_shipping"], true);
398
399 assert!(parsed.get("id").is_none());
401 assert!(parsed.get("inventory_item_id").is_none());
402 assert!(parsed.get("inventory_quantity").is_none());
403 assert!(parsed.get("created_at").is_none());
404 assert!(parsed.get("updated_at").is_none());
405 assert!(parsed.get("admin_graphql_api_id").is_none());
406
407 assert!(parsed.get("option3").is_none());
409 assert!(parsed.get("tax_code").is_none());
410 }
411
412 #[test]
413 fn test_variant_deserialization_from_api_response() {
414 let json = r#"{
415 "id": 39072856,
416 "product_id": 788032119674292922,
417 "title": "Large / Blue",
418 "price": "29.99",
419 "compare_at_price": "39.99",
420 "sku": "PROD-LG-BL",
421 "barcode": "1234567890123",
422 "position": 2,
423 "grams": 500,
424 "weight": 0.5,
425 "weight_unit": "kg",
426 "inventory_item_id": 111222333,
427 "inventory_quantity": 100,
428 "inventory_management": "shopify",
429 "inventory_policy": "deny",
430 "fulfillment_service": "manual",
431 "option1": "Large",
432 "option2": "Blue",
433 "option3": null,
434 "image_id": 999888777,
435 "taxable": true,
436 "tax_code": null,
437 "requires_shipping": true,
438 "created_at": "2024-01-15T10:30:00Z",
439 "updated_at": "2024-06-20T15:45:00Z",
440 "admin_graphql_api_id": "gid://shopify/ProductVariant/39072856"
441 }"#;
442
443 let variant: Variant = serde_json::from_str(json).unwrap();
444
445 assert_eq!(variant.id, Some(39072856));
447 assert_eq!(variant.product_id, Some(788032119674292922));
448 assert_eq!(variant.title, Some("Large / Blue".to_string()));
449 assert_eq!(variant.price, Some("29.99".to_string()));
450 assert_eq!(variant.compare_at_price, Some("39.99".to_string()));
451 assert_eq!(variant.sku, Some("PROD-LG-BL".to_string()));
452 assert_eq!(variant.barcode, Some("1234567890123".to_string()));
453 assert_eq!(variant.position, Some(2));
454 assert_eq!(variant.grams, Some(500));
455 assert_eq!(variant.weight, Some(0.5));
456 assert_eq!(variant.weight_unit, Some(WeightUnit::Kg));
457 assert_eq!(variant.inventory_item_id, Some(111222333));
458 assert_eq!(variant.inventory_quantity, Some(100));
459 assert_eq!(variant.inventory_management, Some("shopify".to_string()));
460 assert_eq!(variant.inventory_policy, Some("deny".to_string()));
461 assert_eq!(variant.fulfillment_service, Some("manual".to_string()));
462 assert_eq!(variant.option1, Some("Large".to_string()));
463 assert_eq!(variant.option2, Some("Blue".to_string()));
464 assert_eq!(variant.option3, None);
465 assert_eq!(variant.image_id, Some(999888777));
466 assert_eq!(variant.taxable, Some(true));
467 assert_eq!(variant.tax_code, None);
468 assert_eq!(variant.requires_shipping, Some(true));
469 assert!(variant.created_at.is_some());
470 assert!(variant.updated_at.is_some());
471 assert_eq!(
472 variant.admin_graphql_api_id,
473 Some("gid://shopify/ProductVariant/39072856".to_string())
474 );
475 }
476
477 #[test]
478 fn test_dual_path_patterns() {
479 let nested_find_path = get_path(
481 Variant::PATHS,
482 ResourceOperation::Find,
483 &["product_id", "id"],
484 );
485 assert!(nested_find_path.is_some());
486 assert_eq!(
487 nested_find_path.unwrap().template,
488 "products/{product_id}/variants/{id}"
489 );
490
491 let standalone_find_path = get_path(Variant::PATHS, ResourceOperation::Find, &["id"]);
493 assert!(standalone_find_path.is_some());
494 assert_eq!(standalone_find_path.unwrap().template, "variants/{id}");
495
496 let nested_all_path = get_path(Variant::PATHS, ResourceOperation::All, &["product_id"]);
498 assert!(nested_all_path.is_some());
499 assert_eq!(
500 nested_all_path.unwrap().template,
501 "products/{product_id}/variants"
502 );
503
504 let standalone_all_path = get_path(Variant::PATHS, ResourceOperation::All, &[]);
506 assert!(standalone_all_path.is_none());
507
508 let nested_update_path = get_path(
510 Variant::PATHS,
511 ResourceOperation::Update,
512 &["product_id", "id"],
513 );
514 assert!(nested_update_path.is_some());
515 assert_eq!(
516 nested_update_path.unwrap().template,
517 "products/{product_id}/variants/{id}"
518 );
519
520 let standalone_update_path = get_path(Variant::PATHS, ResourceOperation::Update, &["id"]);
522 assert!(standalone_update_path.is_some());
523 assert_eq!(standalone_update_path.unwrap().template, "variants/{id}");
524
525 let create_path = get_path(Variant::PATHS, ResourceOperation::Create, &["product_id"]);
527 assert!(create_path.is_some());
528 assert_eq!(
529 create_path.unwrap().template,
530 "products/{product_id}/variants"
531 );
532
533 let delete_path = get_path(
535 Variant::PATHS,
536 ResourceOperation::Delete,
537 &["product_id", "id"],
538 );
539 assert!(delete_path.is_some());
540 assert_eq!(
541 delete_path.unwrap().template,
542 "products/{product_id}/variants/{id}"
543 );
544
545 let count_path = get_path(Variant::PATHS, ResourceOperation::Count, &["product_id"]);
547 assert!(count_path.is_some());
548 assert_eq!(
549 count_path.unwrap().template,
550 "products/{product_id}/variants/count"
551 );
552
553 assert_eq!(Variant::NAME, "Variant");
555 assert_eq!(Variant::PLURAL, "variants");
556 }
557
558 #[test]
559 fn test_weight_unit_enum_serialization() {
560 assert_eq!(serde_json::to_string(&WeightUnit::Kg).unwrap(), "\"kg\"");
562 assert_eq!(serde_json::to_string(&WeightUnit::G).unwrap(), "\"g\"");
563 assert_eq!(serde_json::to_string(&WeightUnit::Lb).unwrap(), "\"lb\"");
564 assert_eq!(serde_json::to_string(&WeightUnit::Oz).unwrap(), "\"oz\"");
565
566 let kg: WeightUnit = serde_json::from_str("\"kg\"").unwrap();
568 let g: WeightUnit = serde_json::from_str("\"g\"").unwrap();
569 let lb: WeightUnit = serde_json::from_str("\"lb\"").unwrap();
570 let oz: WeightUnit = serde_json::from_str("\"oz\"").unwrap();
571
572 assert_eq!(kg, WeightUnit::Kg);
573 assert_eq!(g, WeightUnit::G);
574 assert_eq!(lb, WeightUnit::Lb);
575 assert_eq!(oz, WeightUnit::Oz);
576
577 assert_eq!(WeightUnit::default(), WeightUnit::Kg);
579 }
580
581 #[test]
582 fn test_variant_list_params_serialization() {
583 let params = VariantListParams {
584 limit: Some(50),
585 since_id: Some(12345),
586 fields: Some("id,title,price,sku".to_string()),
587 page_info: Some("eyJsYXN0X2lkIjoxMjM0NTY3ODkwfQ".to_string()),
588 };
589
590 let json = serde_json::to_value(¶ms).unwrap();
591
592 assert_eq!(json["limit"], 50);
593 assert_eq!(json["since_id"], 12345);
594 assert_eq!(json["fields"], "id,title,price,sku");
595 assert_eq!(json["page_info"], "eyJsYXN0X2lkIjoxMjM0NTY3ODkwfQ");
596
597 let empty_params = VariantListParams::default();
599 let empty_json = serde_json::to_value(&empty_params).unwrap();
600
601 assert_eq!(empty_json, serde_json::json!({}));
603 }
604
605 #[test]
606 fn test_variant_get_id_returns_correct_value() {
607 let variant_with_id = Variant {
609 id: Some(123456789),
610 product_id: Some(987654321),
611 title: Some("Test Variant".to_string()),
612 ..Default::default()
613 };
614 assert_eq!(variant_with_id.get_id(), Some(123456789));
615
616 let variant_without_id = Variant {
618 id: None,
619 product_id: Some(987654321),
620 title: Some("New Variant".to_string()),
621 ..Default::default()
622 };
623 assert_eq!(variant_without_id.get_id(), None);
624 }
625}