rustrails-support 0.1.1

Core utilities (ActiveSupport equivalent)
Documentation
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;

/// Errors returned while decoding JSON.
#[derive(Debug, thiserror::Error)]
pub enum JsonExtError {
    /// The JSON payload could not be parsed or deserialized.
    #[error("json parse error: {0}")]
    Parse(#[from] serde_json::Error),
}

/// Converts values into `serde_json::Value`.
pub trait ToJson {
    /// Converts `self` into JSON.
    fn to_json(&self) -> Value;
}

impl<T> ToJson for T
where
    T: Serialize,
{
    fn to_json(&self) -> Value {
        serde_json::to_value(self).unwrap_or(Value::Null)
    }
}

/// Deserializes a JSON string into `T`.
pub fn from_json<T>(json: &str) -> Result<T, JsonExtError>
where
    T: DeserializeOwned,
{
    Ok(serde_json::from_str(json)?)
}

/// Renders a JSON value with pretty indentation.
#[must_use]
pub fn json_pretty(value: &Value) -> String {
    serde_json::to_string_pretty(value).unwrap_or_else(|_| String::from("null"))
}

/// Deep-merges two JSON values, preferring values from `b`.
#[must_use]
pub fn json_merge(a: &Value, b: &Value) -> Value {
    match (a, b) {
        (Value::Object(a), Value::Object(b)) => {
            let mut merged = a.clone();
            for (key, value) in b {
                let next = merged
                    .get(key)
                    .map(|existing| json_merge(existing, value))
                    .unwrap_or_else(|| value.clone());
                merged.insert(key.clone(), next);
            }
            Value::Object(merged)
        }
        (_, b) => b.clone(),
    }
}

#[cfg(test)]
mod tests {
    use super::{ToJson, from_json, json_merge, json_pretty};
    use serde::{Deserialize, Serialize};
    use serde_json::json;

    #[derive(Debug, Deserialize, PartialEq, Eq, Serialize)]
    struct User {
        id: u32,
        name: String,
    }

    #[test]
    fn to_json_converts_serializable_values() {
        let user = User {
            id: 1,
            name: String::from("Rails"),
        };

        assert_eq!(user.to_json(), json!({"id": 1, "name": "Rails"}));
    }

    #[test]
    fn from_json_decodes_typed_values() {
        let user: User =
            from_json("{\"id\":1,\"name\":\"Rails\"}").expect("json should deserialize");

        assert_eq!(
            user,
            User {
                id: 1,
                name: String::from("Rails")
            }
        );
    }

    #[test]
    fn from_json_returns_a_typed_error_for_invalid_json() {
        let error = from_json::<User>("{").expect_err("invalid json should fail");
        assert!(error.to_string().starts_with("json parse error:"));
    }

    #[test]
    fn json_pretty_formats_values_with_indentation() {
        let pretty = json_pretty(&json!({"id": 1, "name": "Rails"}));
        assert!(pretty.contains('\n'));
        assert!(pretty.contains("  \"id\": 1"));
    }

    #[test]
    fn json_merge_prefers_values_from_the_right_hand_side() {
        let merged = json_merge(&json!({"name": "Rails"}), &json!({"name": "RustRails"}));

        assert_eq!(merged, json!({"name": "RustRails"}));
    }

    #[test]
    fn json_merge_recursively_merges_objects() {
        let merged = json_merge(
            &json!({"db": {"host": "localhost", "pool": 5}}),
            &json!({"db": {"pool": 10, "port": 5432}}),
        );

        assert_eq!(
            merged,
            json!({"db": {"host": "localhost", "pool": 10, "port": 5432}})
        );
    }

    #[test]
    fn json_merge_replaces_non_object_values() {
        let merged = json_merge(&json!("left"), &json!([1, 2, 3]));

        assert_eq!(merged, json!([1, 2, 3]));
    }
}