Skip to main content

ghost_io_api/models/
post.rs

1//! Post model for Ghost API.
2//!
3//! Represents a post/article in Ghost with all its associated metadata,
4//! content, and relationships.
5//!
6//! # Example
7//!
8//! ```
9//! use ghost_io_api::models::post::{Post, PostStatus};
10//!
11//! // Typically posts come from API responses
12//! let post = Post {
13//!     id: "5ddc9141c35e7700383b2937".to_string(),
14//!     uuid: Some("a5aa9bd8-ea31-415c-b452-3040dae1e730".to_string()),
15//!     title: "Welcome".to_string(),
16//!     slug: "welcome-short".to_string(),
17//!     status: PostStatus::Published,
18//!     visibility: Some("public".to_string()),
19//!     created_at: Some("2019-11-26T02:43:13.000Z".to_string()),
20//!     updated_at: Some("2019-11-26T02:44:17.000Z".to_string()),
21//!     published_at: Some("2019-11-26T02:44:17.000Z".to_string()),
22//!     // ... other fields
23//!     ..Default::default()
24//! };
25//!
26//! assert_eq!(post.status, PostStatus::Published);
27//! assert!(post.is_published());
28//! ```
29
30use serde::{Deserialize, Serialize};
31
32/// Status of a Ghost post.
33///
34/// Posts can be in one of four states throughout their lifecycle.
35///
36/// # Example
37///
38/// ```
39/// use ghost_io_api::models::post::PostStatus;
40/// use serde_json;
41///
42/// let json = serde_json::json!("published");
43/// let status: PostStatus = serde_json::from_value(json).unwrap();
44/// assert_eq!(status, PostStatus::Published);
45/// ```
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
47#[serde(rename_all = "lowercase")]
48pub enum PostStatus {
49    /// Post is a draft, not visible publicly.
50    #[default]
51    Draft,
52    /// Post is published and visible.
53    Published,
54    /// Post is scheduled for future publication.
55    Scheduled,
56    /// Post was sent as an email newsletter.
57    Sent,
58}
59
60impl PostStatus {
61    /// Returns `true` if the post is published.
62    ///
63    /// # Example
64    ///
65    /// ```
66    /// use ghost_io_api::models::post::PostStatus;
67    ///
68    /// assert!(PostStatus::Published.is_published());
69    /// assert!(!PostStatus::Draft.is_published());
70    /// ```
71    pub fn is_published(&self) -> bool {
72        matches!(self, PostStatus::Published)
73    }
74
75    /// Returns `true` if the post is a draft.
76    ///
77    /// # Example
78    ///
79    /// ```
80    /// use ghost_io_api::models::post::PostStatus;
81    ///
82    /// assert!(PostStatus::Draft.is_draft());
83    /// assert!(!PostStatus::Published.is_draft());
84    /// ```
85    pub fn is_draft(&self) -> bool {
86        matches!(self, PostStatus::Draft)
87    }
88
89    /// Returns `true` if the post is scheduled.
90    ///
91    /// # Example
92    ///
93    /// ```
94    /// use ghost_io_api::models::post::PostStatus;
95    ///
96    /// assert!(PostStatus::Scheduled.is_scheduled());
97    /// assert!(!PostStatus::Draft.is_scheduled());
98    /// ```
99    pub fn is_scheduled(&self) -> bool {
100        matches!(self, PostStatus::Scheduled)
101    }
102
103    /// Returns `true` if the post was sent as email.
104    ///
105    /// # Example
106    ///
107    /// ```
108    /// use ghost_io_api::models::post::PostStatus;
109    ///
110    /// assert!(PostStatus::Sent.is_sent());
111    /// assert!(!PostStatus::Draft.is_sent());
112    /// ```
113    pub fn is_sent(&self) -> bool {
114        matches!(self, PostStatus::Sent)
115    }
116}
117
118/// A Ghost post/article.
119///
120/// Represents a post with all its metadata, content, and relationships.
121/// Posts can contain Lexical JSON content or HTML, along with extensive
122/// metadata for SEO, social sharing, and organization.
123///
124/// # Required Fields
125///
126/// Only `title` is required when creating a new post. The Ghost API will
127/// generate IDs, slugs, and timestamps automatically.
128///
129/// # Field Details
130///
131/// * **Identifiers:** `id`, `uuid`, `slug`, `comment_id`
132/// * **Content:** `title`, `lexical`, `html`, `excerpt`, `custom_excerpt`
133/// * **Status:** `status`, `visibility`, `email_only`
134/// * **Timestamps:** `created_at`, `updated_at`, `published_at`
135/// * **Media:** `feature_image`, `feature_image_alt`, `feature_image_caption`
136/// * **Flags:** `featured`
137/// * **Relationships:** `tags`, `authors`, `primary_author`, `primary_tag`, `newsletter`
138/// * **SEO:** `meta_title`, `meta_description`, `canonical_url`, `url`
139/// * **Social:** `og_*` (Open Graph), `twitter_*` (Twitter Cards)
140/// * **Code Injection:** `codeinjection_head`, `codeinjection_foot`, `custom_template`
141///
142/// # Example
143///
144/// ```
145/// use ghost_io_api::models::post::{Post, PostStatus};
146/// use serde_json::json;
147///
148/// let json = json!({
149///     "id": "5ddc9141c35e7700383b2937",
150///     "title": "Welcome",
151///     "slug": "welcome-short",
152///     "status": "published",
153///     "visibility": "public",
154///     "created_at": "2019-11-26T02:43:13.000Z",
155///     "published_at": "2019-11-26T02:44:17.000Z"
156/// });
157///
158/// let post: Post = serde_json::from_value(json).unwrap();
159/// assert_eq!(post.title, "Welcome");
160/// assert_eq!(post.status, PostStatus::Published);
161/// assert!(post.is_published());
162/// ```
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
164pub struct Post {
165    // === Identifiers ===
166    /// Unique post ID (24-character hex string).
167    #[serde(skip_serializing_if = "String::is_empty", default)]
168    pub id: String,
169
170    /// UUID of the post (RFC 4122).
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub uuid: Option<String>,
173
174    /// URL-friendly identifier (unique, auto-generated from title).
175    #[serde(skip_serializing_if = "String::is_empty", default)]
176    pub slug: String,
177
178    /// ID used for Disqus/comment systems (typically same as `id`).
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub comment_id: Option<String>,
181
182    // === Content ===
183    /// Post title (**required** on create).
184    pub title: String,
185
186    /// Rich text content in Lexical JSON format.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub lexical: Option<String>,
189
190    /// Rendered HTML content (read-only, use `?formats=html`).
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub html: Option<String>,
193
194    /// Auto-generated excerpt from content.
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub excerpt: Option<String>,
197
198    /// Custom excerpt/summary (overrides auto-generated).
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub custom_excerpt: Option<String>,
201
202    // === Status & Visibility ===
203    /// Publication status: draft, published, scheduled, or sent.
204    #[serde(default)]
205    pub status: PostStatus,
206
207    /// Who can see the post: "public", "members", "paid", "tiers".
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub visibility: Option<String>,
210
211    /// If `true`, post is sent as email only (no web post).
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub email_only: Option<bool>,
214
215    // === Timestamps ===
216    /// When the post was created (ISO 8601).
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub created_at: Option<String>,
219
220    /// When the post was last updated (ISO 8601).
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub updated_at: Option<String>,
223
224    /// When the post was/will be published (ISO 8601).
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub published_at: Option<String>,
227
228    // === Media ===
229    /// URL to the feature image.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub feature_image: Option<String>,
232
233    /// Alt text for the feature image (accessibility).
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub feature_image_alt: Option<String>,
236
237    /// Caption for the feature image.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub feature_image_caption: Option<String>,
240
241    // === Flags ===
242    /// Whether this post is featured/highlighted.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub featured: Option<bool>,
245
246    // === Relationships (simplified) ===
247    // Note: Full tag/author/newsletter objects would require separate types.
248    // For now, we use serde_json::Value to accept any structure.
249    /// Associated tags (array of tag objects or strings).
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub tags: Option<serde_json::Value>,
252
253    /// Post authors (array of author objects or email strings).
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub authors: Option<serde_json::Value>,
256
257    /// Primary author object.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub primary_author: Option<serde_json::Value>,
260
261    /// Primary tag object.
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub primary_tag: Option<serde_json::Value>,
264
265    /// Newsletter this post belongs to.
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub newsletter: Option<serde_json::Value>,
268
269    /// Email send metadata (for posts sent as emails).
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub email: Option<serde_json::Value>,
272
273    // === URLs ===
274    /// Public URL of the post (read-only).
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub url: Option<String>,
277
278    /// Override canonical URL for SEO.
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub canonical_url: Option<String>,
281
282    // === SEO ===
283    /// Custom meta title for `<title>` tag.
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub meta_title: Option<String>,
286
287    /// Custom meta description for `<meta name="description">`.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub meta_description: Option<String>,
290
291    // === Open Graph (Facebook) ===
292    /// Custom Open Graph image URL.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub og_image: Option<String>,
295
296    /// Custom Open Graph title.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub og_title: Option<String>,
299
300    /// Custom Open Graph description.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub og_description: Option<String>,
303
304    // === Twitter Cards ===
305    /// Custom Twitter Card image URL.
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub twitter_image: Option<String>,
308
309    /// Custom Twitter Card title.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub twitter_title: Option<String>,
312
313    /// Custom Twitter Card description.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub twitter_description: Option<String>,
316
317    // === Code Injection ===
318    /// Custom HTML injected into `<head>` (Ghost Admin only).
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub codeinjection_head: Option<String>,
321
322    /// Custom HTML injected before `</body>` (Ghost Admin only).
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub codeinjection_foot: Option<String>,
325
326    /// Custom theme template name to use for rendering.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub custom_template: Option<String>,
329}
330
331impl Post {
332    /// Returns `true` if the post is published.
333    ///
334    /// # Example
335    ///
336    /// ```
337    /// use ghost_io_api::models::post::{Post, PostStatus};
338    ///
339    /// let mut post = Post::default();
340    /// post.status = PostStatus::Published;
341    /// assert!(post.is_published());
342    /// ```
343    pub fn is_published(&self) -> bool {
344        self.status.is_published()
345    }
346
347    /// Returns `true` if the post is a draft.
348    ///
349    /// # Example
350    ///
351    /// ```
352    /// use ghost_io_api::models::post::{Post, PostStatus};
353    ///
354    /// let mut post = Post::default();
355    /// post.status = PostStatus::Draft;
356    /// assert!(post.is_draft());
357    /// ```
358    pub fn is_draft(&self) -> bool {
359        self.status.is_draft()
360    }
361
362    /// Returns `true` if the post is scheduled for future publication.
363    ///
364    /// # Example
365    ///
366    /// ```
367    /// use ghost_io_api::models::post::{Post, PostStatus};
368    ///
369    /// let mut post = Post::default();
370    /// post.status = PostStatus::Scheduled;
371    /// assert!(post.is_scheduled());
372    /// ```
373    pub fn is_scheduled(&self) -> bool {
374        self.status.is_scheduled()
375    }
376
377    /// Returns `true` if the post was sent as an email newsletter.
378    ///
379    /// # Example
380    ///
381    /// ```
382    /// use ghost_io_api::models::post::{Post, PostStatus};
383    ///
384    /// let mut post = Post::default();
385    /// post.status = PostStatus::Sent;
386    /// assert!(post.is_sent());
387    /// ```
388    pub fn is_sent(&self) -> bool {
389        self.status.is_sent()
390    }
391
392    /// Returns `true` if the post is featured.
393    ///
394    /// # Example
395    ///
396    /// ```
397    /// use ghost_io_api::models::post::Post;
398    ///
399    /// let mut post = Post::default();
400    /// post.featured = Some(true);
401    /// assert!(post.is_featured());
402    /// ```
403    pub fn is_featured(&self) -> bool {
404        self.featured.unwrap_or(false)
405    }
406
407    /// Returns `true` if the post is email-only (no web post).
408    ///
409    /// # Example
410    ///
411    /// ```
412    /// use ghost_io_api::models::post::Post;
413    ///
414    /// let mut post = Post::default();
415    /// post.email_only = Some(true);
416    /// assert!(post.is_email_only());
417    /// ```
418    pub fn is_email_only(&self) -> bool {
419        self.email_only.unwrap_or(false)
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use serde_json::json;
427
428    #[test]
429    fn test_post_status_deserialization() {
430        assert_eq!(
431            serde_json::from_str::<PostStatus>("\"draft\"").unwrap(),
432            PostStatus::Draft
433        );
434        assert_eq!(
435            serde_json::from_str::<PostStatus>("\"published\"").unwrap(),
436            PostStatus::Published
437        );
438        assert_eq!(
439            serde_json::from_str::<PostStatus>("\"scheduled\"").unwrap(),
440            PostStatus::Scheduled
441        );
442        assert_eq!(
443            serde_json::from_str::<PostStatus>("\"sent\"").unwrap(),
444            PostStatus::Sent
445        );
446    }
447
448    #[test]
449    fn test_post_status_serialization() {
450        assert_eq!(
451            serde_json::to_string(&PostStatus::Draft).unwrap(),
452            "\"draft\""
453        );
454        assert_eq!(
455            serde_json::to_string(&PostStatus::Published).unwrap(),
456            "\"published\""
457        );
458        assert_eq!(
459            serde_json::to_string(&PostStatus::Scheduled).unwrap(),
460            "\"scheduled\""
461        );
462        assert_eq!(
463            serde_json::to_string(&PostStatus::Sent).unwrap(),
464            "\"sent\""
465        );
466    }
467
468    #[test]
469    fn test_post_status_methods() {
470        assert!(PostStatus::Draft.is_draft());
471        assert!(!PostStatus::Draft.is_published());
472        assert!(!PostStatus::Draft.is_scheduled());
473        assert!(!PostStatus::Draft.is_sent());
474
475        assert!(!PostStatus::Published.is_draft());
476        assert!(PostStatus::Published.is_published());
477        assert!(!PostStatus::Published.is_scheduled());
478        assert!(!PostStatus::Published.is_sent());
479
480        assert!(!PostStatus::Scheduled.is_draft());
481        assert!(!PostStatus::Scheduled.is_published());
482        assert!(PostStatus::Scheduled.is_scheduled());
483        assert!(!PostStatus::Scheduled.is_sent());
484
485        assert!(!PostStatus::Sent.is_draft());
486        assert!(!PostStatus::Sent.is_published());
487        assert!(!PostStatus::Sent.is_scheduled());
488        assert!(PostStatus::Sent.is_sent());
489    }
490
491    #[test]
492    fn test_post_minimal_deserialization() {
493        let json = json!({
494            "id": "5ddc9141c35e7700383b2937",
495            "title": "Welcome",
496            "slug": "welcome-short",
497            "status": "published"
498        });
499
500        let post: Post = serde_json::from_value(json).unwrap();
501        assert_eq!(post.id, "5ddc9141c35e7700383b2937");
502        assert_eq!(post.title, "Welcome");
503        assert_eq!(post.slug, "welcome-short");
504        assert_eq!(post.status, PostStatus::Published);
505    }
506
507    #[test]
508    fn test_post_full_deserialization() {
509        let json = json!({
510            "id": "5ddc9141c35e7700383b2937",
511            "uuid": "a5aa9bd8-ea31-415c-b452-3040dae1e730",
512            "title": "Welcome",
513            "slug": "welcome-short",
514            "status": "published",
515            "visibility": "public",
516            "created_at": "2019-11-26T02:43:13.000Z",
517            "updated_at": "2019-11-26T02:44:17.000Z",
518            "published_at": "2019-11-26T02:44:17.000Z",
519            "feature_image": "https://example.com/image.png",
520            "featured": true,
521            "excerpt": "Welcome excerpt",
522            "custom_excerpt": "Custom excerpt",
523            "meta_title": "Welcome | My Site",
524            "meta_description": "A welcoming post",
525            "og_title": "Welcome",
526            "og_description": "OG description",
527            "twitter_title": "Welcome on Twitter",
528            "email_only": false,
529            "canonical_url": "https://example.com/welcome"
530        });
531
532        let post: Post = serde_json::from_value(json).unwrap();
533        assert_eq!(post.id, "5ddc9141c35e7700383b2937");
534        assert_eq!(
535            post.uuid,
536            Some("a5aa9bd8-ea31-415c-b452-3040dae1e730".to_string())
537        );
538        assert_eq!(post.title, "Welcome");
539        assert_eq!(post.status, PostStatus::Published);
540        assert_eq!(post.visibility, Some("public".to_string()));
541        assert_eq!(post.featured, Some(true));
542        assert_eq!(post.email_only, Some(false));
543        assert_eq!(post.meta_title, Some("Welcome | My Site".to_string()));
544    }
545
546    #[test]
547    fn test_post_with_relationships() {
548        let json = json!({
549            "id": "123",
550            "title": "Post with tags",
551            "slug": "post-with-tags",
552            "status": "draft",
553            "tags": [
554                {"id": "tag1", "name": "Tech"},
555                {"id": "tag2", "name": "Rust"}
556            ],
557            "authors": [
558                {"id": "author1", "name": "John Doe", "email": "john@example.com"}
559            ]
560        });
561
562        let post: Post = serde_json::from_value(json).unwrap();
563        assert_eq!(post.title, "Post with tags");
564        assert!(post.tags.is_some());
565        assert!(post.authors.is_some());
566    }
567
568    #[test]
569    fn test_post_serialization() {
570        let post = Post {
571            id: "123".to_string(),
572            title: "Test Post".to_string(),
573            slug: "test-post".to_string(),
574            status: PostStatus::Published,
575            visibility: Some("public".to_string()),
576            featured: Some(true),
577            ..Default::default()
578        };
579
580        let json = serde_json::to_value(&post).unwrap();
581        assert_eq!(json["id"], "123");
582        assert_eq!(json["title"], "Test Post");
583        assert_eq!(json["slug"], "test-post");
584        assert_eq!(json["status"], "published");
585        assert_eq!(json["visibility"], "public");
586        assert_eq!(json["featured"], true);
587    }
588
589    #[test]
590    fn test_post_serialization_skips_none() {
591        let post = Post {
592            id: "123".to_string(),
593            title: "Minimal Post".to_string(),
594            slug: "minimal".to_string(),
595            status: PostStatus::Draft,
596            ..Default::default()
597        };
598
599        let json = serde_json::to_value(&post).unwrap();
600        assert!(!json.as_object().unwrap().contains_key("uuid"));
601        assert!(!json.as_object().unwrap().contains_key("feature_image"));
602        assert!(!json.as_object().unwrap().contains_key("tags"));
603    }
604
605    #[test]
606    fn test_post_default() {
607        let post = Post::default();
608        assert_eq!(post.id, "");
609        assert_eq!(post.title, "");
610        assert_eq!(post.slug, "");
611        assert_eq!(post.status, PostStatus::Draft);
612        assert_eq!(post.uuid, None);
613        assert_eq!(post.featured, None);
614    }
615
616    #[test]
617    fn test_post_is_published() {
618        let post = Post {
619            status: PostStatus::Published,
620            ..Default::default()
621        };
622        assert!(post.is_published());
623        assert!(!post.is_draft());
624    }
625
626    #[test]
627    fn test_post_is_draft() {
628        let post = Post {
629            status: PostStatus::Draft,
630            ..Default::default()
631        };
632        assert!(post.is_draft());
633        assert!(!post.is_published());
634    }
635
636    #[test]
637    fn test_post_is_scheduled() {
638        let post = Post {
639            status: PostStatus::Scheduled,
640            ..Default::default()
641        };
642        assert!(post.is_scheduled());
643        assert!(!post.is_published());
644    }
645
646    #[test]
647    fn test_post_is_sent() {
648        let post = Post {
649            status: PostStatus::Sent,
650            ..Default::default()
651        };
652        assert!(post.is_sent());
653        assert!(!post.is_published());
654    }
655
656    #[test]
657    fn test_post_is_featured() {
658        let mut post = Post::default();
659        assert!(!post.is_featured());
660
661        post.featured = Some(true);
662        assert!(post.is_featured());
663
664        post.featured = Some(false);
665        assert!(!post.is_featured());
666    }
667
668    #[test]
669    fn test_post_is_email_only() {
670        let mut post = Post::default();
671        assert!(!post.is_email_only());
672
673        post.email_only = Some(true);
674        assert!(post.is_email_only());
675
676        post.email_only = Some(false);
677        assert!(!post.is_email_only());
678    }
679
680    #[test]
681    fn test_post_clone() {
682        let post = Post {
683            id: "123".to_string(),
684            title: "Clone Test".to_string(),
685            slug: "clone-test".to_string(),
686            status: PostStatus::Published,
687            ..Default::default()
688        };
689
690        let cloned = post.clone();
691        assert_eq!(post, cloned);
692    }
693
694    #[test]
695    fn test_post_status_clone() {
696        let status = PostStatus::Published;
697        let cloned = status;
698        assert_eq!(status, cloned);
699    }
700}