decamelize-keys 0.1.0

Recursively convert the keys of a JSON object from camelCase to a separated lower-case (e.g. fooBar -> foo_bar). A faithful port of the decamelize-keys npm package.
Documentation
//! # decamelize-keys — convert JSON object keys from `camelCase`
//!
//! Recursively convert the keys of a [`serde_json::Value`] from `camelCase` to a separated
//! lower-case form — `{ "fooBar": 1 }` → `{ "foo_bar": 1 }`. A faithful Rust port of the
//! [`decamelize-keys`](https://www.npmjs.com/package/decamelize-keys) npm package, built on
//! the [`decamelize`](https://crates.io/crates/decamelize) crate. It is the inverse of
//! [`camelcase-keys`](https://crates.io/crates/camelcase-keys).
//!
//! ```
//! use serde_json::json;
//! use decamelize_keys::{decamelize_keys, decamelize_keys_with, Options};
//!
//! assert_eq!(
//!     decamelize_keys(&json!({ "fooBar": 1, "BazQux": 2 })),
//!     json!({ "foo_bar": 1, "baz_qux": 2 })
//! );
//!
//! // Recurse with `deep`, or use a custom separator:
//! assert_eq!(
//!     decamelize_keys_with(&json!({ "fooBar": { "nestedKey": 1 } }), &Options::new().deep(true)),
//!     json!({ "foo_bar": { "nested_key": 1 } })
//! );
//! assert_eq!(
//!     decamelize_keys_with(&json!({ "fooBar": 1 }), &Options::new().separator("-")),
//!     json!({ "foo-bar": 1 })
//! );
//! ```

#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/decamelize-keys/0.1.0")]

use decamelize::decamelize_with;
use serde_json::{Map, Value};

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// Options controlling [`decamelize_keys_with`].
#[derive(Debug, Clone)]
pub struct Options {
    separator: String,
    deep: bool,
    exclude: Vec<String>,
}

impl Default for Options {
    fn default() -> Self {
        Self {
            separator: "_".to_string(),
            deep: false,
            exclude: Vec::new(),
        }
    }
}

impl Options {
    /// Default options: `_` separator, shallow.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the separator inserted between words (defaults to `_`).
    #[must_use]
    pub fn separator(mut self, separator: impl Into<String>) -> Self {
        self.separator = separator.into();
        self
    }

    /// Recurse into nested objects and arrays.
    #[must_use]
    pub fn deep(mut self, value: bool) -> Self {
        self.deep = value;
        self
    }

    /// Keys to leave untouched (compared against the original key).
    #[must_use]
    pub fn exclude<I, S>(mut self, keys: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.exclude = keys.into_iter().map(Into::into).collect();
        self
    }
}

/// Convert the keys of `value` from `camelCase` using the default options (shallow, `_`).
///
/// ```
/// # use serde_json::json;
/// # use decamelize_keys::decamelize_keys;
/// assert_eq!(decamelize_keys(&json!({ "fooBar": 1 })), json!({ "foo_bar": 1 }));
/// ```
#[must_use]
pub fn decamelize_keys(value: &Value) -> Value {
    decamelize_keys_with(value, &Options::new())
}

/// Convert the keys of `value` from `camelCase` with the given [`Options`].
#[must_use]
pub fn decamelize_keys_with(value: &Value, options: &Options) -> Value {
    transform(value, options)
}

/// A value `decamelize-keys` recurses into (an object or array).
fn is_recursable(value: &Value) -> bool {
    value.is_object() || value.is_array()
}

fn transform(value: &Value, options: &Options) -> Value {
    match value {
        Value::Array(array) => Value::Array(
            array
                .iter()
                .map(|item| {
                    if is_recursable(item) {
                        transform(item, options)
                    } else {
                        item.clone()
                    }
                })
                .collect(),
        ),
        Value::Object(map) => {
            let mut result = Map::new();
            for (key, val) in map {
                let new_value = if options.deep && is_recursable(val) {
                    transform(val, options)
                } else {
                    val.clone()
                };
                let new_key = if options.exclude.iter().any(|excluded| excluded == key) {
                    key.clone()
                } else {
                    decamelize_with(key, &options.separator, false)
                };
                result.insert(new_key, new_value);
            }
            Value::Object(result)
        }
        other => other.clone(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn shallow() {
        assert_eq!(
            decamelize_keys(&json!({ "fooBar": 1, "BazQux": 2 })),
            json!({ "foo_bar": 1, "baz_qux": 2 })
        );
        // Default shallow: nested keys untouched.
        assert_eq!(
            decamelize_keys(&json!({ "fooBar": { "nestedKey": 1 } })),
            json!({ "foo_bar": { "nestedKey": 1 } })
        );
    }

    #[test]
    fn deep_and_arrays() {
        assert_eq!(
            decamelize_keys_with(
                &json!({ "fooBar": { "nestedKey": 1 } }),
                &Options::new().deep(true)
            ),
            json!({ "foo_bar": { "nested_key": 1 } })
        );
        assert_eq!(
            decamelize_keys(&json!([{ "aB": 1 }, { "cD": 2 }])),
            json!([{ "a_b": 1 }, { "c_d": 2 }])
        );
        assert_eq!(
            decamelize_keys_with(
                &json!({ "fooBar": [{ "xY": 1 }] }),
                &Options::new().deep(true)
            ),
            json!({ "foo_bar": [{ "x_y": 1 }] })
        );
    }

    #[test]
    fn separator_and_exclude() {
        assert_eq!(
            decamelize_keys_with(&json!({ "fooBar": 1 }), &Options::new().separator("-")),
            json!({ "foo-bar": 1 })
        );
        assert_eq!(
            decamelize_keys_with(
                &json!({ "fooBar": 1, "XMLHttp": 2 }),
                &Options::new().separator(" ")
            ),
            json!({ "foo bar": 1, "xml http": 2 })
        );
        assert_eq!(
            decamelize_keys_with(&json!({ "fooBar": 1 }), &Options::new().exclude(["fooBar"])),
            json!({ "fooBar": 1 })
        );
    }

    #[test]
    fn numeric_keys_and_scalars() {
        assert_eq!(
            decamelize_keys(&json!({ "0": 1, "42": 2, "fooBar": 3 })),
            json!({ "0": 1, "42": 2, "foo_bar": 3 })
        );
        assert_eq!(decamelize_keys(&json!("scalar")), json!("scalar"));
        assert_eq!(decamelize_keys(&json!(42)), json!(42));
    }
}