Skip to main content

elasticctl_api/
saved_objects.rs

1//! Opaque Saved Objects bundle transfer and validation.
2
3use 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/// A Saved Object selected for export.
12#[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/// Read-only facts obtained by scanning an opaque Saved Objects bundle.
20#[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/// The server's Saved Objects import outcome.
29#[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
37/// Scan an opaque NDJSON bundle without changing any of its bytes.
38pub 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
103/// Export dashboards and their deep Saved Objects references as opaque NDJSON.
104pub 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
121/// Import an opaque Saved Objects NDJSON bundle.
122pub 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}