Skip to main content

rustango/
jsonapi.rs

1//! [JSON:API](https://jsonapi.org) v1.1 response shape adapter.
2//!
3//! Converts a flat `serde_json::Value` (typical output of
4//! [`crate::serializer`]) into the JSON:API envelope:
5//!
6//! ```json
7//! {
8//!   "data": {
9//!     "type": "posts",
10//!     "id":   "42",
11//!     "attributes": { "title": "...", "body": "..." }
12//!   }
13//! }
14//! ```
15//!
16//! Or for collections:
17//!
18//! ```json
19//! {
20//!   "data": [
21//!     { "type": "posts", "id": "1", "attributes": {...} },
22//!     { "type": "posts", "id": "2", "attributes": {...} }
23//!   ],
24//!   "meta": { "count": 2 }
25//! }
26//! ```
27//!
28//! Most apps wire it as a thin handler-side helper:
29//!
30//! ```ignore
31//! use rustango::jsonapi::{to_resource, to_collection};
32//!
33//! async fn show_post(...) -> Json<Value> {
34//!     let s = PostSerializer::from_model(&post);
35//!     Json(to_resource("posts", &s.id.to_string(), s.to_value()))
36//! }
37//!
38//! async fn list_posts(...) -> Json<Value> {
39//!     let posts: Vec<PostSerializer> = PostSerializer::many(&rows);
40//!     let docs: Vec<_> = posts.iter()
41//!         .map(|p| (p.id.to_string(), p.to_value()))
42//!         .collect();
43//!     Json(to_collection("posts", &docs))
44//! }
45//! ```
46//!
47//! ## What this does NOT cover (yet)
48//!
49//! - `relationships` — the spec lets you express FKs/M2Ms inline;
50//!   for now, embed via `attributes` or use `included` (helpers
51//!   below).
52//! - Sparse fieldsets, sorting, filtering — those live in your
53//!   ViewSet / handler, not this adapter.
54//! - Errors envelope — use [`crate::problem_details`] for RFC 7807
55//!   (clean, widely-supported alternative).
56
57use serde_json::{json, Map, Value};
58
59const ATTRIBUTES_RESERVED: &[&str] = &["id", "type"];
60
61/// Wrap a single resource into the JSON:API top-level `{"data": …}`.
62/// `id` is always rendered as a string per the spec.
63#[must_use]
64pub fn to_resource(resource_type: &str, id: &str, attributes: Value) -> Value {
65    json!({
66        "data": resource_object(resource_type, id, attributes)
67    })
68}
69
70/// Wrap a collection of resources. `docs` is `[(id, attributes), ...]`.
71/// Adds `meta.count` for free so clients can size pagers.
72#[must_use]
73pub fn to_collection(resource_type: &str, docs: &[(String, Value)]) -> Value {
74    let arr: Vec<Value> = docs
75        .iter()
76        .map(|(id, attrs)| resource_object(resource_type, id, attrs.clone()))
77        .collect();
78    let count = arr.len();
79    json!({
80        "data": arr,
81        "meta": { "count": count }
82    })
83}
84
85/// Produce the inner `{"type", "id", "attributes"}` object — useful
86/// when you're hand-building a response with `included`/`meta`/etc.
87#[must_use]
88pub fn resource_object(resource_type: &str, id: &str, attributes: Value) -> Value {
89    let attrs = strip_reserved(attributes);
90    json!({
91        "type": resource_type,
92        "id":   id,
93        "attributes": attrs
94    })
95}
96
97/// Add `included` to an existing resource doc. The new doc is merged
98/// into `doc["included"]` (creating it if absent). Per the spec,
99/// `included` is an array of full resource objects — pass them
100/// already-shaped via [`resource_object`].
101#[must_use]
102pub fn with_included(mut doc: Value, included: Vec<Value>) -> Value {
103    let Some(obj) = doc.as_object_mut() else {
104        return doc;
105    };
106    let entry = obj.entry("included").or_insert_with(|| Value::Array(Vec::new()));
107    if let Value::Array(existing) = entry {
108        existing.extend(included);
109    }
110    doc
111}
112
113/// Add an arbitrary `meta` field merged into the existing meta object.
114/// Useful for pagination cursors, totals, ETags, etc.
115#[must_use]
116pub fn with_meta(mut doc: Value, meta: Value) -> Value {
117    let Some(obj) = doc.as_object_mut() else {
118        return doc;
119    };
120    if let Some(existing) = obj.get_mut("meta") {
121        if let (Some(existing_obj), Some(new_obj)) = (existing.as_object_mut(), meta.as_object()) {
122            for (k, v) in new_obj {
123                existing_obj.insert(k.clone(), v.clone());
124            }
125            return doc;
126        }
127    }
128    obj.insert("meta".to_owned(), meta);
129    doc
130}
131
132/// Strip `id` and `type` keys from a flat attributes object — the
133/// spec says these MUST live at the top level of the resource
134/// object, not inside `attributes`. Anything else passes through.
135fn strip_reserved(value: Value) -> Value {
136    let Value::Object(map) = value else {
137        return value;
138    };
139    let mut out = Map::with_capacity(map.len());
140    for (k, v) in map {
141        if !ATTRIBUTES_RESERVED.contains(&k.as_str()) {
142            out.insert(k, v);
143        }
144    }
145    Value::Object(out)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use serde_json::json;
152
153    #[test]
154    fn to_resource_wraps_attributes_in_data_envelope() {
155        let attrs = json!({"id": 42, "title": "Hello", "body": "world"});
156        let doc = to_resource("posts", "42", attrs);
157        assert_eq!(doc["data"]["type"], "posts");
158        assert_eq!(doc["data"]["id"], "42");
159        assert_eq!(doc["data"]["attributes"]["title"], "Hello");
160        assert_eq!(doc["data"]["attributes"]["body"], "world");
161        // `id` is stripped from attributes per spec.
162        assert!(doc["data"]["attributes"].get("id").is_none());
163    }
164
165    #[test]
166    fn to_resource_strips_type_from_attributes() {
167        let attrs = json!({"type": "should-be-stripped", "title": "x"});
168        let doc = to_resource("posts", "1", attrs);
169        assert!(doc["data"]["attributes"].get("type").is_none());
170        assert_eq!(doc["data"]["type"], "posts");
171    }
172
173    #[test]
174    fn id_is_always_a_string_in_the_envelope() {
175        let doc = to_resource("posts", "42", json!({"title": "x"}));
176        assert!(doc["data"]["id"].is_string());
177        assert_eq!(doc["data"]["id"], "42");
178    }
179
180    #[test]
181    fn to_collection_renders_array_with_count_meta() {
182        let docs = vec![
183            ("1".to_owned(), json!({"title": "A"})),
184            ("2".to_owned(), json!({"title": "B"})),
185        ];
186        let doc = to_collection("posts", &docs);
187        assert!(doc["data"].is_array());
188        assert_eq!(doc["data"].as_array().unwrap().len(), 2);
189        assert_eq!(doc["data"][0]["type"], "posts");
190        assert_eq!(doc["data"][0]["id"], "1");
191        assert_eq!(doc["data"][1]["attributes"]["title"], "B");
192        assert_eq!(doc["meta"]["count"], 2);
193    }
194
195    #[test]
196    fn empty_collection_renders_empty_array_and_zero_count() {
197        let doc = to_collection("posts", &[]);
198        assert_eq!(doc["data"].as_array().unwrap().len(), 0);
199        assert_eq!(doc["meta"]["count"], 0);
200    }
201
202    #[test]
203    fn resource_object_is_inner_shape_only() {
204        let inner = resource_object("posts", "1", json!({"title": "X"}));
205        assert_eq!(inner["type"], "posts");
206        assert_eq!(inner["id"], "1");
207        assert_eq!(inner["attributes"]["title"], "X");
208        // No "data" wrapper — the inner shape is for hand-building.
209        assert!(inner.get("data").is_none());
210    }
211
212    #[test]
213    fn with_included_appends_to_array_creating_it_when_absent() {
214        let doc = to_resource("posts", "1", json!({}));
215        let included = vec![
216            resource_object("authors", "7", json!({"name": "Alice"})),
217        ];
218        let with = with_included(doc, included);
219        assert_eq!(with["included"].as_array().unwrap().len(), 1);
220        assert_eq!(with["included"][0]["type"], "authors");
221        assert_eq!(with["included"][0]["id"], "7");
222    }
223
224    #[test]
225    fn with_included_appends_to_existing_array() {
226        let doc = json!({"data": {}, "included": [{"type":"a","id":"1","attributes":{}}]});
227        let with = with_included(doc, vec![json!({"type":"b","id":"2","attributes":{}})]);
228        assert_eq!(with["included"].as_array().unwrap().len(), 2);
229    }
230
231    #[test]
232    fn with_meta_merges_into_existing_meta_object() {
233        let doc = to_collection("posts", &[("1".into(), json!({}))]);
234        // collection already has meta.count = 1
235        let with = with_meta(doc, json!({"page": 1, "page_size": 20}));
236        assert_eq!(with["meta"]["count"], 1);
237        assert_eq!(with["meta"]["page"], 1);
238        assert_eq!(with["meta"]["page_size"], 20);
239    }
240
241    #[test]
242    fn with_meta_creates_meta_when_absent() {
243        let doc = to_resource("posts", "1", json!({"title":"x"}));
244        let with = with_meta(doc, json!({"updated_at": "2024-01-01"}));
245        assert_eq!(with["meta"]["updated_at"], "2024-01-01");
246    }
247
248    #[test]
249    fn non_object_attributes_pass_through_unchanged() {
250        // String / number / null aren't object-shaped, so the
251        // strip-reserved pass shouldn't touch them. (Spec actually
252        // requires attributes to be an object, but we don't enforce —
253        // the JSON:API client will reject if the shape is wrong.)
254        let doc = to_resource("counts", "1", json!(42));
255        assert_eq!(doc["data"]["attributes"], 42);
256    }
257}