Skip to main content

elasticctl_api/
dashboards.rs

1use crate::compose::require_path_segment;
2use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
3use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned};
4use serde_json::{Map, Value};
5use std::collections::BTreeSet;
6
7const BASE: &str = "/api/dashboards";
8
9#[derive(Debug, Clone, PartialEq, Serialize)]
10pub struct DashboardSpec {
11    pub id: String,
12    pub data: Map<String, Value>,
13}
14
15#[derive(Deserialize)]
16#[serde(deny_unknown_fields)]
17struct RawDashboardSpec {
18    id: String,
19    data: Map<String, Value>,
20}
21
22impl DashboardSpec {
23    fn validate_shape(&self) -> Result<()> {
24        if self.id.trim().is_empty() {
25            return Err(Error::new(
26                ErrorKind::Error,
27                "dashboard id must not be empty",
28            ));
29        }
30        match self.data.get("title") {
31            Some(Value::String(title)) if !title.trim().is_empty() => Ok(()),
32            _ => Err(Error::new(
33                ErrorKind::Error,
34                "dashboard data.title must be a non-empty string",
35            )),
36        }
37    }
38}
39
40impl TryFrom<Value> for DashboardSpec {
41    type Error = Error;
42
43    fn try_from(value: Value) -> Result<Self> {
44        serde_json::from_value(value)
45            .map_err(|error| Error::new(ErrorKind::Error, format!("decoding dashboard: {error}")))
46    }
47}
48
49impl<'de> Deserialize<'de> for DashboardSpec {
50    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
51    where
52        D: Deserializer<'de>,
53    {
54        let raw = RawDashboardSpec::deserialize(deserializer)?;
55        let spec = Self {
56            id: raw.id,
57            data: raw.data,
58        };
59        spec.validate_shape().map_err(serde::de::Error::custom)?;
60        Ok(spec)
61    }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct DashboardSummary {
67    pub id: String,
68    pub title: String,
69    #[serde(default)]
70    pub description: Option<String>,
71    #[serde(default)]
72    pub tags: Option<Vec<String>>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct DashboardWarning {
78    pub message: String,
79}
80
81#[derive(Debug, Clone, PartialEq)]
82pub struct Dashboard {
83    pub id: String,
84    pub data: Map<String, Value>,
85    pub meta: Map<String, Value>,
86    pub warnings: Vec<DashboardWarning>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct DashboardPage {
91    pub data: Vec<DashboardSummary>,
92    pub page: u64,
93    pub per_page: u64,
94    pub total: u64,
95}
96
97#[derive(Debug, Clone, PartialEq)]
98pub struct DashboardLoss {
99    pub path: String,
100    pub expected: Value,
101    pub actual: Option<Value>,
102}
103
104pub async fn search(
105    transport: &Transport,
106    page: u64,
107    query: Option<&str>,
108    tags: &[String],
109) -> Result<DashboardPage> {
110    transport.require_feature(Feature::Dashboards).await?;
111    let mut route = format!("{BASE}?page={page}&per_page=1000");
112    if let Some(query) = query {
113        route.push_str("&query=");
114        route.push_str(&urlencode(query));
115    }
116    for tag in tags {
117        route.push_str("&tags=");
118        route.push_str(&urlencode(tag));
119    }
120    decode_search(&transport.get(&route).await?)
121}
122
123pub async fn get(transport: &Transport, id: &str) -> Result<Dashboard> {
124    get_selector(transport, require_path_segment(id)?).await
125}
126
127pub(crate) async fn get_selector(transport: &Transport, selector: &str) -> Result<Dashboard> {
128    transport.require_feature(Feature::Dashboards).await?;
129    decode_dashboard(
130        &transport.get(&dashboard_path(selector)).await?,
131        "dashboard get",
132    )
133}
134
135pub async fn put(transport: &Transport, spec: &DashboardSpec) -> Result<Dashboard> {
136    validate_spec(spec)?;
137    let id = require_path_segment(&spec.id)?;
138    transport.require_feature(Feature::Dashboards).await?;
139    decode_dashboard(
140        &transport
141            .put(&dashboard_path(id), &Value::Object(spec.data.clone()))
142            .await?,
143        "dashboard put",
144    )
145}
146
147pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
148    let id = require_path_segment(id)?;
149    transport.require_feature(Feature::Dashboards).await?;
150    match transport.delete(&dashboard_path(id)).await? {
151        Value::Null => Ok(()),
152        _ => Err(Error::new(
153            ErrorKind::Http,
154            "decoding dashboard delete: expected an empty response body",
155        )),
156    }
157}
158
159pub fn validate_spec(spec: &DashboardSpec) -> Result<()> {
160    spec.validate_shape()?;
161    if let Some(path) = time_range_mode_path(&Value::Object(spec.data.clone()), "$") {
162        return Err(Error::new(
163            ErrorKind::Unsupported,
164            format!("dashboard {path} is unsupported because Kibana does not persist it"),
165        ));
166    }
167    Ok(())
168}
169
170pub fn collect_data_view_refs(value: &Value) -> Vec<String> {
171    fn collect(value: &Value, ids: &mut BTreeSet<String>) {
172        match value {
173            Value::Object(map) => {
174                if map.get("type").and_then(Value::as_str) == Some("data_view_reference")
175                    && let Some(id) = map.get("ref_id").and_then(Value::as_str)
176                {
177                    ids.insert(id.to_string());
178                }
179                for value in map.values() {
180                    collect(value, ids);
181                }
182            }
183            Value::Array(values) => {
184                for value in values {
185                    collect(value, ids);
186                }
187            }
188            _ => {}
189        }
190    }
191
192    let mut ids = BTreeSet::new();
193    collect(value, &mut ids);
194    ids.into_iter().collect()
195}
196
197pub fn subset_losses(expected: &Value, actual: &Value) -> Vec<DashboardLoss> {
198    fn collect(
199        expected: &Value,
200        actual: Option<&Value>,
201        path: &str,
202        losses: &mut Vec<DashboardLoss>,
203    ) {
204        let Some(actual) = actual else {
205            losses.push(DashboardLoss {
206                path: path.to_string(),
207                expected: expected.clone(),
208                actual: None,
209            });
210            return;
211        };
212
213        match (expected, actual) {
214            (Value::Object(expected), Value::Object(actual)) => {
215                for (key, expected) in expected {
216                    let path = format!("{path}.{}", json_path_key(key));
217                    collect(expected, actual.get(key), &path, losses);
218                }
219            }
220            (Value::Array(expected), Value::Array(actual)) => {
221                for (index, expected) in expected.iter().enumerate() {
222                    let path = format!("{path}[{index}]");
223                    collect(expected, actual.get(index), &path, losses);
224                }
225            }
226            _ if expected == actual => {}
227            _ => losses.push(DashboardLoss {
228                path: path.to_string(),
229                expected: expected.clone(),
230                actual: Some(actual.clone()),
231            }),
232        }
233    }
234
235    let mut losses = Vec::new();
236    collect(expected, Some(actual), "$", &mut losses);
237    losses
238}
239
240fn dashboard_path(id: &str) -> String {
241    format!("{BASE}/{}", urlencode(id))
242}
243
244fn time_range_mode_path(value: &Value, path: &str) -> Option<String> {
245    match value {
246        Value::Object(map) => {
247            for (key, value) in map {
248                let child_path = format!("{path}.{}", json_path_key(key));
249                if key == "time_range" && value.get("mode").is_some() {
250                    return Some(format!("{child_path}.mode"));
251                }
252                if let Some(path) = time_range_mode_path(value, &child_path) {
253                    return Some(path);
254                }
255            }
256            None
257        }
258        Value::Array(values) => values
259            .iter()
260            .enumerate()
261            .find_map(|(index, value)| time_range_mode_path(value, &format!("{path}[{index}]"))),
262        _ => None,
263    }
264}
265
266fn json_path_key(key: &str) -> String {
267    if key.chars().enumerate().all(|(index, character)| {
268        character == '_'
269            || character.is_ascii_alphanumeric() && (index > 0 || !character.is_ascii_digit())
270    }) {
271        key.to_string()
272    } else {
273        format!("['{}']", key.replace('\\', "\\\\").replace('\'', "\\'"))
274    }
275}
276
277#[derive(Deserialize)]
278#[serde(deny_unknown_fields)]
279struct SearchEnvelope {
280    data: Vec<SearchDashboardRow>,
281    meta: SearchMeta,
282}
283
284#[derive(Deserialize)]
285#[serde(deny_unknown_fields)]
286struct SearchDashboardRow {
287    id: String,
288    data: SearchDashboardData,
289    #[serde(rename = "meta")]
290    _meta: Map<String, Value>,
291}
292
293#[derive(Deserialize)]
294struct SearchDashboardData {
295    title: String,
296    #[serde(default)]
297    description: Option<String>,
298    #[serde(default)]
299    tags: Option<Vec<String>>,
300    #[serde(flatten)]
301    _extra: Map<String, Value>,
302}
303
304#[derive(Deserialize)]
305#[serde(deny_unknown_fields)]
306struct SearchMeta {
307    page: u64,
308    per_page: u64,
309    total: u64,
310}
311
312#[derive(Deserialize)]
313#[serde(deny_unknown_fields)]
314struct DashboardEnvelope {
315    id: String,
316    data: Map<String, Value>,
317    meta: Map<String, Value>,
318    #[serde(default)]
319    warnings: Vec<DashboardWarning>,
320}
321
322fn decode_search(body: &Value) -> Result<DashboardPage> {
323    let response = decode_envelope::<SearchEnvelope>(body, "dashboard search")?;
324    let data = response
325        .data
326        .into_iter()
327        .map(|row| {
328            if row.id.trim().is_empty() {
329                return Err(Error::new(
330                    ErrorKind::Http,
331                    "decoding dashboard search: id must be a non-empty string",
332                ));
333            }
334            if row.data.title.trim().is_empty() {
335                return Err(Error::new(
336                    ErrorKind::Http,
337                    "decoding dashboard search: data.title must be a non-empty string",
338                ));
339            }
340            Ok(DashboardSummary {
341                id: row.id,
342                title: row.data.title,
343                description: row.data.description,
344                tags: row.data.tags,
345            })
346        })
347        .collect::<Result<Vec<_>>>()?;
348    Ok(DashboardPage {
349        data,
350        page: response.meta.page,
351        per_page: response.meta.per_page,
352        total: response.meta.total,
353    })
354}
355
356fn decode_dashboard(body: &Value, context: &str) -> Result<Dashboard> {
357    let response = decode_envelope::<DashboardEnvelope>(body, context)?;
358    if response.id.trim().is_empty() {
359        return Err(Error::new(
360            ErrorKind::Http,
361            format!("decoding {context}: id must be a non-empty string"),
362        ));
363    }
364    if !matches!(response.data.get("title"), Some(Value::String(title)) if !title.trim().is_empty())
365    {
366        return Err(Error::new(
367            ErrorKind::Http,
368            format!("decoding {context}: data.title must be a non-empty string"),
369        ));
370    }
371    Ok(Dashboard {
372        id: response.id,
373        data: response.data,
374        meta: response.meta,
375        warnings: response.warnings,
376    })
377}
378
379fn decode_envelope<T: DeserializeOwned>(body: &Value, context: &str) -> Result<T> {
380    serde_json::from_value(body.clone())
381        .map_err(|error| Error::new(ErrorKind::Http, format!("decoding {context}: {error}")))
382}