kibana_sync/kibana/saved_objects/
loader.rs1use crate::client::KibanaClient;
6use crate::etl::Loader;
7
8use crate::{Error, Result, ResultContext};
9use serde::Deserialize;
10use serde_json::Value;
11
12const LEGACY_JSON_FIELDS: &[&str] = &[
13 "attributes.panelsJSON",
14 "attributes.fieldFormatMap",
15 "attributes.controlGroupInput.ignoreParentSettingsJSON",
16 "attributes.controlGroupInput.panelsJSON",
17 "attributes.kibanaSavedObjectMeta.searchSourceJSON",
18 "attributes.optionsJSON",
19 "attributes.visState",
20 "attributes.fieldAttrs",
21];
22
23#[derive(Debug, Deserialize)]
24#[serde(rename_all = "camelCase")]
25struct ImportResponse {
26 #[serde(default)]
27 success: bool,
28 #[serde(default)]
29 errors: Vec<Value>,
30}
31
32pub struct SavedObjectsLoader {
64 client: KibanaClient,
65 overwrite: bool,
66}
67
68impl SavedObjectsLoader {
69 pub fn new(client: KibanaClient) -> Self {
74 Self {
75 client,
76 overwrite: true,
77 }
78 }
79
80 pub fn with_overwrite(mut self, overwrite: bool) -> Self {
82 self.overwrite = overwrite;
83 self
84 }
85
86 async fn import_objects(&self, objects: &[Value]) -> Result<()> {
90 let ndjson = objects
92 .iter()
93 .cloned()
94 .map(serialize_legacy_json_fields)
95 .collect::<Result<Vec<_>>>()?
96 .iter()
97 .map(serde_json::to_string)
98 .collect::<std::result::Result<Vec<_>, serde_json::Error>>()
99 .with_context(|| "Failed to serialize objects to NDJSON")?
100 .join("\n");
101
102 let path = format!("api/saved_objects/_import?overwrite={}", self.overwrite);
103
104 tracing::debug!(
105 "Importing {} object(s) to space '{}'",
106 objects.len(),
107 self.client.space_id()
108 );
109
110 let response = self
111 .client
112 .post_form(&path, ndjson.as_bytes())
113 .await
114 .with_context(|| "Failed to import saved objects to Kibana")?;
115
116 let status = response.status();
117 if !status.is_success() {
118 let body = response.text().await.unwrap_or_default();
119 return Err(Error::api_response(status, body));
120 }
121
122 let body = response
123 .text()
124 .await
125 .with_context(|| "Failed to read saved object import response")?;
126 let result: ImportResponse = serde_json::from_str(&body)
127 .with_context(|| "Failed to parse saved object import response")?;
128 if !result.success || !result.errors.is_empty() {
129 return Err(Error::api_response(status, body));
130 }
131
132 tracing::info!("Imported {} object(s) to Kibana", objects.len());
133
134 Ok(())
135 }
136}
137
138fn serialize_legacy_json_fields(mut object: Value) -> Result<Value> {
139 for field_path in LEGACY_JSON_FIELDS {
140 if let Some(field) = get_nested_mut(&mut object, field_path)
141 && (field.is_object() || field.is_array())
142 {
143 *field = Value::String(
144 serde_json::to_string(field)
145 .with_context(|| format!("Failed to serialize legacy field: {field_path}"))?,
146 );
147 }
148 }
149 Ok(object)
150}
151
152fn get_nested_mut<'a>(object: &'a mut Value, path: &str) -> Option<&'a mut Value> {
153 let mut current = object;
154 for field in path.split('.') {
155 current = current.get_mut(field)?;
156 }
157 Some(current)
158}
159
160impl Loader for SavedObjectsLoader {
161 type Item = Value;
162
163 async fn load(&self, items: Vec<Self::Item>) -> Result<usize> {
164 if items.is_empty() {
165 tracing::info!("No saved objects to import");
166 return Ok(0);
167 }
168
169 self.import_objects(&items).await?;
170
171 tracing::info!("Loaded {} saved object(s) to Kibana", items.len());
172 Ok(items.len())
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::client::{Auth, KibanaClient};
180 use crate::etl::Loader;
181 use crate::test_support::{MockResponse, TestServer};
182 use serde_json::json;
183 use url::Url;
184
185 #[test]
186 fn test_loader_creation() {
187 let url = Url::parse("http://localhost:5601").unwrap();
188 let client = KibanaClient::new(url, Auth::None).unwrap();
189 let space_client = client.space("default").unwrap();
190 let loader = SavedObjectsLoader::new(space_client);
191 assert!(loader.overwrite);
192 }
193
194 #[test]
195 fn test_with_overwrite() {
196 let url = Url::parse("http://localhost:5601").unwrap();
197 let client = KibanaClient::new(url, Auth::None).unwrap();
198 let space_client = client.space("default").unwrap();
199 let loader = SavedObjectsLoader::new(space_client).with_overwrite(false);
200 assert!(!loader.overwrite);
201 }
202
203 #[test]
204 fn test_custom_space() {
205 let url = Url::parse("http://localhost:5601").unwrap();
206 let client = KibanaClient::builder(url)
207 .spaces([
208 ("default".to_string(), "Default".to_string()),
209 ("marketing".to_string(), "Marketing".to_string()),
210 ])
211 .build()
212 .unwrap();
213 let space_client = client.space("marketing").unwrap();
214 let loader = SavedObjectsLoader::new(space_client);
215 assert_eq!(loader.client.space_id(), "marketing");
216 }
217
218 #[test]
219 fn serializes_structured_legacy_fields_without_reescaping_strings() {
220 let object = json!({
221 "attributes": {
222 "panelsJSON": [{"panelIndex": "1"}],
223 "kibanaSavedObjectMeta": {
224 "searchSourceJSON": {"query": {"language": "kuery"}}
225 },
226 "visState": {"type": "pie"},
227 "optionsJSON": "{\"useMargins\":true}"
228 }
229 });
230
231 let serialized = serialize_legacy_json_fields(object).unwrap();
232 let attributes = &serialized["attributes"];
233 assert!(attributes["panelsJSON"].is_string());
234 assert!(attributes["kibanaSavedObjectMeta"]["searchSourceJSON"].is_string());
235 assert!(attributes["visState"].is_string());
236 assert_eq!(attributes["optionsJSON"], "{\"useMargins\":true}");
237 assert_eq!(
238 serde_json::from_str::<Value>(attributes["panelsJSON"].as_str().unwrap()).unwrap(),
239 json!([{"panelIndex": "1"}])
240 );
241 }
242
243 #[tokio::test]
244 async fn import_serializes_legacy_fields_in_ndjson() {
245 let server = TestServer::new(vec![MockResponse {
246 method: "POST",
247 path: "/api/saved_objects/_import?overwrite=true",
248 status: 200,
249 body: json!({"success": true, "successCount": 1, "errors": []}),
250 }]);
251 let loader = SavedObjectsLoader::new(server.client().unwrap().space("default").unwrap());
252
253 loader
254 .load(vec![json!({
255 "type": "dashboard",
256 "id": "dashboard-1",
257 "attributes": {
258 "panelsJSON": [{"panelIndex": "1"}],
259 "kibanaSavedObjectMeta": {
260 "searchSourceJSON": {"query": {"language": "kuery"}}
261 },
262 "visState": {"type": "pie"}
263 }
264 })])
265 .await
266 .unwrap();
267
268 let request = &server.requests()[0];
269 let (_, ndjson) = request.body.split_once("\r\n\r\n").unwrap();
270 let (ndjson, _) = ndjson.split_once("\r\n--").unwrap();
271 let imported: Value = serde_json::from_str(ndjson).unwrap();
272 assert!(imported["attributes"]["panelsJSON"].is_string());
273 assert!(imported["attributes"]["kibanaSavedObjectMeta"]["searchSourceJSON"].is_string());
274 assert!(imported["attributes"]["visState"].is_string());
275 }
276
277 #[tokio::test]
278 async fn import_fails_when_kibana_reports_item_errors() {
279 let server = TestServer::new(vec![MockResponse {
280 method: "POST",
281 path: "/api/saved_objects/_import?overwrite=true",
282 status: 200,
283 body: json!({
284 "success": true,
285 "successCount": 1,
286 "errors": [{"id": "dashboard-1", "type": "dashboard", "error": {"message": "invalid panelsJSON"}}]
287 }),
288 }]);
289 let loader = SavedObjectsLoader::new(server.client().unwrap().space("default").unwrap());
290
291 let error = loader
292 .load(vec![json!({"type": "dashboard", "id": "dashboard-1"})])
293 .await
294 .unwrap_err();
295
296 assert!(error.to_string().contains("invalid panelsJSON"));
297 }
298
299 #[tokio::test]
300 async fn import_fails_when_kibana_reports_unsuccessful_response() {
301 let server = TestServer::new(vec![MockResponse {
302 method: "POST",
303 path: "/api/saved_objects/_import?overwrite=true",
304 status: 200,
305 body: json!({"success": false, "successCount": 0, "errors": []}),
306 }]);
307 let loader = SavedObjectsLoader::new(server.client().unwrap().space("default").unwrap());
308
309 let error = loader
310 .load(vec![json!({"type": "dashboard", "id": "dashboard-1"})])
311 .await
312 .unwrap_err();
313
314 assert!(error.to_string().contains("\"success\":false"));
315 }
316}