Skip to main content

elasticctl_api/
data_views.rs

1//! Typed data-view models and public Kibana route wrappers.
2
3use elasticctl_core::{Error, ErrorKind, Result, Transport};
4use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned};
5use serde_json::{Map, Value, json};
6
7const BASE: &str = "/api/data_views";
8
9/// The portable, author-controlled data-view representation.
10#[derive(Debug, Clone, PartialEq, Serialize)]
11#[serde(rename_all = "camelCase", deny_unknown_fields)]
12pub struct DataViewSpec {
13    pub id: String,
14    pub title: String,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub name: Option<String>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub time_field_name: Option<String>,
19    #[serde(default)]
20    pub allow_no_index: bool,
21    #[serde(default)]
22    pub allow_hidden: bool,
23    #[serde(default, skip_serializing_if = "Vec::is_empty")]
24    pub source_filters: Vec<Value>,
25    #[serde(default, skip_serializing_if = "Map::is_empty")]
26    pub field_formats: Map<String, Value>,
27    #[serde(default, skip_serializing_if = "Map::is_empty")]
28    pub runtime_field_map: Map<String, Value>,
29    #[serde(default, skip_serializing_if = "Map::is_empty")]
30    pub field_attrs: Map<String, Value>,
31    #[serde(default, skip_serializing_if = "Map::is_empty")]
32    pub fields: Map<String, Value>,
33    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
34    pub view_type: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub type_meta: Option<Map<String, Value>>,
37}
38
39/// The deserialization form is deliberately separate from `DataViewSpec` so
40/// every construction path passes through the same validation and
41/// canonicalization boundary without recursive deserialization.
42#[derive(Deserialize)]
43#[serde(rename_all = "camelCase", deny_unknown_fields)]
44struct RawDataViewSpec {
45    id: String,
46    title: String,
47    #[serde(default)]
48    name: Option<String>,
49    #[serde(default)]
50    time_field_name: Option<String>,
51    #[serde(default)]
52    allow_no_index: bool,
53    #[serde(default)]
54    allow_hidden: bool,
55    #[serde(default)]
56    source_filters: Vec<Value>,
57    #[serde(default)]
58    field_formats: Map<String, Value>,
59    #[serde(default)]
60    runtime_field_map: Map<String, Value>,
61    #[serde(default)]
62    field_attrs: Map<String, Value>,
63    #[serde(default)]
64    fields: Map<String, Value>,
65    #[serde(rename = "type", default)]
66    view_type: Option<String>,
67    #[serde(default)]
68    type_meta: Option<Value>,
69}
70
71impl DataViewSpec {
72    /// Validate the local shape before applying this release's portability
73    /// capability rules. Deserialization uses this boundary so callers that
74    /// validate an artifact receive the stable Unsupported classification.
75    fn validate_shape(&self) -> Result<()> {
76        if self.id.trim().is_empty() {
77            return Err(Error::new(
78                ErrorKind::Error,
79                "data view id must not be empty",
80            ));
81        }
82        if self.title.trim().is_empty() {
83            return Err(Error::new(
84                ErrorKind::Error,
85                "data view title must not be empty",
86            ));
87        }
88        validate_object_values(&self.field_attrs, "fieldAttrs")?;
89        validate_scripted_fields(&self.fields)?;
90        Ok(())
91    }
92
93    /// Validate a portable 0.5.0 data-view artifact.
94    pub fn validate(&self) -> Result<()> {
95        self.validate_shape()?;
96        if !self.fields.is_empty() {
97            return Err(Error::new(
98                ErrorKind::Unsupported,
99                format!(
100                    "legacy scripted fields are unsupported: {}",
101                    sorted_field_names(&self.fields).join(", ")
102                ),
103            ));
104        }
105        Ok(())
106    }
107}
108
109impl DataViewSpec {
110    fn from_raw(raw: RawDataViewSpec) -> Result<Self> {
111        let type_meta = match raw.type_meta {
112            Some(Value::Object(map)) if map.is_empty() => None,
113            Some(Value::Object(map)) => Some(map),
114            Some(_) => return Err(Error::new(ErrorKind::Error, "typeMeta must be an object")),
115            None => None,
116        };
117        let spec = Self {
118            id: raw.id,
119            title: raw.title,
120            name: raw.name,
121            time_field_name: raw.time_field_name,
122            allow_no_index: raw.allow_no_index,
123            allow_hidden: raw.allow_hidden,
124            source_filters: raw.source_filters,
125            field_formats: raw.field_formats,
126            runtime_field_map: raw.runtime_field_map,
127            field_attrs: raw.field_attrs,
128            fields: raw.fields,
129            view_type: raw.view_type,
130            type_meta,
131        };
132        spec.validate_shape()?;
133        Ok(spec)
134    }
135}
136
137fn validate_object_values(values: &Map<String, Value>, path: &str) -> Result<()> {
138    for (name, value) in values {
139        if !value.is_object() {
140            return Err(Error::new(
141                ErrorKind::Error,
142                format!("{path}.{name} must be an object"),
143            ));
144        }
145    }
146    Ok(())
147}
148
149fn validate_scripted_fields(fields: &Map<String, Value>) -> Result<()> {
150    for (name, field) in fields {
151        let path = format!("fields.{name}");
152        let field = field
153            .as_object()
154            .ok_or_else(|| Error::new(ErrorKind::Error, format!("{path} must be an object")))?;
155        if field.get("scripted").and_then(Value::as_bool) != Some(true) {
156            return Err(Error::new(
157                ErrorKind::Error,
158                format!("{path}.scripted must be true"),
159            ));
160        }
161    }
162    Ok(())
163}
164
165fn sorted_field_names(fields: &Map<String, Value>) -> Vec<String> {
166    let mut names: Vec<_> = fields.keys().cloned().collect();
167    names.sort();
168    names
169}
170
171impl TryFrom<Value> for DataViewSpec {
172    type Error = Error;
173
174    fn try_from(value: Value) -> Result<Self> {
175        let spec: Self = serde_json::from_value(value).map_err(|error| {
176            Error::new(ErrorKind::Error, format!("decoding data view: {error}"))
177        })?;
178        spec.validate()?;
179        Ok(spec)
180    }
181}
182
183impl<'de> Deserialize<'de> for DataViewSpec {
184    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
185    where
186        D: Deserializer<'de>,
187    {
188        let raw = RawDataViewSpec::deserialize(deserializer)?;
189        Self::from_raw(raw).map_err(serde::de::Error::custom)
190    }
191}
192
193/// The documented partial-update fields. `None` omits a field from the body.
194#[derive(Debug, Clone, Default, PartialEq, Serialize)]
195#[serde(rename_all = "camelCase")]
196pub struct DataViewUpdate {
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub allow_no_index: Option<bool>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub field_formats: Option<Map<String, Value>>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub fields: Option<Map<String, Value>>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub name: Option<String>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub runtime_field_map: Option<Map<String, Value>>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub source_filters: Option<Vec<Value>>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub time_field_name: Option<String>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub title: Option<String>,
213    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
214    pub view_type: Option<String>,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub type_meta: Option<Map<String, Value>>,
217}
218
219/// A list row returned by Kibana.
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
221#[serde(rename_all = "camelCase")]
222pub struct DataViewSummary {
223    pub id: String,
224    pub title: String,
225    #[serde(default)]
226    pub name: Option<String>,
227    #[serde(default)]
228    pub time_field_name: Option<String>,
229}
230
231/// A data view returned from its detail, create, or update route.
232#[derive(Debug, Clone, PartialEq)]
233pub struct DataView {
234    /// The complete object returned by Kibana. It includes server-owned
235    /// fields and generated mapped-field cache entries, which Task 2 removes
236    /// only when building a portable `DataViewSpec`.
237    pub data_view: Map<String, Value>,
238}
239
240/// A saved object that refers to a data view.
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(deny_unknown_fields)]
243pub struct DataViewReference {
244    pub id: String,
245    #[serde(rename = "type")]
246    pub object_type: String,
247}
248
249/// The delete result returned by a reference swap.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
251#[serde(rename_all = "camelCase", deny_unknown_fields)]
252pub struct DeleteStatus {
253    pub delete_performed: bool,
254    pub remaining_refs: u64,
255}
256
257/// The affected references and deletion status from a reference swap.
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub struct ReferenceSwap {
260    pub result: Vec<DataViewReference>,
261    pub delete_status: DeleteStatus,
262}
263
264/// List all data views in the active space.
265pub async fn list(transport: &Transport) -> Result<Vec<DataViewSummary>> {
266    let body = transport.get(BASE).await?;
267    decode_envelope::<ListEnvelope>(&body, "data views list").map(|envelope| envelope.data_view)
268}
269
270/// Get one data view by its stable id.
271pub async fn get(transport: &Transport, id: &str) -> Result<DataView> {
272    decode_data_view(&transport.get(&data_view_path(id)).await?, "data view get")
273}
274
275/// Create a data view with its explicit, portable id.
276pub async fn create(transport: &Transport, spec: &DataViewSpec) -> Result<DataView> {
277    spec.validate()?;
278    let body = json!({"data_view": spec, "override": false});
279    decode_data_view(
280        &transport
281            .post(&format!("{BASE}/data_view"), Some(&body))
282            .await?,
283        "data view create",
284    )
285}
286
287/// Apply the documented partial data-view update.
288pub async fn update(transport: &Transport, id: &str, update: &DataViewUpdate) -> Result<DataView> {
289    if let Some(fields) = &update.fields {
290        validate_scripted_fields(fields)?;
291        if !fields.is_empty() {
292            return Err(Error::new(
293                ErrorKind::Unsupported,
294                format!(
295                    "legacy scripted fields are unsupported: {}",
296                    sorted_field_names(fields).join(", ")
297                ),
298            ));
299        }
300    }
301    let body = json!({"data_view": update, "refresh_fields": true});
302    decode_data_view(
303        &transport.post(&data_view_path(id), Some(&body)).await?,
304        "data view update",
305    )
306}
307
308/// Update the field metadata delta for one data view.
309pub async fn update_fields_metadata(
310    transport: &Transport,
311    id: &str,
312    fields: &Map<String, Value>,
313) -> Result<()> {
314    let body = json!({"fields": fields});
315    let response = transport
316        .post(&format!("{}/fields", data_view_path(id)), Some(&body))
317        .await?;
318    decode_field_metadata_success(&response, id)
319}
320
321/// Delete one unreferenced data view.
322pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
323    decode_delete_success(&transport.delete(&data_view_path(id)).await?)
324}
325
326/// Get the active space's default data-view id, if one is set.
327pub async fn get_default(transport: &Transport) -> Result<Option<String>> {
328    let body = transport.get(&format!("{BASE}/default")).await?;
329    match decode_envelope::<DefaultEnvelope>(&body, "data view default")?.data_view_id {
330        Value::String(id) if id.is_empty() => Ok(None),
331        Value::String(id) => Ok(Some(id)),
332        Value::Null => Ok(None),
333        _ => Err(Error::new(
334            ErrorKind::Http,
335            "decoding data view default field `data_view_id`: expected string or null",
336        )),
337    }
338}
339
340/// Set a default data view, or clear it when `id` is `None`.
341pub async fn set_default(transport: &Transport, id: Option<&str>) -> Result<()> {
342    if matches!(id, Some(value) if value.trim().is_empty()) {
343        return Err(Error::new(
344            ErrorKind::Error,
345            "data view default id must not be empty",
346        ));
347    }
348    let body = json!({"data_view_id": id, "force": true});
349    decode_acknowledged(
350        &transport
351            .post(&format!("{BASE}/default"), Some(&body))
352            .await?,
353        "data view default set",
354    )
355}
356
357/// Preview the saved-object references a swap would change.
358pub async fn preview_swap(
359    transport: &Transport,
360    from_id: &str,
361    to_id: &str,
362) -> Result<Vec<DataViewReference>> {
363    let body = json!({"fromId": from_id, "toId": to_id});
364    decode_references(
365        &transport
366            .post(&format!("{BASE}/swap_references/_preview"), Some(&body))
367            .await?,
368        "data view reference swap preview",
369    )
370}
371
372/// Replace references and delete the source data view.
373pub async fn swap(transport: &Transport, from_id: &str, to_id: &str) -> Result<ReferenceSwap> {
374    let body = json!({"delete": true, "fromId": from_id, "toId": to_id});
375    let response = transport
376        .post(&format!("{BASE}/swap_references"), Some(&body))
377        .await?;
378    let envelope = decode_envelope::<SwapEnvelope>(&response, "data view reference swap")?;
379    Ok(ReferenceSwap {
380        result: envelope.result,
381        delete_status: envelope.delete_status,
382    })
383}
384
385fn data_view_path(id: &str) -> String {
386    format!("{BASE}/data_view/{}", elasticctl_core::urlencode(id))
387}
388
389fn decode_data_view(body: &Value, context: &str) -> Result<DataView> {
390    let envelope = decode_envelope::<DataViewEnvelope>(body, context)?;
391    Ok(DataView {
392        data_view: envelope.data_view,
393    })
394}
395
396fn decode_acknowledged(body: &Value, context: &str) -> Result<()> {
397    if decode_envelope::<AcknowledgedEnvelope>(body, context)?.acknowledged {
398        Ok(())
399    } else {
400        Err(Error::new(
401            ErrorKind::Http,
402            format!("decoding {context} field `acknowledged`: expected true"),
403        ))
404    }
405}
406
407fn decode_field_metadata_success(body: &Value, requested_id: &str) -> Result<()> {
408    let Value::Object(values) = body else {
409        return Err(Error::new(
410            ErrorKind::Http,
411            "decoding data view field metadata update: expected acknowledged or data_view envelope",
412        ));
413    };
414    if values.len() != 1 {
415        return Err(Error::new(
416            ErrorKind::Http,
417            "decoding data view field metadata update: expected exactly one success envelope key",
418        ));
419    }
420    if values.get("acknowledged") == Some(&Value::Bool(true)) {
421        return Ok(());
422    }
423    let Some(Value::Object(data_view)) = values.get("data_view") else {
424        return Err(Error::new(
425            ErrorKind::Http,
426            "decoding data view field metadata update: expected acknowledged: true or data_view object",
427        ));
428    };
429    match data_view.get("id") {
430        Some(Value::String(id)) if !id.is_empty() && id == requested_id => Ok(()),
431        _ => Err(Error::new(
432            ErrorKind::Http,
433            "decoding data view field metadata update: data_view id must be a non-empty request-matching string",
434        )),
435    }
436}
437
438fn decode_delete_success(body: &Value) -> Result<()> {
439    match body {
440        Value::Null => Ok(()),
441        Value::Object(_) => decode_acknowledged(body, "data view delete"),
442        _ => Err(Error::new(
443            ErrorKind::Http,
444            "decoding data view delete: expected an empty response body or acknowledged: true",
445        )),
446    }
447}
448
449fn decode_references(body: &Value, context: &str) -> Result<Vec<DataViewReference>> {
450    decode_envelope::<ReferencePreviewEnvelope>(body, context).map(|envelope| envelope.result)
451}
452
453#[derive(Deserialize)]
454#[serde(deny_unknown_fields)]
455struct ListEnvelope {
456    data_view: Vec<DataViewSummary>,
457}
458
459#[derive(Deserialize)]
460#[serde(deny_unknown_fields)]
461struct DataViewEnvelope {
462    data_view: Map<String, Value>,
463}
464
465#[derive(Deserialize)]
466#[serde(deny_unknown_fields)]
467struct AcknowledgedEnvelope {
468    acknowledged: bool,
469}
470
471#[derive(Deserialize)]
472#[serde(deny_unknown_fields)]
473struct DefaultEnvelope {
474    data_view_id: Value,
475}
476
477#[derive(Deserialize)]
478#[serde(deny_unknown_fields)]
479struct ReferencePreviewEnvelope {
480    result: Vec<DataViewReference>,
481}
482
483#[derive(Deserialize)]
484#[serde(rename_all = "camelCase", deny_unknown_fields)]
485struct SwapEnvelope {
486    result: Vec<DataViewReference>,
487    delete_status: DeleteStatus,
488}
489
490fn decode_envelope<T: DeserializeOwned>(body: &Value, context: &str) -> Result<T> {
491    serde_json::from_value(body.clone())
492        .map_err(|error| Error::new(ErrorKind::Http, format!("decoding {context}: {error}")))
493}