Skip to main content

decamelize_keys/
lib.rs

1//! # decamelize-keys — convert JSON object keys from `camelCase`
2//!
3//! Recursively convert the keys of a [`serde_json::Value`] from `camelCase` to a separated
4//! lower-case form — `{ "fooBar": 1 }` → `{ "foo_bar": 1 }`. A faithful Rust port of the
5//! [`decamelize-keys`](https://www.npmjs.com/package/decamelize-keys) npm package, built on
6//! the [`decamelize`](https://crates.io/crates/decamelize) crate. It is the inverse of
7//! [`camelcase-keys`](https://crates.io/crates/camelcase-keys).
8//!
9//! ```
10//! use serde_json::json;
11//! use decamelize_keys::{decamelize_keys, decamelize_keys_with, Options};
12//!
13//! assert_eq!(
14//!     decamelize_keys(&json!({ "fooBar": 1, "BazQux": 2 })),
15//!     json!({ "foo_bar": 1, "baz_qux": 2 })
16//! );
17//!
18//! // Recurse with `deep`, or use a custom separator:
19//! assert_eq!(
20//!     decamelize_keys_with(&json!({ "fooBar": { "nestedKey": 1 } }), &Options::new().deep(true)),
21//!     json!({ "foo_bar": { "nested_key": 1 } })
22//! );
23//! assert_eq!(
24//!     decamelize_keys_with(&json!({ "fooBar": 1 }), &Options::new().separator("-")),
25//!     json!({ "foo-bar": 1 })
26//! );
27//! ```
28
29#![forbid(unsafe_code)]
30#![doc(html_root_url = "https://docs.rs/decamelize-keys/0.1.0")]
31
32use decamelize::decamelize_with;
33use serde_json::{Map, Value};
34
35// Compile-test the README's examples as part of `cargo test`.
36#[cfg(doctest)]
37#[doc = include_str!("../README.md")]
38struct ReadmeDoctests;
39
40/// Options controlling [`decamelize_keys_with`].
41#[derive(Debug, Clone)]
42pub struct Options {
43    separator: String,
44    deep: bool,
45    exclude: Vec<String>,
46}
47
48impl Default for Options {
49    fn default() -> Self {
50        Self {
51            separator: "_".to_string(),
52            deep: false,
53            exclude: Vec::new(),
54        }
55    }
56}
57
58impl Options {
59    /// Default options: `_` separator, shallow.
60    #[must_use]
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Set the separator inserted between words (defaults to `_`).
66    #[must_use]
67    pub fn separator(mut self, separator: impl Into<String>) -> Self {
68        self.separator = separator.into();
69        self
70    }
71
72    /// Recurse into nested objects and arrays.
73    #[must_use]
74    pub fn deep(mut self, value: bool) -> Self {
75        self.deep = value;
76        self
77    }
78
79    /// Keys to leave untouched (compared against the original key).
80    #[must_use]
81    pub fn exclude<I, S>(mut self, keys: I) -> Self
82    where
83        I: IntoIterator<Item = S>,
84        S: Into<String>,
85    {
86        self.exclude = keys.into_iter().map(Into::into).collect();
87        self
88    }
89}
90
91/// Convert the keys of `value` from `camelCase` using the default options (shallow, `_`).
92///
93/// ```
94/// # use serde_json::json;
95/// # use decamelize_keys::decamelize_keys;
96/// assert_eq!(decamelize_keys(&json!({ "fooBar": 1 })), json!({ "foo_bar": 1 }));
97/// ```
98#[must_use]
99pub fn decamelize_keys(value: &Value) -> Value {
100    decamelize_keys_with(value, &Options::new())
101}
102
103/// Convert the keys of `value` from `camelCase` with the given [`Options`].
104#[must_use]
105pub fn decamelize_keys_with(value: &Value, options: &Options) -> Value {
106    transform(value, options)
107}
108
109/// A value `decamelize-keys` recurses into (an object or array).
110fn is_recursable(value: &Value) -> bool {
111    value.is_object() || value.is_array()
112}
113
114fn transform(value: &Value, options: &Options) -> Value {
115    match value {
116        Value::Array(array) => Value::Array(
117            array
118                .iter()
119                .map(|item| {
120                    if is_recursable(item) {
121                        transform(item, options)
122                    } else {
123                        item.clone()
124                    }
125                })
126                .collect(),
127        ),
128        Value::Object(map) => {
129            let mut result = Map::new();
130            for (key, val) in map {
131                let new_value = if options.deep && is_recursable(val) {
132                    transform(val, options)
133                } else {
134                    val.clone()
135                };
136                let new_key = if options.exclude.iter().any(|excluded| excluded == key) {
137                    key.clone()
138                } else {
139                    decamelize_with(key, &options.separator, false)
140                };
141                result.insert(new_key, new_value);
142            }
143            Value::Object(result)
144        }
145        other => other.clone(),
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use serde_json::json;
153
154    #[test]
155    fn shallow() {
156        assert_eq!(
157            decamelize_keys(&json!({ "fooBar": 1, "BazQux": 2 })),
158            json!({ "foo_bar": 1, "baz_qux": 2 })
159        );
160        // Default shallow: nested keys untouched.
161        assert_eq!(
162            decamelize_keys(&json!({ "fooBar": { "nestedKey": 1 } })),
163            json!({ "foo_bar": { "nestedKey": 1 } })
164        );
165    }
166
167    #[test]
168    fn deep_and_arrays() {
169        assert_eq!(
170            decamelize_keys_with(
171                &json!({ "fooBar": { "nestedKey": 1 } }),
172                &Options::new().deep(true)
173            ),
174            json!({ "foo_bar": { "nested_key": 1 } })
175        );
176        assert_eq!(
177            decamelize_keys(&json!([{ "aB": 1 }, { "cD": 2 }])),
178            json!([{ "a_b": 1 }, { "c_d": 2 }])
179        );
180        assert_eq!(
181            decamelize_keys_with(
182                &json!({ "fooBar": [{ "xY": 1 }] }),
183                &Options::new().deep(true)
184            ),
185            json!({ "foo_bar": [{ "x_y": 1 }] })
186        );
187    }
188
189    #[test]
190    fn separator_and_exclude() {
191        assert_eq!(
192            decamelize_keys_with(&json!({ "fooBar": 1 }), &Options::new().separator("-")),
193            json!({ "foo-bar": 1 })
194        );
195        assert_eq!(
196            decamelize_keys_with(
197                &json!({ "fooBar": 1, "XMLHttp": 2 }),
198                &Options::new().separator(" ")
199            ),
200            json!({ "foo bar": 1, "xml http": 2 })
201        );
202        assert_eq!(
203            decamelize_keys_with(&json!({ "fooBar": 1 }), &Options::new().exclude(["fooBar"])),
204            json!({ "fooBar": 1 })
205        );
206    }
207
208    #[test]
209    fn numeric_keys_and_scalars() {
210        assert_eq!(
211            decamelize_keys(&json!({ "0": 1, "42": 2, "fooBar": 3 })),
212            json!({ "0": 1, "42": 2, "foo_bar": 3 })
213        );
214        assert_eq!(decamelize_keys(&json!("scalar")), json!("scalar"));
215        assert_eq!(decamelize_keys(&json!(42)), json!(42));
216    }
217}