use serde_json::{json, Map, Value};
const ATTRIBUTES_RESERVED: &[&str] = &["id", "type"];
#[must_use]
pub fn to_resource(resource_type: &str, id: &str, attributes: Value) -> Value {
json!({
"data": resource_object(resource_type, id, attributes)
})
}
#[must_use]
pub fn to_collection(resource_type: &str, docs: &[(String, Value)]) -> Value {
let arr: Vec<Value> = docs
.iter()
.map(|(id, attrs)| resource_object(resource_type, id, attrs.clone()))
.collect();
let count = arr.len();
json!({
"data": arr,
"meta": { "count": count }
})
}
#[must_use]
pub fn resource_object(resource_type: &str, id: &str, attributes: Value) -> Value {
let attrs = strip_reserved(attributes);
json!({
"type": resource_type,
"id": id,
"attributes": attrs
})
}
#[must_use]
pub fn with_included(mut doc: Value, included: Vec<Value>) -> Value {
let Some(obj) = doc.as_object_mut() else {
return doc;
};
let entry = obj
.entry("included")
.or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(existing) = entry {
existing.extend(included);
}
doc
}
#[must_use]
pub fn with_meta(mut doc: Value, meta: Value) -> Value {
let Some(obj) = doc.as_object_mut() else {
return doc;
};
if let Some(existing) = obj.get_mut("meta") {
if let (Some(existing_obj), Some(new_obj)) = (existing.as_object_mut(), meta.as_object()) {
for (k, v) in new_obj {
existing_obj.insert(k.clone(), v.clone());
}
return doc;
}
}
obj.insert("meta".to_owned(), meta);
doc
}
fn strip_reserved(value: Value) -> Value {
let Value::Object(map) = value else {
return value;
};
let mut out = Map::with_capacity(map.len());
for (k, v) in map {
if !ATTRIBUTES_RESERVED.contains(&k.as_str()) {
out.insert(k, v);
}
}
Value::Object(out)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn to_resource_wraps_attributes_in_data_envelope() {
let attrs = json!({"id": 42, "title": "Hello", "body": "world"});
let doc = to_resource("posts", "42", attrs);
assert_eq!(doc["data"]["type"], "posts");
assert_eq!(doc["data"]["id"], "42");
assert_eq!(doc["data"]["attributes"]["title"], "Hello");
assert_eq!(doc["data"]["attributes"]["body"], "world");
assert!(doc["data"]["attributes"].get("id").is_none());
}
#[test]
fn to_resource_strips_type_from_attributes() {
let attrs = json!({"type": "should-be-stripped", "title": "x"});
let doc = to_resource("posts", "1", attrs);
assert!(doc["data"]["attributes"].get("type").is_none());
assert_eq!(doc["data"]["type"], "posts");
}
#[test]
fn id_is_always_a_string_in_the_envelope() {
let doc = to_resource("posts", "42", json!({"title": "x"}));
assert!(doc["data"]["id"].is_string());
assert_eq!(doc["data"]["id"], "42");
}
#[test]
fn to_collection_renders_array_with_count_meta() {
let docs = vec![
("1".to_owned(), json!({"title": "A"})),
("2".to_owned(), json!({"title": "B"})),
];
let doc = to_collection("posts", &docs);
assert!(doc["data"].is_array());
assert_eq!(doc["data"].as_array().unwrap().len(), 2);
assert_eq!(doc["data"][0]["type"], "posts");
assert_eq!(doc["data"][0]["id"], "1");
assert_eq!(doc["data"][1]["attributes"]["title"], "B");
assert_eq!(doc["meta"]["count"], 2);
}
#[test]
fn empty_collection_renders_empty_array_and_zero_count() {
let doc = to_collection("posts", &[]);
assert_eq!(doc["data"].as_array().unwrap().len(), 0);
assert_eq!(doc["meta"]["count"], 0);
}
#[test]
fn resource_object_is_inner_shape_only() {
let inner = resource_object("posts", "1", json!({"title": "X"}));
assert_eq!(inner["type"], "posts");
assert_eq!(inner["id"], "1");
assert_eq!(inner["attributes"]["title"], "X");
assert!(inner.get("data").is_none());
}
#[test]
fn with_included_appends_to_array_creating_it_when_absent() {
let doc = to_resource("posts", "1", json!({}));
let included = vec![resource_object("authors", "7", json!({"name": "Alice"}))];
let with = with_included(doc, included);
assert_eq!(with["included"].as_array().unwrap().len(), 1);
assert_eq!(with["included"][0]["type"], "authors");
assert_eq!(with["included"][0]["id"], "7");
}
#[test]
fn with_included_appends_to_existing_array() {
let doc = json!({"data": {}, "included": [{"type":"a","id":"1","attributes":{}}]});
let with = with_included(doc, vec![json!({"type":"b","id":"2","attributes":{}})]);
assert_eq!(with["included"].as_array().unwrap().len(), 2);
}
#[test]
fn with_meta_merges_into_existing_meta_object() {
let doc = to_collection("posts", &[("1".into(), json!({}))]);
let with = with_meta(doc, json!({"page": 1, "page_size": 20}));
assert_eq!(with["meta"]["count"], 1);
assert_eq!(with["meta"]["page"], 1);
assert_eq!(with["meta"]["page_size"], 20);
}
#[test]
fn with_meta_creates_meta_when_absent() {
let doc = to_resource("posts", "1", json!({"title":"x"}));
let with = with_meta(doc, json!({"updated_at": "2024-01-01"}));
assert_eq!(with["meta"]["updated_at"], "2024-01-01");
}
#[test]
fn non_object_attributes_pass_through_unchanged() {
let doc = to_resource("counts", "1", json!(42));
assert_eq!(doc["data"]["attributes"], 42);
}
}