Skip to main content

map_obj/
lib.rs

1//! # map-obj — map over the keys and values of a JSON object
2//!
3//! Transform the keys and/or values of a [`serde_json::Value`] object with a closure,
4//! optionally recursing into nested objects and arrays. A faithful Rust port of the
5//! widely-used [`map-obj`](https://www.npmjs.com/package/map-obj) npm package — the building
6//! block behind key transforms like
7//! [`camelcase-keys`](https://crates.io/crates/camelcase-keys).
8//!
9//! ```
10//! use serde_json::json;
11//! use map_obj::{map_obj, MapEntry};
12//!
13//! // Append `_` to every key:
14//! let result = map_obj(&json!({ "a": 1, "b": 2 }), false, |key, value| {
15//!     MapEntry::keep(format!("{key}_"), value.clone())
16//! });
17//! assert_eq!(result, json!({ "a_": 1, "b_": 2 }));
18//! ```
19//!
20//! Recurse into nested objects/arrays by passing `deep = true`:
21//!
22//! ```
23//! use serde_json::json;
24//! use map_obj::{map_obj, MapEntry};
25//!
26//! let result = map_obj(&json!({ "a": { "b": 1 } }), true, |key, value| {
27//!     MapEntry::keep(key.to_uppercase(), value.clone())
28//! });
29//! assert_eq!(result, json!({ "A": { "B": 1 } }));
30//! ```
31
32#![forbid(unsafe_code)]
33#![doc(html_root_url = "https://docs.rs/map-obj/0.1.0")]
34
35use serde_json::{Map, Value};
36
37// Compile-test the README's examples as part of `cargo test`.
38#[cfg(doctest)]
39#[doc = include_str!("../README.md")]
40struct ReadmeDoctests;
41
42/// The result of mapping a single `(key, value)` pair.
43#[derive(Debug, Clone, PartialEq)]
44pub enum MapEntry {
45    /// Drop this entry from the result.
46    Skip,
47    /// Keep the entry with the given key and value. `recurse` controls whether nested
48    /// objects/arrays in the value are mapped (when `deep` is set); it defaults to `true`
49    /// via [`MapEntry::keep`].
50    Keep {
51        /// The (possibly new) key.
52        key: String,
53        /// The (possibly new) value.
54        value: Value,
55        /// Whether to recurse into `value` when `deep` is enabled.
56        recurse: bool,
57    },
58}
59
60impl MapEntry {
61    /// Keep the entry with the given key and value, recursing into it when `deep`.
62    #[must_use]
63    pub fn keep(key: impl Into<String>, value: Value) -> Self {
64        Self::Keep {
65            key: key.into(),
66            value,
67            recurse: true,
68        }
69    }
70
71    /// Keep the entry but do not recurse into its value, even when `deep` is set.
72    #[must_use]
73    pub fn keep_without_recursing(key: impl Into<String>, value: Value) -> Self {
74        Self::Keep {
75            key: key.into(),
76            value,
77            recurse: false,
78        }
79    }
80}
81
82/// A value `map-obj` recurses into (a JSON object or array).
83fn is_container(value: &Value) -> bool {
84    value.is_object() || value.is_array()
85}
86
87/// Map over the keys and values of `object`.
88///
89/// `mapper` is called with each `(key, value)` and returns a [`MapEntry`]. With `deep`, the
90/// mapper is also applied to nested objects (and to objects inside arrays). Keys mapped to
91/// `__proto__` are dropped, matching the reference.
92///
93/// Non-object input is returned unchanged (the JavaScript original instead throws; this is
94/// the lenient Rust equivalent).
95pub fn map_obj<F>(object: &Value, deep: bool, mut mapper: F) -> Value
96where
97    F: FnMut(&str, &Value) -> MapEntry,
98{
99    match object {
100        Value::Object(map) => Value::Object(map_object(map, deep, &mut mapper)),
101        other => other.clone(),
102    }
103}
104
105fn map_object<F>(map: &Map<String, Value>, deep: bool, mapper: &mut F) -> Map<String, Value>
106where
107    F: FnMut(&str, &Value) -> MapEntry,
108{
109    let mut target = Map::new();
110    for (key, value) in map {
111        let MapEntry::Keep {
112            key: new_key,
113            value: new_value,
114            recurse,
115        } = mapper(key, value)
116        else {
117            continue;
118        };
119
120        // Drop `__proto__` keys.
121        if new_key == "__proto__" {
122            continue;
123        }
124
125        let new_value = if deep && recurse && is_container(&new_value) {
126            map_container(&new_value, deep, mapper)
127        } else {
128            new_value
129        };
130
131        target.insert(new_key, new_value);
132    }
133    target
134}
135
136/// Map an array's items (mapping objects within), as `map-obj`'s `mapArray` does.
137fn map_array<F>(array: &[Value], deep: bool, mapper: &mut F) -> Vec<Value>
138where
139    F: FnMut(&str, &Value) -> MapEntry,
140{
141    array
142        .iter()
143        .map(|item| {
144            if is_container(item) {
145                map_container(item, deep, mapper)
146            } else {
147                item.clone()
148            }
149        })
150        .collect()
151}
152
153fn map_container<F>(value: &Value, deep: bool, mapper: &mut F) -> Value
154where
155    F: FnMut(&str, &Value) -> MapEntry,
156{
157    match value {
158        Value::Object(map) => Value::Object(map_object(map, deep, mapper)),
159        Value::Array(array) => Value::Array(map_array(array, deep, mapper)),
160        other => other.clone(),
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use serde_json::json;
168
169    fn append_underscore(value: &Value, deep: bool) -> Value {
170        map_obj(value, deep, |key, value| {
171            MapEntry::keep(format!("{key}_"), value.clone())
172        })
173    }
174
175    #[test]
176    fn shallow_keys() {
177        assert_eq!(
178            append_underscore(&json!({ "a": 1, "b": 2 }), false),
179            json!({ "a_": 1, "b_": 2 })
180        );
181        // Shallow: nested objects untouched.
182        assert_eq!(
183            append_underscore(&json!({ "a": { "b": 1 } }), false),
184            json!({ "a_": { "b": 1 } })
185        );
186    }
187
188    #[test]
189    fn deep_and_arrays() {
190        assert_eq!(
191            append_underscore(&json!({ "a": { "b": 1 } }), true),
192            json!({ "a_": { "b_": 1 } })
193        );
194        assert_eq!(
195            append_underscore(&json!({ "a": [{ "b": 1 }] }), true),
196            json!({ "a_": [{ "b_": 1 }] })
197        );
198    }
199
200    #[test]
201    fn skip() {
202        let result = map_obj(&json!({ "a": 1, "_x": 2 }), false, |key, value| {
203            if key.starts_with('_') {
204                MapEntry::Skip
205            } else {
206                MapEntry::keep(key, value.clone())
207            }
208        });
209        assert_eq!(result, json!({ "a": 1 }));
210    }
211
212    #[test]
213    fn should_not_recurse() {
214        let result = map_obj(
215            &json!({ "a": { "b": 1 }, "stop": { "c": 2 } }),
216            true,
217            |key, value| {
218                if key == "stop" {
219                    MapEntry::keep_without_recursing(key, value.clone())
220                } else {
221                    MapEntry::keep(key, value.clone())
222                }
223            },
224        );
225        assert_eq!(result, json!({ "a": { "b": 1 }, "stop": { "c": 2 } }));
226        // "a" recursed (no rename here, but recursion visited); "stop" not recursed.
227    }
228
229    #[test]
230    fn drops_proto() {
231        let result = map_obj(&json!({ "evil": 1, "ok": 2 }), false, |key, value| {
232            if key == "evil" {
233                MapEntry::keep("__proto__", value.clone())
234            } else {
235                MapEntry::keep(key, value.clone())
236            }
237        });
238        assert_eq!(result, json!({ "ok": 2 }));
239    }
240
241    #[test]
242    fn transform_values() {
243        let result = map_obj(&json!({ "a": 1, "b": 2 }), false, |key, value| {
244            let n = value.as_i64().unwrap_or(0) * 10;
245            MapEntry::keep(key, json!(n))
246        });
247        assert_eq!(result, json!({ "a": 10, "b": 20 }));
248    }
249
250    #[test]
251    fn non_object_passthrough() {
252        assert_eq!(
253            map_obj(&json!(42), true, |k, v| MapEntry::keep(k, v.clone())),
254            json!(42)
255        );
256    }
257}