Skip to main content

ghost_io_api/models/
page.rs

1//! Page model for Ghost API.
2//!
3//! A Ghost page is a static content item (About, Contact, etc.) as opposed to
4//! a chronological post. The schema mirrors [`super::post::Post`] closely.
5//!
6//! # Example
7//!
8//! ```
9//! use ghost_io_api::models::page::{Page, PageStatus};
10//!
11//! let page = Page {
12//!     id: "5e6f7a8b9c0d1e2f3a4b5c6d".to_string(),
13//!     title: "About".to_string(),
14//!     slug: "about".to_string(),
15//!     status: PageStatus::Published,
16//!     ..Default::default()
17//! };
18//!
19//! assert!(page.is_published());
20//! ```
21
22use serde::{Deserialize, Serialize};
23
24/// Publication status of a Ghost page.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
26#[serde(rename_all = "lowercase")]
27pub enum PageStatus {
28    /// Draft — not publicly visible.
29    #[default]
30    Draft,
31    /// Published and publicly accessible.
32    Published,
33    /// Scheduled for future publication.
34    Scheduled,
35}
36
37impl PageStatus {
38    /// Returns `true` if the page is published.
39    ///
40    /// # Example
41    ///
42    /// ```
43    /// use ghost_io_api::models::page::PageStatus;
44    ///
45    /// assert!(PageStatus::Published.is_published());
46    /// assert!(!PageStatus::Draft.is_published());
47    /// ```
48    pub fn is_published(&self) -> bool {
49        matches!(self, PageStatus::Published)
50    }
51
52    /// Returns `true` if the page is a draft.
53    ///
54    /// # Example
55    ///
56    /// ```
57    /// use ghost_io_api::models::page::PageStatus;
58    ///
59    /// assert!(PageStatus::Draft.is_draft());
60    /// ```
61    pub fn is_draft(&self) -> bool {
62        matches!(self, PageStatus::Draft)
63    }
64
65    /// Returns `true` if the page is scheduled.
66    ///
67    /// # Example
68    ///
69    /// ```
70    /// use ghost_io_api::models::page::PageStatus;
71    ///
72    /// assert!(PageStatus::Scheduled.is_scheduled());
73    /// ```
74    pub fn is_scheduled(&self) -> bool {
75        matches!(self, PageStatus::Scheduled)
76    }
77}
78
79/// A Ghost page resource.
80///
81/// Pages are static content items that are not part of the chronological
82/// post feed (e.g. "About", "Contact").
83///
84/// # Example
85///
86/// ```
87/// use ghost_io_api::models::page::{Page, PageStatus};
88///
89/// let page = Page {
90///     id: "5e6f7a8b9c0d1e2f3a4b5c6d".to_string(),
91///     title: "About".to_string(),
92///     slug: "about".to_string(),
93///     status: PageStatus::Published,
94///     ..Default::default()
95/// };
96///
97/// assert_eq!(page.slug, "about");
98/// assert!(page.is_published());
99/// ```
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
101pub struct Page {
102    /// Ghost object ID.
103    pub id: String,
104    /// UUID.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub uuid: Option<String>,
107    /// Page title.
108    pub title: String,
109    /// URL slug.
110    pub slug: String,
111    /// Rendered HTML content.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub html: Option<String>,
114    /// Plaintext content.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub plaintext: Option<String>,
117    /// Feature image URL.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub feature_image: Option<String>,
120    /// Feature image alt text.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub feature_image_alt: Option<String>,
123    /// Feature image caption.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub feature_image_caption: Option<String>,
126    /// Whether the page is featured.
127    #[serde(default)]
128    pub featured: bool,
129    /// Publication status.
130    #[serde(default)]
131    pub status: PageStatus,
132    /// Visibility: `"public"`, `"members"`, `"paid"`, etc.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub visibility: Option<String>,
135    /// ISO 8601 creation timestamp.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub created_at: Option<String>,
138    /// ISO 8601 last-updated timestamp.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub updated_at: Option<String>,
141    /// ISO 8601 publication timestamp.
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub published_at: Option<String>,
144    /// Custom excerpt.
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub custom_excerpt: Option<String>,
147    /// Auto-generated excerpt.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub excerpt: Option<String>,
150    /// Estimated reading time in minutes.
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub reading_time: Option<u32>,
153    /// Whether the page requires a paid membership to access.
154    #[serde(default)]
155    pub access: bool,
156    /// Canonical URL override.
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub canonical_url: Option<String>,
159    /// Public URL of the page on the site.
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub url: Option<String>,
162    /// OG image URL.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub og_image: Option<String>,
165    /// OG title.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub og_title: Option<String>,
168    /// OG description.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub og_description: Option<String>,
171    /// Twitter card image URL.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub twitter_image: Option<String>,
174    /// Twitter card title.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub twitter_title: Option<String>,
177    /// Twitter card description.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub twitter_description: Option<String>,
180    /// Meta title override.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub meta_title: Option<String>,
183    /// Meta description override.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub meta_description: Option<String>,
186    /// Custom template name.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub custom_template: Option<String>,
189}
190
191impl Page {
192    /// Returns `true` if the page is published.
193    pub fn is_published(&self) -> bool {
194        self.status.is_published()
195    }
196
197    /// Returns `true` if the page is a draft.
198    pub fn is_draft(&self) -> bool {
199        self.status.is_draft()
200    }
201
202    /// Returns `true` if the page is scheduled.
203    pub fn is_scheduled(&self) -> bool {
204        self.status.is_scheduled()
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use serde_json::json;
212
213    #[test]
214    fn test_page_status_default() {
215        assert_eq!(PageStatus::default(), PageStatus::Draft);
216    }
217
218    #[test]
219    fn test_page_status_is_published() {
220        assert!(PageStatus::Published.is_published());
221        assert!(!PageStatus::Draft.is_published());
222        assert!(!PageStatus::Scheduled.is_published());
223    }
224
225    #[test]
226    fn test_page_status_is_draft() {
227        assert!(PageStatus::Draft.is_draft());
228        assert!(!PageStatus::Published.is_draft());
229    }
230
231    #[test]
232    fn test_page_status_is_scheduled() {
233        assert!(PageStatus::Scheduled.is_scheduled());
234        assert!(!PageStatus::Published.is_scheduled());
235    }
236
237    #[test]
238    fn test_page_status_serialization() {
239        assert_eq!(
240            serde_json::to_string(&PageStatus::Published).unwrap(),
241            "\"published\""
242        );
243        assert_eq!(
244            serde_json::to_string(&PageStatus::Draft).unwrap(),
245            "\"draft\""
246        );
247        assert_eq!(
248            serde_json::to_string(&PageStatus::Scheduled).unwrap(),
249            "\"scheduled\""
250        );
251    }
252
253    #[test]
254    fn test_page_status_deserialization() {
255        let s: PageStatus = serde_json::from_str("\"published\"").unwrap();
256        assert_eq!(s, PageStatus::Published);
257        let d: PageStatus = serde_json::from_str("\"draft\"").unwrap();
258        assert_eq!(d, PageStatus::Draft);
259    }
260
261    #[test]
262    fn test_page_minimal_deserialization() {
263        let json = json!({
264            "id": "abc123",
265            "title": "About",
266            "slug": "about"
267        });
268        let page: Page = serde_json::from_value(json).unwrap();
269        assert_eq!(page.id, "abc123");
270        assert_eq!(page.title, "About");
271        assert_eq!(page.slug, "about");
272        assert_eq!(page.status, PageStatus::Draft);
273        assert!(!page.featured);
274    }
275
276    #[test]
277    fn test_page_full_deserialization() {
278        let json = json!({
279            "id": "abc123",
280            "uuid": "a-b-c-d",
281            "title": "About",
282            "slug": "about",
283            "html": "<p>Hello</p>",
284            "plaintext": "Hello",
285            "feature_image": "https://example.com/img.jpg",
286            "featured": false,
287            "status": "published",
288            "visibility": "public",
289            "created_at": "2021-01-01T00:00:00.000Z",
290            "updated_at": "2021-01-02T00:00:00.000Z",
291            "published_at": "2021-01-02T00:00:00.000Z",
292            "custom_excerpt": "Custom",
293            "excerpt": "Auto",
294            "reading_time": 2,
295            "access": true,
296            "url": "https://example.com/about/"
297        });
298        let page: Page = serde_json::from_value(json).unwrap();
299        assert_eq!(page.status, PageStatus::Published);
300        assert!(page.is_published());
301        assert_eq!(page.reading_time, Some(2));
302        assert!(page.access);
303    }
304
305    #[test]
306    fn test_page_default() {
307        let page = Page::default();
308        assert_eq!(page.status, PageStatus::Draft);
309        assert!(page.id.is_empty());
310        assert!(!page.featured);
311        assert!(!page.access);
312    }
313
314    #[test]
315    fn test_page_is_methods() {
316        let published = Page {
317            status: PageStatus::Published,
318            ..Default::default()
319        };
320        assert!(published.is_published());
321        assert!(!published.is_draft());
322        assert!(!published.is_scheduled());
323
324        let draft = Page {
325            status: PageStatus::Draft,
326            ..Default::default()
327        };
328        assert!(draft.is_draft());
329
330        let scheduled = Page {
331            status: PageStatus::Scheduled,
332            ..Default::default()
333        };
334        assert!(scheduled.is_scheduled());
335    }
336
337    #[test]
338    fn test_page_serialization_skips_none() {
339        let page = Page {
340            id: "1".to_string(),
341            title: "T".to_string(),
342            slug: "t".to_string(),
343            ..Default::default()
344        };
345        let json = serde_json::to_value(&page).unwrap();
346        let obj = json.as_object().unwrap();
347        assert!(!obj.contains_key("html"));
348        assert!(!obj.contains_key("feature_image"));
349        assert!(!obj.contains_key("og_image"));
350    }
351
352    #[test]
353    fn test_page_clone_eq() {
354        let page = Page {
355            id: "1".to_string(),
356            title: "T".to_string(),
357            slug: "t".to_string(),
358            status: PageStatus::Published,
359            ..Default::default()
360        };
361        assert_eq!(page, page.clone());
362    }
363}