Skip to main content

ghost_io_api/models/
envelope.rs

1//! Generic response envelopes for Ghost API endpoints.
2//!
3//! Ghost wraps every response in a resource-keyed JSON object. Browse endpoints
4//! include a `meta` block for pagination; read, create, and update endpoints
5//! return a one-element array without `meta`. These two envelope shapes are
6//! modelled by [`BrowseEnvelope`] and [`SingleEnvelope`] respectively.
7//!
8//! Both types use a custom `Deserialize` implementation that locates the
9//! resource array by scanning the JSON object for the first array-valued field
10//! (skipping `meta`). This means a single generic type works for every Ghost
11//! resource — `posts`, `pages`, `tags`, `authors`, and any future resource —
12//! without requiring per-resource newtype wrappers.
13//!
14//! # Examples
15//!
16//! ```
17//! use ghost_io_api::models::envelope::{BrowseEnvelope, SingleEnvelope};
18//! use serde::Deserialize;
19//! use serde_json::json;
20//!
21//! #[derive(Debug, Deserialize)]
22//! struct Widget { id: String, name: String }
23//!
24//! // Browse: items + pagination
25//! let browse_json = json!({
26//!     "widgets": [
27//!         {"id": "w1", "name": "Alpha"},
28//!         {"id": "w2", "name": "Beta"}
29//!     ],
30//!     "meta": {
31//!         "pagination": {
32//!             "page": 1, "limit": 15, "pages": 1, "total": 2
33//!         }
34//!     }
35//! });
36//! let env: BrowseEnvelope<Widget> = serde_json::from_value(browse_json).unwrap();
37//! assert_eq!(env.items.len(), 2);
38//! assert_eq!(env.meta.pagination.total, 2);
39//!
40//! // Single: one item in the array, no meta
41//! let read_json = json!({ "widgets": [{"id": "w1", "name": "Alpha"}] });
42//! let env: SingleEnvelope<Widget> = serde_json::from_value(read_json).unwrap();
43//! assert_eq!(env.item.id, "w1");
44//! ```
45
46use crate::models::pagination::Meta;
47use serde::de::DeserializeOwned;
48use serde::{Deserialize, Deserializer};
49use serde_json::Value;
50
51// ── BrowseEnvelope ────────────────────────────────────────────────────────────
52
53/// Response envelope for Ghost browse endpoints.
54///
55/// Browse endpoints return `{"<resource>": [...], "meta": {...}}`. The resource
56/// key differs by type (`"posts"`, `"tags"`, `"authors"`, …), but the shape is
57/// always the same: an array of items plus a `"meta"` block containing
58/// pagination details.
59///
60/// `BrowseEnvelope<T>` deserializes any such object by scanning for the first
61/// array-valued field that is not `"meta"`. You never need to specify the
62/// resource key; the same type works for every Ghost resource.
63///
64/// # Fields
65///
66/// * `items` — the deserialized resource objects from the current page.
67/// * `meta`  — pagination metadata (`page`, `limit`, `pages`, `total`, …).
68///
69/// # Example
70///
71/// ```
72/// use ghost_io_api::models::envelope::BrowseEnvelope;
73/// use serde::Deserialize;
74/// use serde_json::json;
75///
76/// #[derive(Debug, Deserialize)]
77/// struct Post { id: String, title: String }
78///
79/// let json = json!({
80///     "posts": [{"id": "abc", "title": "Hello"}],
81///     "meta": {
82///         "pagination": {"page": 1, "limit": 15, "pages": 1, "total": 1}
83///     }
84/// });
85///
86/// let env: BrowseEnvelope<Post> = serde_json::from_value(json).unwrap();
87/// assert_eq!(env.items[0].id, "abc");
88/// assert_eq!(env.meta.pagination.page, 1);
89/// assert_eq!(env.meta.pagination.total, 1);
90/// ```
91#[derive(Debug, Clone)]
92pub struct BrowseEnvelope<T> {
93    /// The resource items returned by this page.
94    pub items: Vec<T>,
95    /// Pagination metadata.
96    pub meta: Meta,
97}
98
99impl<'de, T: DeserializeOwned> Deserialize<'de> for BrowseEnvelope<T> {
100    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
101        use serde::de::Error;
102
103        let mut map: serde_json::Map<String, Value> = serde_json::Map::deserialize(deserializer)?;
104
105        // Extract `meta` before scanning for items so it is not mistaken for
106        // an array-valued resource field (it is an object, but just in case).
107        let meta_val = map.remove("meta").unwrap_or(Value::Null);
108        let meta = serde_json::from_value::<Meta>(meta_val).map_err(Error::custom)?;
109
110        // The resource array is the first (and typically only) remaining field
111        // that holds a JSON array.
112        let items_val = map
113            .into_values()
114            .find(|v| v.is_array())
115            .ok_or_else(|| Error::custom("no resource array found in browse envelope"))?;
116
117        let items = serde_json::from_value::<Vec<T>>(items_val).map_err(Error::custom)?;
118
119        Ok(Self { items, meta })
120    }
121}
122
123// ── SingleEnvelope ────────────────────────────────────────────────────────────
124
125/// Response envelope for Ghost read, create, and update endpoints.
126///
127/// These endpoints return `{"<resource>": [item]}` — a one-element array
128/// wrapped in a resource-keyed object, with no `"meta"` block. `SingleEnvelope`
129/// deserializes any such object, extracts the first element of the array, and
130/// stores it as `item`.
131///
132/// As with [`BrowseEnvelope`], the resource key (`"posts"`, `"pages"`, …) is
133/// detected automatically; no per-resource specialisation is required.
134///
135/// # Fields
136///
137/// * `item` — the single deserialized resource object.
138///
139/// # Errors
140///
141/// Deserialization fails if the JSON object contains no array-valued field, or
142/// if that array is empty.
143///
144/// # Example
145///
146/// ```
147/// use ghost_io_api::models::envelope::SingleEnvelope;
148/// use serde::Deserialize;
149/// use serde_json::json;
150///
151/// #[derive(Debug, Deserialize)]
152/// struct Post { id: String, title: String }
153///
154/// // Read by ID
155/// let json = json!({"posts": [{"id": "abc", "title": "Hello"}]});
156/// let env: SingleEnvelope<Post> = serde_json::from_value(json).unwrap();
157/// assert_eq!(env.item.id, "abc");
158/// assert_eq!(env.item.title, "Hello");
159/// ```
160#[derive(Debug, Clone)]
161pub struct SingleEnvelope<T> {
162    /// The single resource item extracted from the array.
163    pub item: T,
164}
165
166impl<'de, T: DeserializeOwned> Deserialize<'de> for SingleEnvelope<T> {
167    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
168        use serde::de::Error;
169
170        let map: serde_json::Map<String, Value> = serde_json::Map::deserialize(deserializer)?;
171
172        // Skip `meta` if present (some endpoints include it even for single
173        // reads), then locate the resource array.
174        let items_val = map
175            .into_iter()
176            .filter(|(k, _)| k != "meta")
177            .find_map(|(_, v)| v.is_array().then_some(v))
178            .ok_or_else(|| Error::custom("no resource array found in single-item envelope"))?;
179
180        let mut items = serde_json::from_value::<Vec<T>>(items_val).map_err(Error::custom)?;
181
182        let item = items
183            .drain(..)
184            .next()
185            .ok_or_else(|| Error::custom("resource array is empty in single-item envelope"))?;
186
187        Ok(Self { item })
188    }
189}
190
191// ── Tests ─────────────────────────────────────────────────────────────────────
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde::Deserialize;
197    use serde_json::json;
198
199    // ── Fixture types ─────────────────────────────────────────────────────────
200
201    #[derive(Debug, Clone, PartialEq, Deserialize)]
202    struct Post {
203        id: String,
204        title: String,
205        #[serde(default)]
206        status: String,
207    }
208
209    #[derive(Debug, Clone, PartialEq, Deserialize)]
210    struct Tag {
211        id: String,
212        name: String,
213        slug: String,
214    }
215
216    #[derive(Debug, Clone, PartialEq, Deserialize)]
217    struct Author {
218        id: String,
219        email: String,
220    }
221
222    fn pagination(page: u32, total: u32) -> serde_json::Value {
223        json!({
224            "pagination": {
225                "page": page, "limit": 15,
226                "pages": 1, "total": total,
227                "next": null, "prev": null
228            }
229        })
230    }
231
232    // ── BrowseEnvelope — happy path ───────────────────────────────────────────
233
234    #[test]
235    fn test_browse_envelope_posts() {
236        let v = json!({
237            "posts": [
238                {"id": "1", "title": "First", "status": "published"},
239                {"id": "2", "title": "Second", "status": "draft"}
240            ],
241            "meta": pagination(1, 2)
242        });
243        let env: BrowseEnvelope<Post> = serde_json::from_value(v).unwrap();
244        assert_eq!(env.items.len(), 2);
245        assert_eq!(env.items[0].id, "1");
246        assert_eq!(env.items[1].title, "Second");
247        assert_eq!(env.meta.pagination.total, 2);
248        assert_eq!(env.meta.pagination.page, 1);
249    }
250
251    #[test]
252    fn test_browse_envelope_tags() {
253        let v = json!({
254            "tags": [{"id": "t1", "name": "Rust", "slug": "rust"}],
255            "meta": pagination(1, 1)
256        });
257        let env: BrowseEnvelope<Tag> = serde_json::from_value(v).unwrap();
258        assert_eq!(env.items.len(), 1);
259        assert_eq!(env.items[0].slug, "rust");
260        assert_eq!(env.meta.pagination.total, 1);
261    }
262
263    #[test]
264    fn test_browse_envelope_authors() {
265        let v = json!({
266            "authors": [{"id": "a1", "email": "jane@example.com"}],
267            "meta": pagination(1, 1)
268        });
269        let env: BrowseEnvelope<Author> = serde_json::from_value(v).unwrap();
270        assert_eq!(env.items[0].email, "jane@example.com");
271    }
272
273    #[test]
274    fn test_browse_envelope_empty_list() {
275        let v = json!({
276            "posts": [],
277            "meta": pagination(1, 0)
278        });
279        let env: BrowseEnvelope<Post> = serde_json::from_value(v).unwrap();
280        assert!(env.items.is_empty());
281        assert_eq!(env.meta.pagination.total, 0);
282    }
283
284    #[test]
285    fn test_browse_envelope_multiple_pages() {
286        let v = json!({
287            "posts": [{"id": "p1", "title": "Page 2 post"}],
288            "meta": {
289                "pagination": {
290                    "page": 2, "limit": 1,
291                    "pages": 3, "total": 3,
292                    "next": 3, "prev": 1
293                }
294            }
295        });
296        let env: BrowseEnvelope<Post> = serde_json::from_value(v).unwrap();
297        assert_eq!(env.meta.pagination.page, 2);
298        assert_eq!(env.meta.pagination.pages, 3);
299        assert_eq!(env.meta.pagination.next, Some(3));
300        assert_eq!(env.meta.pagination.prev, Some(1));
301    }
302
303    #[test]
304    fn test_browse_envelope_extra_fields_ignored() {
305        // Ghost may add extra top-level keys in future — ensure they are ignored
306        let v = json!({
307            "posts": [{"id": "x", "title": "X"}],
308            "meta": pagination(1, 1),
309            "some_future_key": "value"
310        });
311        let env: BrowseEnvelope<Post> = serde_json::from_value(v).unwrap();
312        assert_eq!(env.items.len(), 1);
313    }
314
315    #[test]
316    fn test_browse_envelope_many_items() {
317        let posts: Vec<serde_json::Value> = (1..=20)
318            .map(|i| json!({"id": i.to_string(), "title": format!("Post {i}")}))
319            .collect();
320        let v = json!({"posts": posts, "meta": pagination(1, 20)});
321        let env: BrowseEnvelope<Post> = serde_json::from_value(v).unwrap();
322        assert_eq!(env.items.len(), 20);
323        assert_eq!(env.items[19].title, "Post 20");
324    }
325
326    // ── BrowseEnvelope — error cases ──────────────────────────────────────────
327
328    #[test]
329    fn test_browse_envelope_missing_array_errors() {
330        let v = json!({"meta": pagination(1, 0)});
331        let err = serde_json::from_value::<BrowseEnvelope<Post>>(v).unwrap_err();
332        assert!(err.to_string().contains("no resource array"));
333    }
334
335    #[test]
336    fn test_browse_envelope_not_an_object_errors() {
337        let v = json!([1, 2, 3]);
338        assert!(serde_json::from_value::<BrowseEnvelope<Post>>(v).is_err());
339    }
340
341    #[test]
342    fn test_browse_envelope_null_errors() {
343        assert!(serde_json::from_value::<BrowseEnvelope<Post>>(json!(null)).is_err());
344    }
345
346    #[test]
347    fn test_browse_envelope_bad_item_type_errors() {
348        // `status` should be a string, but we pass a number — deserialization of
349        // Post should fail and propagate through BrowseEnvelope.
350        let v = json!({
351            "posts": [{"id": 99, "title": "Bad", "status": 0}],
352            "meta": pagination(1, 1)
353        });
354        // id is a String field, so 99 as a number should fail
355        assert!(serde_json::from_value::<BrowseEnvelope<Post>>(v).is_err());
356    }
357
358    // ── SingleEnvelope — happy path ───────────────────────────────────────────
359
360    #[test]
361    fn test_single_envelope_posts() {
362        let v = json!({"posts": [{"id": "abc", "title": "Hello", "status": "published"}]});
363        let env: SingleEnvelope<Post> = serde_json::from_value(v).unwrap();
364        assert_eq!(env.item.id, "abc");
365        assert_eq!(env.item.title, "Hello");
366        assert_eq!(env.item.status, "published");
367    }
368
369    #[test]
370    fn test_single_envelope_tags() {
371        let v = json!({"tags": [{"id": "t1", "name": "Rust", "slug": "rust"}]});
372        let env: SingleEnvelope<Tag> = serde_json::from_value(v).unwrap();
373        assert_eq!(env.item.name, "Rust");
374        assert_eq!(env.item.slug, "rust");
375    }
376
377    #[test]
378    fn test_single_envelope_authors() {
379        let v = json!({"authors": [{"id": "a1", "email": "jane@example.com"}]});
380        let env: SingleEnvelope<Author> = serde_json::from_value(v).unwrap();
381        assert_eq!(env.item.email, "jane@example.com");
382    }
383
384    #[test]
385    fn test_single_envelope_ignores_meta_if_present() {
386        // Some Ghost endpoints include meta even on single-item reads
387        let v = json!({
388            "posts": [{"id": "x", "title": "With meta"}],
389            "meta": pagination(1, 1)
390        });
391        let env: SingleEnvelope<Post> = serde_json::from_value(v).unwrap();
392        assert_eq!(env.item.id, "x");
393    }
394
395    #[test]
396    fn test_single_envelope_extra_fields_ignored() {
397        let v = json!({
398            "posts": [{"id": "y", "title": "Extra"}],
399            "some_key": "ignored"
400        });
401        let env: SingleEnvelope<Post> = serde_json::from_value(v).unwrap();
402        assert_eq!(env.item.id, "y");
403    }
404
405    #[test]
406    fn test_single_envelope_picks_first_item_when_multiple() {
407        // Ghost always returns one item, but we verify we pick the first
408        let v = json!({"posts": [
409            {"id": "first", "title": "First"},
410            {"id": "second", "title": "Second"}
411        ]});
412        let env: SingleEnvelope<Post> = serde_json::from_value(v).unwrap();
413        assert_eq!(env.item.id, "first");
414    }
415
416    // ── SingleEnvelope — error cases ──────────────────────────────────────────
417
418    #[test]
419    fn test_single_envelope_empty_array_errors() {
420        let v = json!({"posts": []});
421        let err = serde_json::from_value::<SingleEnvelope<Post>>(v).unwrap_err();
422        assert!(err.to_string().contains("empty"));
423    }
424
425    #[test]
426    fn test_single_envelope_missing_array_errors() {
427        let v = json!({"not_an_array": "hello"});
428        let err = serde_json::from_value::<SingleEnvelope<Post>>(v).unwrap_err();
429        assert!(err.to_string().contains("no resource array"));
430    }
431
432    #[test]
433    fn test_single_envelope_not_an_object_errors() {
434        assert!(serde_json::from_value::<SingleEnvelope<Post>>(json!(42)).is_err());
435    }
436
437    #[test]
438    fn test_single_envelope_null_errors() {
439        assert!(serde_json::from_value::<SingleEnvelope<Post>>(json!(null)).is_err());
440    }
441
442    // ── Clone & Debug ─────────────────────────────────────────────────────────
443
444    #[test]
445    fn test_browse_envelope_clone() {
446        let v = json!({
447            "posts": [{"id": "1", "title": "Clone me"}],
448            "meta": pagination(1, 1)
449        });
450        let env: BrowseEnvelope<Post> = serde_json::from_value(v).unwrap();
451        let cloned = env.clone();
452        assert_eq!(cloned.items[0].id, env.items[0].id);
453        assert_eq!(cloned.meta.pagination.total, env.meta.pagination.total);
454    }
455
456    #[test]
457    fn test_browse_envelope_debug() {
458        let v = json!({
459            "posts": [{"id": "d1", "title": "Debug"}],
460            "meta": pagination(1, 1)
461        });
462        let env: BrowseEnvelope<Post> = serde_json::from_value(v).unwrap();
463        let debug = format!("{env:?}");
464        assert!(debug.contains("BrowseEnvelope"));
465    }
466
467    #[test]
468    fn test_single_envelope_clone() {
469        let v = json!({"posts": [{"id": "c1", "title": "Clone"}]});
470        let env: SingleEnvelope<Post> = serde_json::from_value(v).unwrap();
471        let cloned = env.clone();
472        assert_eq!(cloned.item.id, env.item.id);
473    }
474
475    #[test]
476    fn test_single_envelope_debug() {
477        let v = json!({"posts": [{"id": "d2", "title": "Debug"}]});
478        let env: SingleEnvelope<Post> = serde_json::from_value(v).unwrap();
479        let debug = format!("{env:?}");
480        assert!(debug.contains("SingleEnvelope"));
481    }
482
483    // ── Real Ghost model round-trips ──────────────────────────────────────────
484
485    #[test]
486    fn test_browse_envelope_with_real_post_model() {
487        use crate::models::post::Post;
488        let v = json!({
489            "posts": [{
490                "id": "5ddc9141c35e7700383b2937",
491                "title": "Welcome",
492                "slug": "welcome",
493                "status": "published"
494            }],
495            "meta": pagination(1, 1)
496        });
497        let env: BrowseEnvelope<Post> = serde_json::from_value(v).unwrap();
498        assert_eq!(env.items[0].id, "5ddc9141c35e7700383b2937");
499        assert_eq!(env.items[0].title, "Welcome");
500    }
501
502    #[test]
503    fn test_single_envelope_with_real_post_model() {
504        use crate::models::post::Post;
505        let v = json!({
506            "posts": [{
507                "id": "abc123",
508                "title": "Created Post",
509                "slug": "created-post",
510                "status": "draft"
511            }]
512        });
513        let env: SingleEnvelope<Post> = serde_json::from_value(v).unwrap();
514        assert_eq!(env.item.id, "abc123");
515        assert_eq!(env.item.title, "Created Post");
516    }
517}