1use serde_json::{json, Map, Value};
58
59const ATTRIBUTES_RESERVED: &[&str] = &["id", "type"];
60
61#[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#[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#[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#[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#[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
134fn 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 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 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 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 let doc = to_resource("counts", "1", json!(42));
255 assert_eq!(doc["data"]["attributes"], 42);
256 }
257}