Skip to main content

shopify_sdk/rest/resources/v2025_10/
variant.rs

1//! Variant resource implementation.
2//!
3//! This module provides the Variant resource, which represents a variant of a product
4//! in a Shopify store. Variants are different versions of a product (e.g., size, color).
5//!
6//! # Dual Path Support
7//!
8//! The Variant resource supports both nested and standalone paths:
9//! - Nested: `/products/{product_id}/variants/{id}` - when `product_id` is available
10//! - Standalone: `/variants/{id}` - fallback when only variant id is available
11//!
12//! The path selection automatically chooses the most specific path based on available IDs.
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use shopify_sdk::rest::{RestResource, ResourceResponse};
18//! use shopify_sdk::rest::resources::v2025_10::{Variant, VariantListParams, WeightUnit};
19//!
20//! // Find a variant by ID (standalone path)
21//! let variant = Variant::find(&client, 123, None).await?;
22//! println!("Variant: {}", variant.title.as_deref().unwrap_or(""));
23//!
24//! // List variants under a product (nested path)
25//! let variants = Variant::all_with_parent(&client, "product_id", 456, None).await?;
26//!
27//! // Create a new variant under a product
28//! let mut variant = Variant {
29//!     product_id: Some(456),
30//!     title: Some("Large / Blue".to_string()),
31//!     price: Some("29.99".to_string()),
32//!     sku: Some("PROD-LG-BL".to_string()),
33//!     weight: Some(1.5),
34//!     weight_unit: Some(WeightUnit::Kg),
35//!     ..Default::default()
36//! };
37//! let saved = variant.save(&client).await?;
38//! ```
39
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43use crate::rest::{ResourceOperation, ResourcePath, RestResource};
44use crate::HttpMethod;
45
46/// The unit of measurement for variant weight.
47///
48/// Used to specify whether the weight is in kilograms, grams, pounds, or ounces.
49#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
50#[serde(rename_all = "lowercase")]
51pub enum WeightUnit {
52    /// Kilograms
53    #[default]
54    Kg,
55    /// Grams
56    G,
57    /// Pounds
58    Lb,
59    /// Ounces
60    Oz,
61}
62
63/// A product variant in a Shopify store.
64///
65/// Variants represent different versions of a product, typically distinguished by
66/// attributes like size, color, or material. Each variant can have its own price,
67/// SKU, inventory, and weight settings.
68///
69/// # Dual Path Access
70///
71/// Variants can be accessed through two path patterns:
72/// - Nested under a product: `/products/{product_id}/variants/{id}`
73/// - Standalone: `/variants/{id}`
74///
75/// The nested path is preferred when `product_id` is available.
76///
77/// # Fields
78///
79/// ## Writable Fields
80/// - `product_id` - The ID of the product this variant belongs to
81/// - `title` - The title of the variant
82/// - `price` - The price of the variant
83/// - `compare_at_price` - The original price for comparison (sale pricing)
84/// - `sku` - Stock keeping unit identifier
85/// - `barcode` - The barcode, UPC, or ISBN number
86/// - `position` - The position in the variant list
87/// - `grams` - The weight in grams (deprecated, use `weight`/`weight_unit`)
88/// - `weight` - The weight value
89/// - `weight_unit` - The unit of measurement for weight
90/// - `inventory_management` - The fulfillment service tracking inventory
91/// - `inventory_policy` - Whether to allow purchases when out of stock
92/// - `fulfillment_service` - The fulfillment service for this variant
93/// - `option1`, `option2`, `option3` - Option values
94/// - `image_id` - The ID of the associated image
95/// - `taxable` - Whether the variant is taxable
96/// - `tax_code` - The tax code for the variant
97/// - `requires_shipping` - Whether the variant requires shipping
98///
99/// ## Read-Only Fields
100/// - `id` - The unique identifier
101/// - `inventory_item_id` - The ID of the associated inventory item
102/// - `inventory_quantity` - The available quantity
103/// - `created_at` - When the variant was created
104/// - `updated_at` - When the variant was last updated
105/// - `admin_graphql_api_id` - The GraphQL API ID
106#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
107pub struct Variant {
108    /// The unique identifier of the variant.
109    /// Read-only field.
110    #[serde(skip_serializing)]
111    pub id: Option<u64>,
112
113    /// The ID of the product this variant belongs to.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub product_id: Option<u64>,
116
117    /// The title of the variant.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub title: Option<String>,
120
121    /// The price of the variant.
122    /// Stored as a string to preserve decimal precision.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub price: Option<String>,
125
126    /// The original price of the variant for comparison.
127    /// Used to show sale pricing.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub compare_at_price: Option<String>,
130
131    /// The stock keeping unit (SKU) of the variant.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub sku: Option<String>,
134
135    /// The barcode, UPC, or ISBN number of the variant.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub barcode: Option<String>,
138
139    /// The position of the variant in the product's variant list.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub position: Option<i64>,
142
143    /// The weight of the variant in grams.
144    /// Deprecated: Use `weight` and `weight_unit` instead.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub grams: Option<i64>,
147
148    /// The weight of the variant.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub weight: Option<f64>,
151
152    /// The unit of measurement for the variant's weight.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub weight_unit: Option<WeightUnit>,
155
156    /// The ID of the inventory item associated with this variant.
157    /// Read-only field.
158    #[serde(skip_serializing)]
159    pub inventory_item_id: Option<u64>,
160
161    /// The available quantity of the variant.
162    /// Read-only field - use Inventory API to modify.
163    #[serde(skip_serializing)]
164    pub inventory_quantity: Option<i64>,
165
166    /// The fulfillment service that tracks inventory for this variant.
167    /// Valid values: "shopify" or the handle of a fulfillment service.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub inventory_management: Option<String>,
170
171    /// Whether customers can purchase the variant when it's out of stock.
172    /// Valid values: "deny" or "continue".
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub inventory_policy: Option<String>,
175
176    /// The fulfillment service for this variant.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub fulfillment_service: Option<String>,
179
180    /// The value of the first option.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub option1: Option<String>,
183
184    /// The value of the second option.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub option2: Option<String>,
187
188    /// The value of the third option.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub option3: Option<String>,
191
192    /// The ID of the image associated with this variant.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub image_id: Option<u64>,
195
196    /// Whether the variant is taxable.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub taxable: Option<bool>,
199
200    /// The tax code for the variant (Shopify Plus feature).
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub tax_code: Option<String>,
203
204    /// Whether the variant requires shipping.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub requires_shipping: Option<bool>,
207
208    /// When the variant was created.
209    /// Read-only field.
210    #[serde(skip_serializing)]
211    pub created_at: Option<DateTime<Utc>>,
212
213    /// When the variant was last updated.
214    /// Read-only field.
215    #[serde(skip_serializing)]
216    pub updated_at: Option<DateTime<Utc>>,
217
218    /// The admin GraphQL API ID for this variant.
219    /// Read-only field.
220    #[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    /// Paths for the Variant resource.
234    ///
235    /// The Variant resource supports DUAL PATHS:
236    /// 1. Nested paths under products (more specific, preferred when `product_id` available)
237    /// 2. Standalone paths (fallback when only variant id available)
238    ///
239    /// Path selection chooses the most specific path based on available IDs.
240    const PATHS: &'static [ResourcePath] = &[
241        // Nested paths (more specific - preferred when product_id is available)
242        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        // Standalone paths (fallback - used when only variant id is available)
279        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/// Parameters for finding a single variant.
299#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
300pub struct VariantFindParams {
301    /// Comma-separated list of fields to include in the response.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub fields: Option<String>,
304}
305
306/// Parameters for listing variants.
307#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
308pub struct VariantListParams {
309    /// Maximum number of results to return (default: 50, max: 250).
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub limit: Option<u32>,
312
313    /// Return variants after this ID.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub since_id: Option<u64>,
316
317    /// Comma-separated list of fields to include in the response.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub fields: Option<String>,
320
321    /// Cursor for pagination.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub page_info: Option<String>,
324}
325
326/// Parameters for counting variants.
327#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
328pub struct VariantCountParams {
329    // No specific count params for variants beyond the product_id in the path
330}
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),         // Read-only, should be skipped
341            product_id: Some(67890), // Writable
342            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), // Read-only
352            inventory_quantity: Some(100),   // Read-only
353            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            ), // Read-only
368            updated_at: Some(
369                DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
370                    .unwrap()
371                    .with_timezone(&Utc),
372            ), // Read-only
373            admin_graphql_api_id: Some("gid://shopify/ProductVariant/12345".to_string()), // Read-only
374        };
375
376        let json = serde_json::to_string(&variant).unwrap();
377        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
378
379        // Writable fields should be present
380        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        // Read-only fields should be omitted
400        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        // Optional fields that are None should be omitted
408        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        // Verify all fields are deserialized correctly
446        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        // Test nested path (with both product_id and id) - most specific for Find
480        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        // Test standalone path (with only id) - fallback for Find
492        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        // Test nested All path (requires product_id)
497        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        // Test that All without product_id fails (no standalone All path)
505        let standalone_all_path = get_path(Variant::PATHS, ResourceOperation::All, &[]);
506        assert!(standalone_all_path.is_none());
507
508        // Test nested Update path (with both product_id and id)
509        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        // Test standalone Update path (with only id)
521        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        // Test Create path (requires product_id)
526        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        // Test Delete path (requires both product_id and id)
534        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        // Test Count path (requires product_id)
546        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        // Verify constants
554        assert_eq!(Variant::NAME, "Variant");
555        assert_eq!(Variant::PLURAL, "variants");
556    }
557
558    #[test]
559    fn test_weight_unit_enum_serialization() {
560        // Test serialization to lowercase
561        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        // Test deserialization from lowercase
567        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        // Test default value
578        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(&params).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        // Test with minimal params (all None)
598        let empty_params = VariantListParams::default();
599        let empty_json = serde_json::to_value(&empty_params).unwrap();
600
601        // Empty object when all fields are None
602        assert_eq!(empty_json, serde_json::json!({}));
603    }
604
605    #[test]
606    fn test_variant_get_id_returns_correct_value() {
607        // Variant with ID
608        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        // Variant without ID (new variant)
617        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}