Skip to main content

kibana_object_manager/transform/
field_escaper.rs

1//! Field escaper/unescaper transformers
2//!
3//! Handles conversion between JSON strings and objects for specific fields
4//! in Kibana saved objects that store nested JSON as strings.
5
6use crate::etl::Transformer;
7use eyre::{Context, Result};
8use serde_json::Value;
9
10/// Transformer that escapes specific fields (converts objects to JSON strings)
11///
12/// Used during import (Files → Kibana) to convert nested JSON objects
13/// back into JSON strings that Kibana expects.
14///
15/// # Example
16/// ```
17/// use kibana_object_manager::transform::FieldEscaper;
18/// use kibana_object_manager::etl::Transformer;
19/// use serde_json::json;
20///
21/// let escaper = FieldEscaper::new(vec!["attributes.visState"]);
22/// let input = json!({
23///     "attributes": {
24///         "visState": {"type": "pie", "params": {}}
25///     }
26/// });
27///
28/// let output = escaper.transform(input).unwrap();
29/// assert!(output["attributes"]["visState"].is_string());
30/// ```
31pub struct FieldEscaper {
32    fields: Vec<String>,
33}
34
35impl FieldEscaper {
36    /// Create a new field escaper with specified fields to escape
37    pub fn new(fields: Vec<&str>) -> Self {
38        Self {
39            fields: fields.iter().map(|s| s.to_string()).collect(),
40        }
41    }
42
43    /// Create a field escaper with default Kibana JSON string fields
44    pub fn default_kibana_fields() -> Self {
45        Self::new(vec![
46            "attributes.panelsJSON",
47            "attributes.fieldFormatMap",
48            "attributes.controlGroupInput.ignoreParentSettingsJSON",
49            "attributes.controlGroupInput.panelsJSON",
50            "attributes.kibanaSavedObjectMeta.searchSourceJSON",
51            "attributes.optionsJSON",
52            "attributes.visState",
53            "attributes.fieldAttrs",
54        ])
55    }
56
57    fn get_nested_mut<'a>(obj: &'a mut Value, path: &str) -> Option<&'a mut Value> {
58        let parts: Vec<&str> = path.split('.').collect();
59        let mut current = obj;
60
61        for part in parts {
62            current = current.get_mut(part)?;
63        }
64
65        Some(current)
66    }
67}
68
69impl Transformer for FieldEscaper {
70    type Input = Value;
71    type Output = Value;
72
73    fn transform(&self, mut input: Self::Input) -> Result<Self::Output> {
74        for field_path in &self.fields {
75            if let Some(field) = Self::get_nested_mut(&mut input, field_path) {
76                // If it's an object or array, convert to JSON string
77                if field.is_object() || field.is_array() {
78                    let json_string = serde_json::to_string(field)
79                        .with_context(|| format!("Failed to escape field: {}", field_path))?;
80                    *field = Value::String(json_string);
81                }
82            }
83        }
84        Ok(input)
85    }
86}
87
88/// Transformer that unescapes specific fields (converts JSON strings to objects)
89///
90/// Used during export (Kibana → Files) to convert JSON strings into proper
91/// nested objects for better readability and version control.
92///
93/// # Example
94/// ```
95/// use kibana_object_manager::transform::FieldUnescaper;
96/// use kibana_object_manager::etl::Transformer;
97/// use serde_json::json;
98///
99/// let unescaper = FieldUnescaper::new(vec!["attributes.visState"]);
100/// let input = json!({
101///     "attributes": {
102///         "visState": r#"{"type":"pie","params":{}}"#
103///     }
104/// });
105///
106/// let output = unescaper.transform(input).unwrap();
107/// assert!(output["attributes"]["visState"].is_object());
108/// assert_eq!(output["attributes"]["visState"]["type"], "pie");
109/// ```
110pub struct FieldUnescaper {
111    fields: Vec<String>,
112}
113
114impl FieldUnescaper {
115    /// Create a new field unescaper with specified fields to unescape
116    pub fn new(fields: Vec<&str>) -> Self {
117        Self {
118            fields: fields.iter().map(|s| s.to_string()).collect(),
119        }
120    }
121
122    /// Create a field unescaper with default Kibana JSON string fields
123    pub fn default_kibana_fields() -> Self {
124        Self::new(vec![
125            "attributes.panelsJSON",
126            "attributes.fieldFormatMap",
127            "attributes.controlGroupInput.ignoreParentSettingsJSON",
128            "attributes.controlGroupInput.panelsJSON",
129            "attributes.kibanaSavedObjectMeta.searchSourceJSON",
130            "attributes.optionsJSON",
131            "attributes.visState",
132            "attributes.fieldAttrs",
133        ])
134    }
135
136    fn get_nested_mut<'a>(obj: &'a mut Value, path: &str) -> Option<&'a mut Value> {
137        let parts: Vec<&str> = path.split('.').collect();
138        let mut current = obj;
139
140        for part in parts {
141            current = current.get_mut(part)?;
142        }
143
144        Some(current)
145    }
146}
147
148impl Transformer for FieldUnescaper {
149    type Input = Value;
150    type Output = Value;
151
152    fn transform(&self, mut input: Self::Input) -> Result<Self::Output> {
153        for field_path in &self.fields {
154            if let Some(field) = Self::get_nested_mut(&mut input, field_path) {
155                // If it's a string, try to parse as JSON
156                if let Some(json_str) = field.as_str() {
157                    // Only parse if it looks like JSON (starts with { or [)
158                    let trimmed = json_str.trim();
159                    if trimmed.starts_with('{') || trimmed.starts_with('[') {
160                        match serde_json::from_str(json_str) {
161                            Ok(parsed) => *field = parsed,
162                            Err(_) => {
163                                // If parsing fails, leave as string
164                                log::debug!(
165                                    "Failed to unescape field {}, leaving as string",
166                                    field_path
167                                );
168                            }
169                        }
170                    }
171                }
172            }
173        }
174        Ok(input)
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use serde_json::json;
182
183    #[test]
184    fn test_escape_field() {
185        let escaper = FieldEscaper::new(vec!["attributes.visState"]);
186        let input = json!({
187            "attributes": {
188                "visState": {"type": "pie", "params": {"size": 10}},
189                "title": "My Viz"
190            }
191        });
192
193        let output = escaper.transform(input).unwrap();
194
195        assert!(output["attributes"]["visState"].is_string());
196        let vis_state_str = output["attributes"]["visState"].as_str().unwrap();
197        let parsed: Value = serde_json::from_str(vis_state_str).unwrap();
198        assert_eq!(parsed["type"], "pie");
199        assert_eq!(parsed["params"]["size"], 10);
200    }
201
202    #[test]
203    fn test_unescape_field() {
204        let unescaper = FieldUnescaper::new(vec!["attributes.visState"]);
205        let input = json!({
206            "attributes": {
207                "visState": r#"{"type":"pie","params":{"size":10}}"#,
208                "title": "My Viz"
209            }
210        });
211
212        let output = unescaper.transform(input).unwrap();
213
214        assert!(output["attributes"]["visState"].is_object());
215        assert_eq!(output["attributes"]["visState"]["type"], "pie");
216        assert_eq!(output["attributes"]["visState"]["params"]["size"], 10);
217    }
218
219    #[test]
220    fn test_roundtrip() {
221        let unescaper = FieldUnescaper::new(vec!["attributes.visState"]);
222        let escaper = FieldEscaper::new(vec!["attributes.visState"]);
223
224        let original = json!({
225            "attributes": {
226                "visState": r#"{"type":"pie"}"#
227            }
228        });
229
230        // Unescape (string -> object)
231        let unescaped = unescaper.transform(original.clone()).unwrap();
232        assert!(unescaped["attributes"]["visState"].is_object());
233
234        // Escape (object -> string)
235        let escaped = escaper.transform(unescaped).unwrap();
236        assert!(escaped["attributes"]["visState"].is_string());
237
238        // Should be equivalent to original
239        let escaped_str = escaped["attributes"]["visState"].as_str().unwrap();
240        let original_str = original["attributes"]["visState"].as_str().unwrap();
241        let escaped_parsed: Value = serde_json::from_str(escaped_str).unwrap();
242        let original_parsed: Value = serde_json::from_str(original_str).unwrap();
243        assert_eq!(escaped_parsed, original_parsed);
244    }
245
246    #[test]
247    fn test_nested_path() {
248        let escaper = FieldEscaper::new(vec!["attributes.controlGroupInput.panelsJSON"]);
249        let input = json!({
250            "attributes": {
251                "controlGroupInput": {
252                    "panelsJSON": {"panel1": {"id": "test"}}
253                }
254            }
255        });
256
257        let output = escaper.transform(input).unwrap();
258        assert!(output["attributes"]["controlGroupInput"]["panelsJSON"].is_string());
259    }
260
261    #[test]
262    fn test_missing_field_ignored() {
263        let escaper = FieldEscaper::new(vec!["attributes.nonexistent"]);
264        let input = json!({"attributes": {"title": "Test"}});
265
266        let output = escaper.transform(input.clone()).unwrap();
267        assert_eq!(output, input);
268    }
269
270    #[test]
271    fn test_already_string_unchanged() {
272        let escaper = FieldEscaper::new(vec!["attributes.title"]);
273        let input = json!({"attributes": {"title": "Already a string"}});
274
275        let output = escaper.transform(input.clone()).unwrap();
276        assert_eq!(output, input);
277    }
278
279    #[test]
280    fn test_invalid_json_string_unchanged() {
281        let unescaper = FieldUnescaper::new(vec!["attributes.visState"]);
282        let input = json!({
283            "attributes": {
284                "visState": "not valid json"
285            }
286        });
287
288        let output = unescaper.transform(input.clone()).unwrap();
289        // Should remain as string since it's not valid JSON
290        assert_eq!(output, input);
291    }
292}