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
107        .entry("included")
108        .or_insert_with(|| Value::Array(Vec::new()));
109    if let Value::Array(existing) = entry {
110        existing.extend(included);
111    }
112    doc
113}
114
115/// Add an arbitrary `meta` field merged into the existing meta object.
116/// Useful for pagination cursors, totals, ETags, etc.
117#[must_use]
118pub fn with_meta(mut doc: Value, meta: Value) -> Value {
119    let Some(obj) = doc.as_object_mut() else {
120        return doc;
121    };
122    if let Some(existing) = obj.get_mut("meta") {
123        if let (Some(existing_obj), Some(new_obj)) = (existing.as_object_mut(), meta.as_object()) {
124            for (k, v) in new_obj {
125                existing_obj.insert(k.clone(), v.clone());
126            }
127            return doc;
128        }
129    }
130    obj.insert("meta".to_owned(), meta);
131    doc
132}
133
134/// Strip `id` and `type` keys from a flat attributes object — the
135/// spec says these MUST live at the top level of the resource
136/// object, not inside `attributes`. Anything else passes through.
137fn strip_reserved(value: Value) -> Value {
138    let Value::Object(map) = value else {
139        return value;
140    };
141    let mut out = Map::with_capacity(map.len());
142    for (k, v) in map {
143        if !ATTRIBUTES_RESERVED.contains(&k.as_str()) {
144            out.insert(k, v);
145        }
146    }
147    Value::Object(out)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use serde_json::json;
154
155    #[test]
156    fn to_resource_wraps_attributes_in_data_envelope() {
157        let attrs = json!({"id": 42, "title": "Hello", "body": "world"});
158        let doc = to_resource("posts", "42", attrs);
159        assert_eq!(doc["data"]["type"], "posts");
160        assert_eq!(doc["data"]["id"], "42");
161        assert_eq!(doc["data"]["attributes"]["title"], "Hello");
162        assert_eq!(doc["data"]["attributes"]["body"], "world");
163        // `id` is stripped from attributes per spec.
164        assert!(doc["data"]["attributes"].get("id").is_none());
165    }
166
167    #[test]
168    fn to_resource_strips_type_from_attributes() {
169        let attrs = json!({"type": "should-be-stripped", "title": "x"});
170        let doc = to_resource("posts", "1", attrs);
171        assert!(doc["data"]["attributes"].get("type").is_none());
172        assert_eq!(doc["data"]["type"], "posts");
173    }
174
175    #[test]
176    fn id_is_always_a_string_in_the_envelope() {
177        let doc = to_resource("posts", "42", json!({"title": "x"}));
178        assert!(doc["data"]["id"].is_string());
179        assert_eq!(doc["data"]["id"], "42");
180    }
181
182    #[test]
183    fn to_collection_renders_array_with_count_meta() {
184        let docs = vec![
185            ("1".to_owned(), json!({"title": "A"})),
186            ("2".to_owned(), json!({"title": "B"})),
187        ];
188        let doc = to_collection("posts", &docs);
189        assert!(doc["data"].is_array());
190        assert_eq!(doc["data"].as_array().unwrap().len(), 2);
191        assert_eq!(doc["data"][0]["type"], "posts");
192        assert_eq!(doc["data"][0]["id"], "1");
193        assert_eq!(doc["data"][1]["attributes"]["title"], "B");
194        assert_eq!(doc["meta"]["count"], 2);
195    }
196
197    #[test]
198    fn empty_collection_renders_empty_array_and_zero_count() {
199        let doc = to_collection("posts", &[]);
200        assert_eq!(doc["data"].as_array().unwrap().len(), 0);
201        assert_eq!(doc["meta"]["count"], 0);
202    }
203
204    #[test]
205    fn resource_object_is_inner_shape_only() {
206        let inner = resource_object("posts", "1", json!({"title": "X"}));
207        assert_eq!(inner["type"], "posts");
208        assert_eq!(inner["id"], "1");
209        assert_eq!(inner["attributes"]["title"], "X");
210        // No "data" wrapper — the inner shape is for hand-building.
211        assert!(inner.get("data").is_none());
212    }
213
214    #[test]
215    fn with_included_appends_to_array_creating_it_when_absent() {
216        let doc = to_resource("posts", "1", json!({}));
217        let included = vec![resource_object("authors", "7", json!({"name": "Alice"}))];
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}