Skip to main content

architect_sdk/
case.rs

1//! Case conversion for API: request keys camelCase -> snake_case (for DB), response keys snake_case -> camelCase (for client).
2
3use serde_json::{Map, Value};
4use std::collections::HashMap;
5
6/// Convert a single identifier from snake_case to camelCase.
7/// e.g. "user_id" -> "userId", "created_at" -> "createdAt"
8pub fn to_camel_case(s: &str) -> String {
9    let mut out = String::with_capacity(s.len());
10    let mut capitalize_next = false;
11    for c in s.chars() {
12        if c == '_' {
13            capitalize_next = true;
14        } else if capitalize_next {
15            out.extend(c.to_uppercase());
16            capitalize_next = false;
17        } else {
18            out.push(c);
19        }
20    }
21    out
22}
23
24/// Convert a single identifier from camelCase to snake_case.
25/// e.g. "userId" -> "user_id", "createdAt" -> "created_at"
26pub fn to_snake_case(s: &str) -> String {
27    let mut out = String::with_capacity(s.len() + 4);
28    for (i, c) in s.chars().enumerate() {
29        if c.is_uppercase() {
30            if i > 0 {
31                out.push('_');
32            }
33            out.extend(c.to_lowercase());
34        } else {
35            out.push(c);
36        }
37    }
38    out
39}
40
41/// Convert all keys of a JSON object from snake_case to camelCase (in place).
42/// Used for API responses so the client receives camelCase keys.
43pub fn object_keys_to_camel_case(obj: &mut Map<String, Value>) {
44    let keys: Vec<String> = obj.keys().cloned().collect();
45    for k in keys {
46        let camel = to_camel_case(&k);
47        if camel != k {
48            if let Some(v) = obj.remove(&k) {
49                obj.insert(camel, v);
50            }
51        }
52    }
53}
54
55/// Convert all keys of a JSON object from camelCase to snake_case (in place).
56/// Used for request bodies and query params so we use snake_case for DB column names.
57pub fn object_keys_to_snake_case(obj: &mut Map<String, Value>) {
58    let keys: Vec<String> = obj.keys().cloned().collect();
59    for k in keys {
60        let snake = to_snake_case(&k);
61        if snake != k {
62            if let Some(v) = obj.remove(&k) {
63                obj.insert(snake, v);
64            }
65        }
66    }
67}
68
69/// Apply camelCase conversion to a Value. If it's an object, converts its keys; otherwise no-op.
70pub fn value_keys_to_camel_case(value: &mut Value) {
71    if let Value::Object(ref mut map) = value {
72        object_keys_to_camel_case(map);
73    }
74}
75
76/// Recursively apply camelCase to all object keys in a Value (objects and arrays of objects).
77pub fn value_keys_to_camel_case_recursive(value: &mut Value) {
78    match value {
79        Value::Object(map) => {
80            object_keys_to_camel_case(map);
81            for (_, v) in map.iter_mut() {
82                value_keys_to_camel_case_recursive(v);
83            }
84        }
85        Value::Array(arr) => {
86            for v in arr.iter_mut() {
87                value_keys_to_camel_case_recursive(v);
88            }
89        }
90        _ => {}
91    }
92}
93
94/// Apply snake_case conversion to a Value. If it's an object, converts its keys; otherwise no-op.
95pub fn value_keys_to_snake_case(value: &mut Value) {
96    if let Value::Object(ref mut map) = value {
97        object_keys_to_snake_case(map);
98    }
99}
100
101/// Convert a HashMap's keys from camelCase to snake_case. Returns a new map.
102pub fn hashmap_keys_to_snake_case(map: &HashMap<String, Value>) -> HashMap<String, Value> {
103    map.iter()
104        .map(|(k, v)| (to_snake_case(k), v.clone()))
105        .collect()
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use serde_json::json;
112
113    // --- to_camel_case ---
114
115    #[test]
116    fn camel_single_underscore() {
117        assert_eq!(to_camel_case("user_id"), "userId");
118    }
119
120    #[test]
121    fn camel_multiple_underscores() {
122        assert_eq!(to_camel_case("created_at_time"), "createdAtTime");
123    }
124
125    #[test]
126    fn camel_no_underscore() {
127        assert_eq!(to_camel_case("name"), "name");
128    }
129
130    #[test]
131    fn camel_leading_underscore() {
132        assert_eq!(to_camel_case("_private"), "Private");
133    }
134
135    #[test]
136    fn camel_empty() {
137        assert_eq!(to_camel_case(""), "");
138    }
139
140    #[test]
141    fn camel_trailing_underscore() {
142        // trailing underscore sets capitalize_next=true but no char follows — no crash
143        assert_eq!(to_camel_case("foo_"), "foo");
144    }
145
146    // --- to_snake_case ---
147
148    #[test]
149    fn snake_simple() {
150        assert_eq!(to_snake_case("userId"), "user_id");
151    }
152
153    #[test]
154    fn snake_multiple_caps() {
155        assert_eq!(to_snake_case("createdAt"), "created_at");
156    }
157
158    #[test]
159    fn snake_already_snake() {
160        assert_eq!(to_snake_case("user_id"), "user_id");
161    }
162
163    #[test]
164    fn snake_leading_cap() {
165        // Leading uppercase: no underscore prepended at index 0
166        assert_eq!(to_snake_case("UserName"), "user_name");
167    }
168
169    #[test]
170    fn snake_empty() {
171        assert_eq!(to_snake_case(""), "");
172    }
173
174    // --- round-trip ---
175
176    #[test]
177    fn round_trip_snake_to_camel_to_snake() {
178        let original = "order_line_item";
179        assert_eq!(to_snake_case(&to_camel_case(original)), original);
180    }
181
182    // --- object_keys_to_camel_case ---
183
184    #[test]
185    fn object_keys_camel_converts_keys() {
186        let mut map = json!({"user_id": 1, "created_at": "2024"})
187            .as_object()
188            .unwrap()
189            .clone();
190        object_keys_to_camel_case(&mut map);
191        assert!(map.contains_key("userId"));
192        assert!(map.contains_key("createdAt"));
193        assert!(!map.contains_key("user_id"));
194    }
195
196    #[test]
197    fn object_keys_snake_converts_keys() {
198        let mut map = json!({"userId": 1, "createdAt": "2024"})
199            .as_object()
200            .unwrap()
201            .clone();
202        object_keys_to_snake_case(&mut map);
203        assert!(map.contains_key("user_id"));
204        assert!(map.contains_key("created_at"));
205        assert!(!map.contains_key("userId"));
206    }
207
208    // --- value_keys_to_camel_case_recursive ---
209
210    #[test]
211    fn recursive_camel_nested_object() {
212        let mut v = json!({
213            "user_id": 1,
214            "address": { "zip_code": "12345" }
215        });
216        value_keys_to_camel_case_recursive(&mut v);
217        assert!(v.get("userId").is_some());
218        assert!(v.get("address").unwrap().get("zipCode").is_some());
219    }
220
221    #[test]
222    fn recursive_camel_array_of_objects() {
223        let mut v = json!([
224            { "first_name": "Alice" },
225            { "first_name": "Bob" }
226        ]);
227        value_keys_to_camel_case_recursive(&mut v);
228        let arr = v.as_array().unwrap();
229        assert!(arr[0].get("firstName").is_some());
230        assert!(arr[1].get("firstName").is_some());
231    }
232
233    #[test]
234    fn recursive_camel_scalar_is_noop() {
235        let mut v = json!(42);
236        value_keys_to_camel_case_recursive(&mut v);
237        assert_eq!(v, json!(42));
238    }
239
240    // --- hashmap_keys_to_snake_case ---
241
242    #[test]
243    fn hashmap_snake_keys() {
244        let map: HashMap<String, Value> = [
245            ("firstName".to_string(), json!("Alice")),
246            ("lastName".to_string(), json!("Smith")),
247        ]
248        .into_iter()
249        .collect();
250        let result = hashmap_keys_to_snake_case(&map);
251        assert!(result.contains_key("first_name"));
252        assert!(result.contains_key("last_name"));
253        assert_eq!(result["first_name"], json!("Alice"));
254    }
255}