Skip to main content

shopify_sdk/rest/resources/v2025_10/
custom_collection.rs

1//! `CustomCollection` resource implementation.
2//!
3//! This module provides the [`CustomCollection`] resource for managing manually curated
4//! product collections in Shopify.
5//!
6//! # Overview
7//!
8//! Custom collections are collections where products are manually added by the merchant.
9//! They contrast with smart collections, which automatically include products based on rules.
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use shopify_sdk::rest::resources::v2025_10::{CustomCollection, CustomCollectionListParams};
15//! use shopify_sdk::rest::RestResource;
16//!
17//! // Find a single custom collection
18//! let collection = CustomCollection::find(&client, 123, None).await?;
19//! println!("Collection: {}", collection.title.as_deref().unwrap_or(""));
20//!
21//! // List custom collections
22//! let collections = CustomCollection::all(&client, None).await?;
23//! for coll in collections.iter() {
24//!     println!("- {}", coll.title.as_deref().unwrap_or(""));
25//! }
26//!
27//! // Create a new custom collection
28//! let collection = CustomCollection {
29//!     title: Some("Summer Sale".to_string()),
30//!     body_html: Some("<p>Best summer products</p>".to_string()),
31//!     ..Default::default()
32//! };
33//! let saved = collection.save(&client).await?;
34//! ```
35
36use chrono::{DateTime, Utc};
37use serde::{Deserialize, Serialize};
38
39use crate::rest::{ResourceOperation, ResourcePath, RestResource};
40use crate::HttpMethod;
41
42use super::common::CollectionImage;
43
44/// A manually curated collection of products.
45///
46/// Custom collections allow merchants to manually select which products to include
47/// in the collection. Products are added to custom collections through "collects",
48/// which are join records linking products to collections.
49///
50/// # Fields
51///
52/// ## Read-Only Fields
53/// - `id` - The unique identifier
54/// - `handle` - The URL-friendly name (auto-generated from title)
55/// - `created_at` - When the collection was created
56/// - `updated_at` - When the collection was last updated
57/// - `admin_graphql_api_id` - The GraphQL API ID
58///
59/// ## Writable Fields
60/// - `title` - The name of the collection
61/// - `body_html` - The description in HTML format
62/// - `published_at` - When the collection was/will be published
63/// - `published_scope` - Where the collection is published ("web", "global")
64/// - `sort_order` - How products are sorted in the collection
65/// - `template_suffix` - The template suffix for the collection page
66/// - `image` - The collection's featured image
67///
68/// # Example
69///
70/// ```rust,ignore
71/// use shopify_sdk::rest::resources::v2025_10::CustomCollection;
72///
73/// let collection = CustomCollection {
74///     title: Some("Featured Products".to_string()),
75///     body_html: Some("<p>Our best-selling items</p>".to_string()),
76///     sort_order: Some("best-selling".to_string()),
77///     ..Default::default()
78/// };
79/// ```
80#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
81pub struct CustomCollection {
82    // --- Read-only fields ---
83    /// The unique identifier of the custom collection.
84    #[serde(skip_serializing)]
85    pub id: Option<u64>,
86
87    /// The URL-friendly name of the collection.
88    /// Automatically generated from the title if not specified.
89    #[serde(skip_serializing)]
90    pub handle: Option<String>,
91
92    /// When the collection was created.
93    #[serde(skip_serializing)]
94    pub created_at: Option<DateTime<Utc>>,
95
96    /// When the collection was last updated.
97    #[serde(skip_serializing)]
98    pub updated_at: Option<DateTime<Utc>>,
99
100    /// The admin GraphQL API ID for this collection.
101    #[serde(skip_serializing)]
102    pub admin_graphql_api_id: Option<String>,
103
104    // --- Writable fields ---
105    /// The name of the collection.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub title: Option<String>,
108
109    /// The description of the collection in HTML format.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub body_html: Option<String>,
112
113    /// When the collection was or will be published.
114    /// Set to null to unpublish.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub published_at: Option<DateTime<Utc>>,
117
118    /// Where the collection is published.
119    /// Valid values: "web", "global".
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub published_scope: Option<String>,
122
123    /// The order in which products appear in the collection.
124    ///
125    /// Valid values:
126    /// - `alpha-asc` - Alphabetically, A-Z
127    /// - `alpha-desc` - Alphabetically, Z-A
128    /// - `best-selling` - By best-selling products
129    /// - `created` - By date created, newest first
130    /// - `created-desc` - By date created, oldest first
131    /// - `manual` - Manual ordering
132    /// - `price-asc` - By price, lowest to highest
133    /// - `price-desc` - By price, highest to lowest
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub sort_order: Option<String>,
136
137    /// The suffix of the Liquid template used for the collection page.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub template_suffix: Option<String>,
140
141    /// The collection's featured image.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub image: Option<CollectionImage>,
144
145    /// Whether the collection is published.
146    /// Convenience field returned by the API.
147    #[serde(skip_serializing)]
148    pub published: Option<bool>,
149}
150
151impl RestResource for CustomCollection {
152    type Id = u64;
153    type FindParams = CustomCollectionFindParams;
154    type AllParams = CustomCollectionListParams;
155    type CountParams = CustomCollectionCountParams;
156
157    const NAME: &'static str = "CustomCollection";
158    const PLURAL: &'static str = "custom_collections";
159
160    const PATHS: &'static [ResourcePath] = &[
161        ResourcePath::new(
162            HttpMethod::Get,
163            ResourceOperation::Find,
164            &["id"],
165            "custom_collections/{id}",
166        ),
167        ResourcePath::new(
168            HttpMethod::Get,
169            ResourceOperation::All,
170            &[],
171            "custom_collections",
172        ),
173        ResourcePath::new(
174            HttpMethod::Get,
175            ResourceOperation::Count,
176            &[],
177            "custom_collections/count",
178        ),
179        ResourcePath::new(
180            HttpMethod::Post,
181            ResourceOperation::Create,
182            &[],
183            "custom_collections",
184        ),
185        ResourcePath::new(
186            HttpMethod::Put,
187            ResourceOperation::Update,
188            &["id"],
189            "custom_collections/{id}",
190        ),
191        ResourcePath::new(
192            HttpMethod::Delete,
193            ResourceOperation::Delete,
194            &["id"],
195            "custom_collections/{id}",
196        ),
197    ];
198
199    fn get_id(&self) -> Option<Self::Id> {
200        self.id
201    }
202}
203
204/// Parameters for finding a single custom collection.
205#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
206pub struct CustomCollectionFindParams {
207    /// Comma-separated list of fields to include in the response.
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub fields: Option<String>,
210}
211
212/// Parameters for listing custom collections.
213#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
214pub struct CustomCollectionListParams {
215    /// Return only collections with the given IDs.
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub ids: Option<Vec<u64>>,
218
219    /// Maximum number of results to return (default: 50, max: 250).
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub limit: Option<u32>,
222
223    /// Return collections after this ID.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub since_id: Option<u64>,
226
227    /// Filter by collection title.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub title: Option<String>,
230
231    /// Filter by collection handle.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub handle: Option<String>,
234
235    /// Filter to collections containing this product.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub product_id: Option<u64>,
238
239    /// Show collections updated after this date.
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub updated_at_min: Option<DateTime<Utc>>,
242
243    /// Show collections updated before this date.
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub updated_at_max: Option<DateTime<Utc>>,
246
247    /// Filter by published status.
248    /// Valid values: "published", "unpublished", "any".
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub published_status: Option<String>,
251
252    /// Comma-separated list of fields to include in the response.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub fields: Option<String>,
255
256    /// Cursor for pagination.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub page_info: Option<String>,
259}
260
261/// Parameters for counting custom collections.
262#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
263pub struct CustomCollectionCountParams {
264    /// Filter by collection title.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub title: Option<String>,
267
268    /// Filter to collections containing this product.
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub product_id: Option<u64>,
271
272    /// Show collections updated after this date.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub updated_at_min: Option<DateTime<Utc>>,
275
276    /// Show collections updated before this date.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub updated_at_max: Option<DateTime<Utc>>,
279
280    /// Filter by published status.
281    /// Valid values: "published", "unpublished", "any".
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub published_status: Option<String>,
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::rest::{get_path, ResourceOperation};
290
291    #[test]
292    fn test_custom_collection_struct_serialization() {
293        let collection = CustomCollection {
294            id: Some(841564295),
295            title: Some("Summer Collection".to_string()),
296            body_html: Some("<p>Best summer products</p>".to_string()),
297            handle: Some("summer-collection".to_string()),
298            published_at: None,
299            published_scope: Some("web".to_string()),
300            sort_order: Some("best-selling".to_string()),
301            template_suffix: Some("custom".to_string()),
302            image: Some(CollectionImage {
303                src: Some("https://cdn.shopify.com/collection.jpg".to_string()),
304                alt: Some("Summer".to_string()),
305                ..Default::default()
306            }),
307            created_at: None,
308            updated_at: None,
309            admin_graphql_api_id: Some("gid://shopify/Collection/841564295".to_string()),
310            published: Some(true),
311        };
312
313        let json = serde_json::to_string(&collection).unwrap();
314        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
315
316        // Writable fields should be present
317        assert_eq!(parsed["title"], "Summer Collection");
318        assert_eq!(parsed["body_html"], "<p>Best summer products</p>");
319        assert_eq!(parsed["published_scope"], "web");
320        assert_eq!(parsed["sort_order"], "best-selling");
321        assert_eq!(parsed["template_suffix"], "custom");
322        assert!(parsed.get("image").is_some());
323
324        // Read-only fields should be omitted
325        assert!(parsed.get("id").is_none());
326        assert!(parsed.get("handle").is_none());
327        assert!(parsed.get("created_at").is_none());
328        assert!(parsed.get("updated_at").is_none());
329        assert!(parsed.get("admin_graphql_api_id").is_none());
330        assert!(parsed.get("published").is_none());
331    }
332
333    #[test]
334    fn test_custom_collection_deserialization_from_api_response() {
335        let json = r#"{
336            "id": 841564295,
337            "handle": "ipods",
338            "title": "IPods",
339            "updated_at": "2024-01-02T09:28:43-05:00",
340            "body_html": "<p>The best iPods</p>",
341            "published_at": "2008-02-01T19:00:00-05:00",
342            "sort_order": "manual",
343            "template_suffix": null,
344            "published_scope": "web",
345            "admin_graphql_api_id": "gid://shopify/Collection/841564295",
346            "image": {
347                "src": "https://cdn.shopify.com/s/files/ipods.jpg",
348                "alt": "iPods collection",
349                "width": 1024,
350                "height": 768,
351                "created_at": "2024-01-01T10:00:00-05:00"
352            }
353        }"#;
354
355        let collection: CustomCollection = serde_json::from_str(json).unwrap();
356
357        assert_eq!(collection.id, Some(841564295));
358        assert_eq!(collection.handle.as_deref(), Some("ipods"));
359        assert_eq!(collection.title.as_deref(), Some("IPods"));
360        assert_eq!(
361            collection.body_html.as_deref(),
362            Some("<p>The best iPods</p>")
363        );
364        assert_eq!(collection.sort_order.as_deref(), Some("manual"));
365        assert_eq!(collection.published_scope.as_deref(), Some("web"));
366        assert!(collection.published_at.is_some());
367        assert!(collection.updated_at.is_some());
368        assert_eq!(
369            collection.admin_graphql_api_id.as_deref(),
370            Some("gid://shopify/Collection/841564295")
371        );
372
373        // Check image
374        let image = collection.image.unwrap();
375        assert_eq!(
376            image.src.as_deref(),
377            Some("https://cdn.shopify.com/s/files/ipods.jpg")
378        );
379        assert_eq!(image.alt.as_deref(), Some("iPods collection"));
380        assert_eq!(image.width, Some(1024));
381        assert_eq!(image.height, Some(768));
382    }
383
384    #[test]
385    fn test_custom_collection_path_constants_are_correct() {
386        // Test Find path
387        let find_path = get_path(CustomCollection::PATHS, ResourceOperation::Find, &["id"]);
388        assert!(find_path.is_some());
389        assert_eq!(find_path.unwrap().template, "custom_collections/{id}");
390        assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
391
392        // Test All path
393        let all_path = get_path(CustomCollection::PATHS, ResourceOperation::All, &[]);
394        assert!(all_path.is_some());
395        assert_eq!(all_path.unwrap().template, "custom_collections");
396
397        // Test Count path
398        let count_path = get_path(CustomCollection::PATHS, ResourceOperation::Count, &[]);
399        assert!(count_path.is_some());
400        assert_eq!(count_path.unwrap().template, "custom_collections/count");
401
402        // Test Create path
403        let create_path = get_path(CustomCollection::PATHS, ResourceOperation::Create, &[]);
404        assert!(create_path.is_some());
405        assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
406
407        // Test Update path
408        let update_path = get_path(CustomCollection::PATHS, ResourceOperation::Update, &["id"]);
409        assert!(update_path.is_some());
410        assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
411
412        // Test Delete path
413        let delete_path = get_path(CustomCollection::PATHS, ResourceOperation::Delete, &["id"]);
414        assert!(delete_path.is_some());
415        assert_eq!(delete_path.unwrap().http_method, HttpMethod::Delete);
416
417        // Verify constants
418        assert_eq!(CustomCollection::NAME, "CustomCollection");
419        assert_eq!(CustomCollection::PLURAL, "custom_collections");
420    }
421
422    #[test]
423    fn test_custom_collection_get_id_returns_correct_value() {
424        let collection_with_id = CustomCollection {
425            id: Some(841564295),
426            title: Some("Test Collection".to_string()),
427            ..Default::default()
428        };
429        assert_eq!(collection_with_id.get_id(), Some(841564295));
430
431        let collection_without_id = CustomCollection {
432            id: None,
433            title: Some("New Collection".to_string()),
434            ..Default::default()
435        };
436        assert_eq!(collection_without_id.get_id(), None);
437    }
438
439    #[test]
440    fn test_custom_collection_list_params_serialization() {
441        let params = CustomCollectionListParams {
442            ids: Some(vec![123, 456, 789]),
443            limit: Some(50),
444            since_id: Some(100),
445            title: Some("Summer".to_string()),
446            handle: Some("summer-sale".to_string()),
447            product_id: Some(999),
448            published_status: Some("published".to_string()),
449            ..Default::default()
450        };
451
452        let json = serde_json::to_value(&params).unwrap();
453
454        assert_eq!(json["ids"], serde_json::json!([123, 456, 789]));
455        assert_eq!(json["limit"], 50);
456        assert_eq!(json["since_id"], 100);
457        assert_eq!(json["title"], "Summer");
458        assert_eq!(json["handle"], "summer-sale");
459        assert_eq!(json["product_id"], 999);
460        assert_eq!(json["published_status"], "published");
461
462        // Test empty params
463        let empty_params = CustomCollectionListParams::default();
464        let empty_json = serde_json::to_value(&empty_params).unwrap();
465        assert_eq!(empty_json, serde_json::json!({}));
466    }
467
468    #[test]
469    fn test_collection_image_handling() {
470        // Test collection with image
471        let collection = CustomCollection {
472            title: Some("Image Test".to_string()),
473            image: Some(CollectionImage {
474                src: Some("https://example.com/image.jpg".to_string()),
475                alt: Some("Collection image".to_string()),
476                width: Some(800),
477                height: Some(600),
478                created_at: None,
479            }),
480            ..Default::default()
481        };
482
483        let json = serde_json::to_string(&collection).unwrap();
484        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
485
486        let image = &parsed["image"];
487        assert_eq!(image["src"], "https://example.com/image.jpg");
488        assert_eq!(image["alt"], "Collection image");
489        assert_eq!(image["width"], 800);
490        assert_eq!(image["height"], 600);
491        // created_at should be skipped in serialization
492        assert!(image.get("created_at").is_none());
493    }
494
495    #[test]
496    fn test_sort_order_field() {
497        // Test various sort_order values
498        let sort_orders = vec![
499            "alpha-asc",
500            "alpha-desc",
501            "best-selling",
502            "created",
503            "created-desc",
504            "manual",
505            "price-asc",
506            "price-desc",
507        ];
508
509        for sort_order in sort_orders {
510            let collection = CustomCollection {
511                title: Some("Test".to_string()),
512                sort_order: Some(sort_order.to_string()),
513                ..Default::default()
514            };
515
516            let json = serde_json::to_string(&collection).unwrap();
517            let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
518
519            assert_eq!(parsed["sort_order"], sort_order);
520        }
521    }
522
523    #[test]
524    fn test_custom_collection_count_params_serialization() {
525        let params = CustomCollectionCountParams {
526            title: Some("Summer".to_string()),
527            product_id: Some(12345),
528            published_status: Some("published".to_string()),
529            ..Default::default()
530        };
531
532        let json = serde_json::to_value(&params).unwrap();
533
534        assert_eq!(json["title"], "Summer");
535        assert_eq!(json["product_id"], 12345);
536        assert_eq!(json["published_status"], "published");
537
538        let empty_params = CustomCollectionCountParams::default();
539        let empty_json = serde_json::to_value(&empty_params).unwrap();
540        assert_eq!(empty_json, serde_json::json!({}));
541    }
542}