Skip to main content

elasticctl_api/
data_views.rs

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