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