Skip to main content

shopify_sdk/rest/resources/v2025_10/
blog.rs

1//! Blog resource implementation.
2//!
3//! This module provides the Blog resource, which represents a blog
4//! in a Shopify store. Blogs are containers for articles.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use shopify_sdk::rest::{RestResource, ResourceResponse};
10//! use shopify_sdk::rest::resources::v2025_10::{Blog, BlogListParams};
11//! use shopify_sdk::rest::resources::v2025_10::common::BlogCommentable;
12//!
13//! // Find a single blog
14//! let blog = Blog::find(&client, 123, None).await?;
15//! println!("Blog: {}", blog.title.as_deref().unwrap_or(""));
16//!
17//! // List blogs
18//! let params = BlogListParams {
19//!     limit: Some(50),
20//!     ..Default::default()
21//! };
22//! let blogs = Blog::all(&client, Some(params)).await?;
23//!
24//! // Create a new blog with comment moderation
25//! let mut blog = Blog {
26//!     title: Some("News".to_string()),
27//!     commentable: Some(BlogCommentable::Moderate),
28//!     ..Default::default()
29//! };
30//! let saved = blog.save(&client).await?;
31//!
32//! // Count blogs
33//! let count = Blog::count(&client, None).await?;
34//! println!("Total blogs: {}", count);
35//! ```
36
37use chrono::{DateTime, Utc};
38use serde::{Deserialize, Serialize};
39
40use crate::rest::{ResourceOperation, ResourcePath, RestResource};
41use crate::HttpMethod;
42
43use super::common::BlogCommentable;
44
45/// A blog in a Shopify store.
46///
47/// Blogs are containers for articles. A store can have multiple blogs,
48/// each with its own set of articles. Blogs support comment settings
49/// and can integrate with Feedburner.
50///
51/// # Fields
52///
53/// ## Read-Only Fields
54/// - `id` - The unique identifier of the blog
55/// - `created_at` - When the blog was created
56/// - `updated_at` - When the blog was last updated
57/// - `admin_graphql_api_id` - The GraphQL API ID
58///
59/// ## Writable Fields
60/// - `title` - The title of the blog
61/// - `handle` - The URL-friendly handle (auto-generated from title if not set)
62/// - `commentable` - Comment settings (no, moderate, yes)
63/// - `template_suffix` - The suffix of the Liquid template used for the blog
64/// - `feedburner` - The Feedburner URL (optional)
65/// - `feedburner_location` - The Feedburner location (optional)
66/// - `tags` - Comma-separated tags for the blog
67#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
68pub struct Blog {
69    /// The unique identifier of the blog.
70    /// Read-only field.
71    #[serde(skip_serializing)]
72    pub id: Option<u64>,
73
74    /// The title of the blog.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub title: Option<String>,
77
78    /// The URL-friendly handle of the blog.
79    /// Auto-generated from the title if not specified.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub handle: Option<String>,
82
83    /// The comment moderation setting for the blog.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub commentable: Option<BlogCommentable>,
86
87    /// The suffix of the Liquid template used for the blog.
88    /// For example, if the value is "custom", the blog uses the
89    /// `blog.custom.liquid` template.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub template_suffix: Option<String>,
92
93    /// The Feedburner URL for the blog.
94    /// Used to redirect RSS feeds through Feedburner.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub feedburner: Option<String>,
97
98    /// The Feedburner location.
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub feedburner_location: Option<String>,
101
102    /// Comma-separated tags for the blog.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub tags: Option<String>,
105
106    /// When the blog was created.
107    /// Read-only field.
108    #[serde(skip_serializing)]
109    pub created_at: Option<DateTime<Utc>>,
110
111    /// When the blog was last updated.
112    /// Read-only field.
113    #[serde(skip_serializing)]
114    pub updated_at: Option<DateTime<Utc>>,
115
116    /// The admin GraphQL API ID for this blog.
117    /// Read-only field.
118    #[serde(skip_serializing)]
119    pub admin_graphql_api_id: Option<String>,
120}
121
122impl RestResource for Blog {
123    type Id = u64;
124    type FindParams = BlogFindParams;
125    type AllParams = BlogListParams;
126    type CountParams = BlogCountParams;
127
128    const NAME: &'static str = "Blog";
129    const PLURAL: &'static str = "blogs";
130
131    const PATHS: &'static [ResourcePath] = &[
132        ResourcePath::new(
133            HttpMethod::Get,
134            ResourceOperation::Find,
135            &["id"],
136            "blogs/{id}",
137        ),
138        ResourcePath::new(HttpMethod::Get, ResourceOperation::All, &[], "blogs"),
139        ResourcePath::new(
140            HttpMethod::Get,
141            ResourceOperation::Count,
142            &[],
143            "blogs/count",
144        ),
145        ResourcePath::new(HttpMethod::Post, ResourceOperation::Create, &[], "blogs"),
146        ResourcePath::new(
147            HttpMethod::Put,
148            ResourceOperation::Update,
149            &["id"],
150            "blogs/{id}",
151        ),
152        ResourcePath::new(
153            HttpMethod::Delete,
154            ResourceOperation::Delete,
155            &["id"],
156            "blogs/{id}",
157        ),
158    ];
159
160    fn get_id(&self) -> Option<Self::Id> {
161        self.id
162    }
163}
164
165/// Parameters for finding a single blog.
166#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
167pub struct BlogFindParams {
168    /// Comma-separated list of fields to include in the response.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub fields: Option<String>,
171}
172
173/// Parameters for listing blogs.
174#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
175pub struct BlogListParams {
176    /// Filter by blog handle.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub handle: Option<String>,
179
180    /// Show blogs created after this date.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub created_at_min: Option<DateTime<Utc>>,
183
184    /// Show blogs created before this date.
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub created_at_max: Option<DateTime<Utc>>,
187
188    /// Show blogs updated after this date.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub updated_at_min: Option<DateTime<Utc>>,
191
192    /// Show blogs updated before this date.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub updated_at_max: Option<DateTime<Utc>>,
195
196    /// Maximum number of results to return (default: 50, max: 250).
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub limit: Option<u32>,
199
200    /// Return blogs after this ID.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub since_id: Option<u64>,
203
204    /// Cursor for pagination.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub page_info: Option<String>,
207
208    /// Comma-separated list of fields to include in the response.
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub fields: Option<String>,
211}
212
213/// Parameters for counting blogs.
214#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
215pub struct BlogCountParams {
216    // No specific count params for blogs
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::rest::{get_path, ResourceOperation};
223
224    #[test]
225    fn test_blog_struct_serialization() {
226        let blog = Blog {
227            id: Some(12345),
228            title: Some("Company News".to_string()),
229            handle: Some("news".to_string()),
230            commentable: Some(BlogCommentable::Moderate),
231            template_suffix: Some("custom".to_string()),
232            feedburner: Some("https://feeds.feedburner.com/example".to_string()),
233            feedburner_location: Some("example".to_string()),
234            tags: Some("news, updates".to_string()),
235            created_at: Some(
236                DateTime::parse_from_rfc3339("2024-01-10T08:00:00Z")
237                    .unwrap()
238                    .with_timezone(&Utc),
239            ),
240            updated_at: Some(
241                DateTime::parse_from_rfc3339("2024-06-20T15:45:00Z")
242                    .unwrap()
243                    .with_timezone(&Utc),
244            ),
245            admin_graphql_api_id: Some("gid://shopify/OnlineStoreBlog/12345".to_string()),
246        };
247
248        let json = serde_json::to_string(&blog).unwrap();
249        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
250
251        // Writable fields should be present
252        assert_eq!(parsed["title"], "Company News");
253        assert_eq!(parsed["handle"], "news");
254        assert_eq!(parsed["commentable"], "moderate");
255        assert_eq!(parsed["template_suffix"], "custom");
256        assert_eq!(parsed["feedburner"], "https://feeds.feedburner.com/example");
257        assert_eq!(parsed["feedburner_location"], "example");
258        assert_eq!(parsed["tags"], "news, updates");
259
260        // Read-only fields should be omitted
261        assert!(parsed.get("id").is_none());
262        assert!(parsed.get("created_at").is_none());
263        assert!(parsed.get("updated_at").is_none());
264        assert!(parsed.get("admin_graphql_api_id").is_none());
265    }
266
267    #[test]
268    fn test_blog_deserialization_from_api_response() {
269        let json = r#"{
270            "id": 241253187,
271            "handle": "apple-blog",
272            "title": "Apple Blog",
273            "updated_at": "2024-06-20T15:45:00Z",
274            "commentable": "no",
275            "feedburner": null,
276            "feedburner_location": null,
277            "created_at": "2024-01-10T08:00:00Z",
278            "template_suffix": null,
279            "tags": "apple, tech",
280            "admin_graphql_api_id": "gid://shopify/OnlineStoreBlog/241253187"
281        }"#;
282
283        let blog: Blog = serde_json::from_str(json).unwrap();
284
285        assert_eq!(blog.id, Some(241253187));
286        assert_eq!(blog.handle, Some("apple-blog".to_string()));
287        assert_eq!(blog.title, Some("Apple Blog".to_string()));
288        assert_eq!(blog.commentable, Some(BlogCommentable::No));
289        assert!(blog.feedburner.is_none());
290        assert!(blog.feedburner_location.is_none());
291        assert!(blog.template_suffix.is_none());
292        assert_eq!(blog.tags, Some("apple, tech".to_string()));
293        assert!(blog.created_at.is_some());
294        assert!(blog.updated_at.is_some());
295        assert_eq!(
296            blog.admin_graphql_api_id,
297            Some("gid://shopify/OnlineStoreBlog/241253187".to_string())
298        );
299    }
300
301    #[test]
302    fn test_blog_commentable_enum_handling() {
303        // Test all BlogCommentable variants in blog context
304        let variants = [
305            (BlogCommentable::No, "no"),
306            (BlogCommentable::Moderate, "moderate"),
307            (BlogCommentable::Yes, "yes"),
308        ];
309
310        for (commentable, expected_str) in variants {
311            let blog = Blog {
312                title: Some("Test Blog".to_string()),
313                commentable: Some(commentable),
314                ..Default::default()
315            };
316
317            let json = serde_json::to_value(&blog).unwrap();
318            assert_eq!(json["commentable"], expected_str);
319        }
320    }
321
322    #[test]
323    fn test_blog_list_params_serialization() {
324        let params = BlogListParams {
325            handle: Some("news".to_string()),
326            limit: Some(50),
327            since_id: Some(100),
328            ..Default::default()
329        };
330
331        let json = serde_json::to_value(&params).unwrap();
332
333        assert_eq!(json["handle"], "news");
334        assert_eq!(json["limit"], 50);
335        assert_eq!(json["since_id"], 100);
336
337        // Fields not set should be omitted
338        assert!(json.get("created_at_min").is_none());
339        assert!(json.get("page_info").is_none());
340    }
341
342    #[test]
343    fn test_blog_path_constants_are_correct() {
344        // Test Find path
345        let find_path = get_path(Blog::PATHS, ResourceOperation::Find, &["id"]);
346        assert!(find_path.is_some());
347        assert_eq!(find_path.unwrap().template, "blogs/{id}");
348        assert_eq!(find_path.unwrap().http_method, HttpMethod::Get);
349
350        // Test All path
351        let all_path = get_path(Blog::PATHS, ResourceOperation::All, &[]);
352        assert!(all_path.is_some());
353        assert_eq!(all_path.unwrap().template, "blogs");
354        assert_eq!(all_path.unwrap().http_method, HttpMethod::Get);
355
356        // Test Count path
357        let count_path = get_path(Blog::PATHS, ResourceOperation::Count, &[]);
358        assert!(count_path.is_some());
359        assert_eq!(count_path.unwrap().template, "blogs/count");
360        assert_eq!(count_path.unwrap().http_method, HttpMethod::Get);
361
362        // Test Create path
363        let create_path = get_path(Blog::PATHS, ResourceOperation::Create, &[]);
364        assert!(create_path.is_some());
365        assert_eq!(create_path.unwrap().template, "blogs");
366        assert_eq!(create_path.unwrap().http_method, HttpMethod::Post);
367
368        // Test Update path
369        let update_path = get_path(Blog::PATHS, ResourceOperation::Update, &["id"]);
370        assert!(update_path.is_some());
371        assert_eq!(update_path.unwrap().template, "blogs/{id}");
372        assert_eq!(update_path.unwrap().http_method, HttpMethod::Put);
373
374        // Test Delete path
375        let delete_path = get_path(Blog::PATHS, ResourceOperation::Delete, &["id"]);
376        assert!(delete_path.is_some());
377        assert_eq!(delete_path.unwrap().template, "blogs/{id}");
378        assert_eq!(delete_path.unwrap().http_method, HttpMethod::Delete);
379
380        // Verify constants
381        assert_eq!(Blog::NAME, "Blog");
382        assert_eq!(Blog::PLURAL, "blogs");
383    }
384
385    #[test]
386    fn test_blog_get_id_returns_correct_value() {
387        // Blog with ID
388        let blog_with_id = Blog {
389            id: Some(123456789),
390            title: Some("Test Blog".to_string()),
391            ..Default::default()
392        };
393        assert_eq!(blog_with_id.get_id(), Some(123456789));
394
395        // Blog without ID (new blog)
396        let blog_without_id = Blog {
397            id: None,
398            title: Some("New Blog".to_string()),
399            ..Default::default()
400        };
401        assert_eq!(blog_without_id.get_id(), None);
402    }
403
404    #[test]
405    fn test_blog_tags_field_handling() {
406        // Tags as comma-separated string
407        let blog = Blog {
408            title: Some("Tech Blog".to_string()),
409            tags: Some("tech, programming, rust, web".to_string()),
410            ..Default::default()
411        };
412
413        let json = serde_json::to_value(&blog).unwrap();
414        assert_eq!(json["tags"], "tech, programming, rust, web");
415
416        // Deserialize tags back
417        let deserialized: Blog = serde_json::from_value(json).unwrap();
418        assert_eq!(
419            deserialized.tags,
420            Some("tech, programming, rust, web".to_string())
421        );
422    }
423}