Skip to main content

ferro_rs/http/resources/
resource_map.rs

1use std::collections::HashMap;
2use std::hash::Hash;
3
4use serde_json::{Map, Value};
5
6/// Internal representation for conditional field inclusion.
7enum ResourceValue {
8    Present(Value),
9    Missing,
10}
11
12/// Builder for constructing JSON resource representations with conditional field support.
13///
14/// `ResourceMap` collects field name-value pairs and supports conditional inclusion
15/// via `when()`, `unless()`, `merge_when()`, and `when_some()`. Fields are output
16/// in insertion order.
17///
18/// # Example
19///
20/// ```rust,ignore
21/// use ferro_rs::ResourceMap;
22/// use serde_json::json;
23///
24/// let value = ResourceMap::new()
25///     .field("id", json!(1))
26///     .field("name", json!("Alice"))
27///     .when("email", is_admin, || json!("alice@example.com"))
28///     .build();
29/// ```
30pub struct ResourceMap {
31    fields: Vec<(String, ResourceValue)>,
32}
33
34impl ResourceMap {
35    /// Create an empty resource map.
36    pub fn new() -> Self {
37        Self { fields: Vec::new() }
38    }
39
40    /// Always include this field in the output.
41    pub fn field(mut self, key: &str, value: impl Into<Value>) -> Self {
42        self.fields
43            .push((key.to_string(), ResourceValue::Present(value.into())));
44        self
45    }
46
47    /// Include field only when `condition` is true.
48    /// The value closure is only evaluated when the condition holds.
49    pub fn when(mut self, key: &str, condition: bool, value: impl FnOnce() -> Value) -> Self {
50        if condition {
51            self.fields
52                .push((key.to_string(), ResourceValue::Present(value())));
53        } else {
54            self.fields.push((key.to_string(), ResourceValue::Missing));
55        }
56        self
57    }
58
59    /// Include field only when `condition` is false (opposite of `when`).
60    pub fn unless(self, key: &str, condition: bool, value: impl FnOnce() -> Value) -> Self {
61        self.when(key, !condition, value)
62    }
63
64    /// Conditionally merge multiple fields at once.
65    /// When the condition is true, all fields from the closure are included.
66    pub fn merge_when(
67        mut self,
68        condition: bool,
69        fields: impl FnOnce() -> Vec<(&'static str, Value)>,
70    ) -> Self {
71        if condition {
72            for (key, value) in fields() {
73                self.fields
74                    .push((key.to_string(), ResourceValue::Present(value)));
75            }
76        }
77        self
78    }
79
80    /// Include field only if the `Option` is `Some`.
81    pub fn when_some<T: serde::Serialize>(mut self, key: &str, value: &Option<T>) -> Self {
82        if let Some(v) = value {
83            self.fields.push((
84                key.to_string(),
85                ResourceValue::Present(serde_json::to_value(v).unwrap_or(Value::Null)),
86            ));
87        } else {
88            self.fields.push((key.to_string(), ResourceValue::Missing));
89        }
90        self
91    }
92
93    /// Include field if `lookup_key` exists in the given `HashMap` (belongs_to / has_one).
94    ///
95    /// When the key is found, `transform` converts the value to JSON.
96    /// When absent the field is omitted from output.
97    pub fn when_loaded<K, M>(
98        mut self,
99        key: &str,
100        lookup_key: &K,
101        map: &HashMap<K, M>,
102        transform: impl FnOnce(&M) -> Value,
103    ) -> Self
104    where
105        K: Eq + Hash,
106    {
107        if let Some(item) = map.get(lookup_key) {
108            self.fields
109                .push((key.to_string(), ResourceValue::Present(transform(item))));
110        } else {
111            self.fields.push((key.to_string(), ResourceValue::Missing));
112        }
113        self
114    }
115
116    /// Include field if `lookup_key` exists in the given `HashMap<K, Vec<M>>` (has_many).
117    ///
118    /// When the key is found, `transform` receives the vec slice.
119    /// When absent the field is omitted from output.
120    /// An empty vec is still included (loaded but empty).
121    pub fn when_loaded_many<K, M>(
122        mut self,
123        key: &str,
124        lookup_key: &K,
125        map: &HashMap<K, Vec<M>>,
126        transform: impl FnOnce(&[M]) -> Value,
127    ) -> Self
128    where
129        K: Eq + Hash,
130    {
131        if let Some(items) = map.get(lookup_key) {
132            self.fields
133                .push((key.to_string(), ResourceValue::Present(transform(items))));
134        } else {
135            self.fields.push((key.to_string(), ResourceValue::Missing));
136        }
137        self
138    }
139
140    /// Finalize into a JSON object, stripping fields marked as missing.
141    /// Preserves insertion order.
142    pub fn build(self) -> Value {
143        let mut map = Map::new();
144        for (key, value) in self.fields {
145            if let ResourceValue::Present(v) = value {
146                map.insert(key, v);
147            }
148        }
149        Value::Object(map)
150    }
151}
152
153impl Default for ResourceMap {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use serde_json::json;
163
164    #[test]
165    fn test_field_basic() {
166        let value = ResourceMap::new()
167            .field("id", json!(1))
168            .field("name", json!("Alice"))
169            .build();
170
171        assert_eq!(value, json!({"id": 1, "name": "Alice"}));
172    }
173
174    #[test]
175    fn test_when_true() {
176        let value = ResourceMap::new()
177            .field("id", json!(1))
178            .when("email", true, || json!("a@b.com"))
179            .build();
180
181        assert_eq!(value, json!({"id": 1, "email": "a@b.com"}));
182    }
183
184    #[test]
185    fn test_when_false() {
186        let value = ResourceMap::new()
187            .field("id", json!(1))
188            .when("email", false, || json!("a@b.com"))
189            .build();
190
191        assert_eq!(value, json!({"id": 1}));
192    }
193
194    #[test]
195    fn test_unless_true() {
196        let value = ResourceMap::new()
197            .field("id", json!(1))
198            .unless("debug", true, || json!(true))
199            .build();
200
201        assert_eq!(value, json!({"id": 1}));
202    }
203
204    #[test]
205    fn test_unless_false() {
206        let value = ResourceMap::new()
207            .field("id", json!(1))
208            .unless("debug", false, || json!(true))
209            .build();
210
211        assert_eq!(value, json!({"id": 1, "debug": true}));
212    }
213
214    #[test]
215    fn test_merge_when_true() {
216        let value = ResourceMap::new()
217            .field("id", json!(1))
218            .merge_when(true, || vec![("a", json!(1)), ("b", json!(2))])
219            .build();
220
221        assert_eq!(value, json!({"id": 1, "a": 1, "b": 2}));
222    }
223
224    #[test]
225    fn test_merge_when_false() {
226        let value = ResourceMap::new()
227            .field("id", json!(1))
228            .merge_when(false, || vec![("a", json!(1)), ("b", json!(2))])
229            .build();
230
231        assert_eq!(value, json!({"id": 1}));
232    }
233
234    #[test]
235    fn test_when_some_present() {
236        let bio: Option<&str> = Some("hello");
237        let value = ResourceMap::new()
238            .field("id", json!(1))
239            .when_some("bio", &bio)
240            .build();
241
242        assert_eq!(value, json!({"id": 1, "bio": "hello"}));
243    }
244
245    #[test]
246    fn test_when_some_none() {
247        let bio: Option<String> = None;
248        let value = ResourceMap::new()
249            .field("id", json!(1))
250            .when_some("bio", &bio)
251            .build();
252
253        assert_eq!(value, json!({"id": 1}));
254    }
255
256    #[test]
257    fn test_when_loaded_present() {
258        let mut authors = HashMap::new();
259        authors.insert(10, "Alice".to_string());
260
261        let value = ResourceMap::new()
262            .field("id", json!(1))
263            .when_loaded("author", &10, &authors, |name| json!(name))
264            .build();
265
266        assert_eq!(value, json!({"id": 1, "author": "Alice"}));
267    }
268
269    #[test]
270    fn test_when_loaded_missing() {
271        let authors: HashMap<i32, String> = HashMap::new();
272
273        let value = ResourceMap::new()
274            .field("id", json!(1))
275            .when_loaded("author", &10, &authors, |name| json!(name))
276            .build();
277
278        assert_eq!(value, json!({"id": 1}));
279    }
280
281    #[test]
282    fn test_when_loaded_many_present() {
283        let mut tags = HashMap::new();
284        tags.insert(1, vec!["rust", "web"]);
285
286        let value = ResourceMap::new()
287            .field("id", json!(1))
288            .when_loaded_many("tags", &1, &tags, |t| json!(t))
289            .build();
290
291        assert_eq!(value, json!({"id": 1, "tags": ["rust", "web"]}));
292    }
293
294    #[test]
295    fn test_when_loaded_many_missing() {
296        let tags: HashMap<i32, Vec<&str>> = HashMap::new();
297
298        let value = ResourceMap::new()
299            .field("id", json!(1))
300            .when_loaded_many("tags", &1, &tags, |t| json!(t))
301            .build();
302
303        assert_eq!(value, json!({"id": 1}));
304    }
305
306    #[test]
307    fn test_when_loaded_many_empty_vec() {
308        let mut tags: HashMap<i32, Vec<&str>> = HashMap::new();
309        tags.insert(1, vec![]);
310
311        let value = ResourceMap::new()
312            .field("id", json!(1))
313            .when_loaded_many("tags", &1, &tags, |t| json!(t))
314            .build();
315
316        assert_eq!(value, json!({"id": 1, "tags": []}));
317    }
318
319    #[test]
320    fn test_when_loaded_combined() {
321        let mut authors = HashMap::new();
322        authors.insert(10, "Alice".to_string());
323
324        let mut tags: HashMap<i32, Vec<&str>> = HashMap::new();
325        tags.insert(1, vec!["rust", "web"]);
326
327        // author present, tags present
328        let value = ResourceMap::new()
329            .field("id", json!(1))
330            .when_loaded("author", &10, &authors, |name| json!(name))
331            .when_loaded_many("tags", &1, &tags, |t| json!(t))
332            .build();
333
334        assert_eq!(
335            value,
336            json!({"id": 1, "author": "Alice", "tags": ["rust", "web"]})
337        );
338
339        // author missing, tags present
340        let value = ResourceMap::new()
341            .field("id", json!(2))
342            .when_loaded("author", &99, &authors, |name| json!(name))
343            .when_loaded_many("tags", &1, &tags, |t| json!(t))
344            .build();
345
346        assert_eq!(value, json!({"id": 2, "tags": ["rust", "web"]}));
347
348        // author present, tags missing
349        let value = ResourceMap::new()
350            .field("id", json!(3))
351            .when_loaded("author", &10, &authors, |name| json!(name))
352            .when_loaded_many("tags", &99, &tags, |t| json!(t))
353            .build();
354
355        assert_eq!(value, json!({"id": 3, "author": "Alice"}));
356    }
357
358    #[test]
359    fn test_field_order_preserved() {
360        let value = ResourceMap::new()
361            .field("c", json!(3))
362            .field("a", json!(1))
363            .field("b", json!(2))
364            .build();
365
366        let keys: Vec<&String> = value.as_object().unwrap().keys().collect();
367        assert_eq!(keys, vec!["c", "a", "b"]);
368    }
369}