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.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#[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
132fn 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 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 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 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}