#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/map-obj/0.1.0")]
use serde_json::{Map, Value};
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[derive(Debug, Clone, PartialEq)]
pub enum MapEntry {
Skip,
Keep {
key: String,
value: Value,
recurse: bool,
},
}
impl MapEntry {
#[must_use]
pub fn keep(key: impl Into<String>, value: Value) -> Self {
Self::Keep {
key: key.into(),
value,
recurse: true,
}
}
#[must_use]
pub fn keep_without_recursing(key: impl Into<String>, value: Value) -> Self {
Self::Keep {
key: key.into(),
value,
recurse: false,
}
}
}
fn is_container(value: &Value) -> bool {
value.is_object() || value.is_array()
}
pub fn map_obj<F>(object: &Value, deep: bool, mut mapper: F) -> Value
where
F: FnMut(&str, &Value) -> MapEntry,
{
match object {
Value::Object(map) => Value::Object(map_object(map, deep, &mut mapper)),
other => other.clone(),
}
}
fn map_object<F>(map: &Map<String, Value>, deep: bool, mapper: &mut F) -> Map<String, Value>
where
F: FnMut(&str, &Value) -> MapEntry,
{
let mut target = Map::new();
for (key, value) in map {
let MapEntry::Keep {
key: new_key,
value: new_value,
recurse,
} = mapper(key, value)
else {
continue;
};
if new_key == "__proto__" {
continue;
}
let new_value = if deep && recurse && is_container(&new_value) {
map_container(&new_value, deep, mapper)
} else {
new_value
};
target.insert(new_key, new_value);
}
target
}
fn map_array<F>(array: &[Value], deep: bool, mapper: &mut F) -> Vec<Value>
where
F: FnMut(&str, &Value) -> MapEntry,
{
array
.iter()
.map(|item| {
if is_container(item) {
map_container(item, deep, mapper)
} else {
item.clone()
}
})
.collect()
}
fn map_container<F>(value: &Value, deep: bool, mapper: &mut F) -> Value
where
F: FnMut(&str, &Value) -> MapEntry,
{
match value {
Value::Object(map) => Value::Object(map_object(map, deep, mapper)),
Value::Array(array) => Value::Array(map_array(array, deep, mapper)),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn append_underscore(value: &Value, deep: bool) -> Value {
map_obj(value, deep, |key, value| {
MapEntry::keep(format!("{key}_"), value.clone())
})
}
#[test]
fn shallow_keys() {
assert_eq!(
append_underscore(&json!({ "a": 1, "b": 2 }), false),
json!({ "a_": 1, "b_": 2 })
);
assert_eq!(
append_underscore(&json!({ "a": { "b": 1 } }), false),
json!({ "a_": { "b": 1 } })
);
}
#[test]
fn deep_and_arrays() {
assert_eq!(
append_underscore(&json!({ "a": { "b": 1 } }), true),
json!({ "a_": { "b_": 1 } })
);
assert_eq!(
append_underscore(&json!({ "a": [{ "b": 1 }] }), true),
json!({ "a_": [{ "b_": 1 }] })
);
}
#[test]
fn skip() {
let result = map_obj(&json!({ "a": 1, "_x": 2 }), false, |key, value| {
if key.starts_with('_') {
MapEntry::Skip
} else {
MapEntry::keep(key, value.clone())
}
});
assert_eq!(result, json!({ "a": 1 }));
}
#[test]
fn should_not_recurse() {
let result = map_obj(
&json!({ "a": { "b": 1 }, "stop": { "c": 2 } }),
true,
|key, value| {
if key == "stop" {
MapEntry::keep_without_recursing(key, value.clone())
} else {
MapEntry::keep(key, value.clone())
}
},
);
assert_eq!(result, json!({ "a": { "b": 1 }, "stop": { "c": 2 } }));
}
#[test]
fn drops_proto() {
let result = map_obj(&json!({ "evil": 1, "ok": 2 }), false, |key, value| {
if key == "evil" {
MapEntry::keep("__proto__", value.clone())
} else {
MapEntry::keep(key, value.clone())
}
});
assert_eq!(result, json!({ "ok": 2 }));
}
#[test]
fn transform_values() {
let result = map_obj(&json!({ "a": 1, "b": 2 }), false, |key, value| {
let n = value.as_i64().unwrap_or(0) * 10;
MapEntry::keep(key, json!(n))
});
assert_eq!(result, json!({ "a": 10, "b": 20 }));
}
#[test]
fn non_object_passthrough() {
assert_eq!(
map_obj(&json!(42), true, |k, v| MapEntry::keep(k, v.clone())),
json!(42)
);
}
}