Skip to main content

elasticctl_api/
saved_objects.rs

1use 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}