elasticctl_api/
saved_objects.rs1use std::collections::{BTreeMap, BTreeSet};
2
3use elasticctl_core::{Error, ErrorKind, Result, Transport};
4use serde::Serialize;
5use serde_json::{Map, Value, json};
6
7const BASE: &str = "/api/saved_objects";
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
10pub struct SavedObjectRef {
11 #[serde(rename = "type")]
12 pub object_type: String,
13 pub id: String,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct BundleScan {
18 pub dashboards: Vec<String>,
19 pub counts: BTreeMap<String, usize>,
20 pub total: usize,
21 pub has_export_details: bool,
22}
23
24#[derive(Debug, Clone, PartialEq)]
25pub struct SavedObjectsImportReport {
26 pub success: bool,
27 pub success_count: u64,
28 pub success_results: Vec<Value>,
29 pub errors: Vec<Value>,
30}
31
32pub fn scan_bundle(bundle: &str) -> Result<BundleScan> {
33 let mut dashboards = Vec::new();
34 let mut counts = BTreeMap::new();
35 let mut total = 0;
36 let mut export_details_lines = Vec::new();
37 let mut last_nonempty_line = None;
38
39 for (index, line) in bundle.lines().enumerate() {
40 if line.trim().is_empty() {
41 continue;
42 }
43 let line_number = index + 1;
44 last_nonempty_line = Some(line_number);
45 let value = serde_json::from_str::<Value>(line).map_err(|error| {
46 local_error(format!(
47 "invalid Saved Objects bundle JSON at line {line_number}: {error}"
48 ))
49 })?;
50 let object = value.as_object().ok_or_else(|| {
51 local_error(format!(
52 "invalid Saved Objects bundle line {line_number}: expected a JSON object"
53 ))
54 })?;
55
56 if is_export_details(object) {
57 validate_export_details(object, line_number)?;
58 export_details_lines.push(line_number);
59 continue;
60 }
61
62 let object_type = required_string(object, "type", line_number)?;
63 let id = required_string(object, "id", line_number)?;
64 if object_type == "dashboard" {
65 dashboards.push(id.to_owned());
66 }
67 *counts.entry(object_type.to_owned()).or_default() += 1;
68 total += 1;
69 }
70
71 if export_details_lines.len() > 1 {
72 return Err(local_error(
73 "invalid Saved Objects bundle: more than one export-details trailer",
74 ));
75 }
76 if let Some(line) = export_details_lines.first()
77 && Some(*line) != last_nonempty_line
78 {
79 return Err(local_error(format!(
80 "invalid Saved Objects bundle: export-details trailer at line {line} is not last"
81 )));
82 }
83 if dashboards.is_empty() {
84 return Err(local_error(
85 "invalid Saved Objects bundle: expected at least one dashboard",
86 ));
87 }
88
89 Ok(BundleScan {
90 dashboards,
91 counts,
92 total,
93 has_export_details: !export_details_lines.is_empty(),
94 })
95}
96
97pub async fn export(t: &Transport, ids: &[String]) -> Result<String> {
98 let ids: BTreeSet<String> = ids.iter().cloned().collect();
99 let objects = ids
100 .into_iter()
101 .map(|id| SavedObjectRef {
102 object_type: "dashboard".into(),
103 id,
104 })
105 .collect::<Vec<_>>();
106 let body = json!({
107 "objects": objects,
108 "includeReferencesDeep": true,
109 "excludeExportDetails": false,
110 });
111 t.post_text(&format!("{BASE}/_export"), Some(&body)).await
112}
113
114pub async fn import(
115 t: &Transport,
116 bundle: &str,
117 overwrite: bool,
118) -> Result<SavedObjectsImportReport> {
119 let response = t
120 .post_multipart_ndjson_named(
121 &format!("{BASE}/_import?overwrite={overwrite}"),
122 "dashboards.ndjson",
123 bundle,
124 )
125 .await?;
126 decode_import_response(&response)
127}
128
129fn is_export_details(object: &Map<String, Value>) -> bool {
130 object.contains_key("exportedCount")
131 || object.contains_key("missingRefCount")
132 || object.contains_key("missingReferences")
133}
134
135fn validate_export_details(object: &Map<String, Value>, line: usize) -> Result<()> {
136 for field in ["exportedCount", "missingRefCount"] {
137 if object.get(field).and_then(Value::as_u64).is_none() {
138 return Err(local_error(format!(
139 "invalid Saved Objects export-details trailer at line {line}: {field} must be an unsigned number"
140 )));
141 }
142 }
143 if object
144 .get("missingReferences")
145 .and_then(Value::as_array)
146 .is_none()
147 {
148 return Err(local_error(format!(
149 "invalid Saved Objects export-details trailer at line {line}: missingReferences must be an array"
150 )));
151 }
152 Ok(())
153}
154
155fn required_string<'a>(
156 object: &'a Map<String, Value>,
157 field: &str,
158 line: usize,
159) -> Result<&'a str> {
160 object.get(field).and_then(Value::as_str).ok_or_else(|| {
161 local_error(format!(
162 "invalid Saved Objects bundle line {line}: {field} must be a string"
163 ))
164 })
165}
166
167fn decode_import_response(response: &Value) -> Result<SavedObjectsImportReport> {
168 let object = response
169 .as_object()
170 .ok_or_else(|| import_error("response must be an object"))?;
171 let success = object
172 .get("success")
173 .and_then(Value::as_bool)
174 .ok_or_else(|| import_error("field `success` must be a boolean"))?;
175 let success_count = object
176 .get("successCount")
177 .and_then(Value::as_u64)
178 .ok_or_else(|| import_error("field `successCount` must be an unsigned number"))?;
179 let success_results = optional_array(object, "successResults")?;
180 let errors = optional_array(object, "errors")?;
181
182 if success_count != success_results.len() as u64 {
183 return Err(import_error(
184 "field `successCount` must equal the number of `successResults`",
185 ));
186 }
187 if success && !errors.is_empty() {
188 return Err(import_error("successful imports must not contain `errors`"));
189 }
190 if !success && errors.is_empty() {
191 return Err(import_error("failed imports must contain `errors`"));
192 }
193
194 Ok(SavedObjectsImportReport {
195 success,
196 success_count,
197 success_results,
198 errors,
199 })
200}
201
202fn optional_array(object: &Map<String, Value>, field: &str) -> Result<Vec<Value>> {
203 match object.get(field) {
204 None => Ok(Vec::new()),
205 Some(Value::Array(values)) => Ok(values.clone()),
206 Some(_) => Err(import_error(format!("field `{field}` must be an array"))),
207 }
208}
209
210fn local_error(message: impl Into<String>) -> Error {
211 Error::new(ErrorKind::Error, message)
212}
213
214fn import_error(message: impl Into<String>) -> Error {
215 Error::new(
216 ErrorKind::Http,
217 format!("decoding Saved Objects import response: {}", message.into()),
218 )
219}