kibana-sync 0.2.2

Reusable Kibana sync library for saved objects, spaces, agents, tools, skills, and workflows
Documentation
//! Spaces API extractor
//!
//! Extracts space definitions from Kibana via GET /api/spaces/space

use crate::client::KibanaClient;
use crate::etl::Extractor;

use crate::{Error, Result, ResultContext};
use serde_json::Value;

/// Extractor for Kibana spaces
///
/// Fetches all spaces from Kibana and filters them based on the manifest.
/// If no manifest is provided, all spaces are extracted.
///
/// # Example
/// ```no_run
/// use kibana_sync::kibana::spaces::{SpaceEntry, SpacesExtractor, SpacesManifest};
/// use kibana_sync::client::{Auth, KibanaClient};
/// use kibana_sync::etl::Extractor;
/// use url::Url;
///
/// # async fn example() -> kibana_sync::Result<()> {
/// let url = Url::parse("http://localhost:5601")?;
/// let client = KibanaClient::new(url, Auth::None)?;
/// let manifest = SpacesManifest::with_spaces(vec![
///     SpaceEntry::new("default".to_string(), "Default".to_string()),
///     SpaceEntry::new("marketing".to_string(), "Marketing".to_string()),
/// ]);
///
/// let extractor = SpacesExtractor::new(client, Some(manifest));
/// let spaces = extractor.extract().await?;
/// # Ok(())
/// # }
/// ```
pub struct SpacesExtractor {
    client: KibanaClient,
    manifest: Option<super::SpacesManifest>,
}

impl SpacesExtractor {
    /// Create a new spaces extractor
    ///
    /// # Arguments
    /// * `client` - Kibana client (spaces are global, not space-scoped)
    /// * `manifest` - Optional manifest to filter which spaces to extract
    pub fn new(client: KibanaClient, manifest: Option<super::SpacesManifest>) -> Self {
        Self { client, manifest }
    }

    /// Create an extractor that fetches all spaces
    pub fn all(client: KibanaClient) -> Self {
        Self::new(client, None)
    }

    /// Search for spaces via the Spaces API
    ///
    /// Returns all spaces from Kibana. The Spaces API doesn't support
    /// server-side filtering, so use the `filter` parameter in the
    /// add command to filter by name.
    ///
    /// # Arguments
    /// * `_query` - Reserved for future use (Spaces API doesn't support search)
    ///
    /// # Returns
    /// Vector of space JSON objects
    pub async fn search_spaces(&self, _query: Option<&str>) -> Result<Vec<Value>> {
        // Spaces API doesn't support query filtering, so we fetch all
        // and let the caller filter by name if needed
        self.fetch_all_spaces().await
    }

    /// Fetch all spaces from Kibana
    async fn fetch_all_spaces(&self) -> Result<Vec<Value>> {
        let path = "/api/spaces/space";

        tracing::debug!("Fetching spaces from {}", path);

        let response = self
            .client
            .get(path)
            .await
            .with_context(|| "Failed to fetch spaces from Kibana")?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            return Err(Error::api_response(status, body));
        }

        let spaces: Vec<Value> = response
            .json()
            .await
            .with_context(|| "Failed to parse spaces response")?;

        tracing::info!("Fetched {} spaces from Kibana", spaces.len());

        Ok(spaces)
    }

    /// Fetch a single space by ID from Kibana
    pub async fn fetch_space(&self, space_id: &str) -> Result<Value> {
        let path = format!("/api/spaces/space/{}", space_id);

        tracing::debug!("Fetching space '{}' from {}", space_id, path);

        let response = self
            .client
            .get(&path)
            .await
            .with_context(|| format!("Failed to fetch space '{}'", space_id))?;

        if !response.status().is_success() {
            let status = response.status();
            let body = response.text().await.unwrap_or_default();
            return Err(Error::api_response(status, body));
        }

        let space: Value = response
            .json()
            .await
            .with_context(|| format!("Failed to parse space '{}' response", space_id))?;

        tracing::debug!("Fetched space: {}", space_id);

        Ok(space)
    }

    /// Fetch specific spaces by ID from manifest
    async fn fetch_manifest_spaces(&self, manifest: &super::SpacesManifest) -> Result<Vec<Value>> {
        let mut spaces = Vec::new();

        for entry in &manifest.spaces {
            match self.fetch_space(&entry.id).await {
                Ok(space) => spaces.push(space),
                Err(e) => {
                    tracing::warn!("Failed to fetch space '{}': {}", entry.id, e);
                    // Continue with other spaces instead of failing completely
                }
            }
        }

        tracing::info!("Fetched {} space(s) from manifest", spaces.len());

        Ok(spaces)
    }

    /// Filter spaces based on manifest
    fn filter_spaces(&self, spaces: Vec<Value>) -> Vec<Value> {
        if let Some(manifest) = &self.manifest {
            spaces
                .into_iter()
                .filter(|space| {
                    if let Some(id) = space.get("id").and_then(|v| v.as_str()) {
                        manifest.contains(id)
                    } else {
                        false
                    }
                })
                .collect()
        } else {
            spaces
        }
    }
}

impl Extractor for SpacesExtractor {
    type Item = Value;

    async fn extract(&self) -> Result<Vec<Self::Item>> {
        let spaces = if let Some(manifest) = &self.manifest {
            // Fetch only spaces from manifest by ID
            self.fetch_manifest_spaces(manifest).await?
        } else {
            // Fetch all spaces and filter
            let all_spaces = self.fetch_all_spaces().await?;
            self.filter_spaces(all_spaces)
        };

        tracing::info!(
            "Extracted {} space(s){}",
            spaces.len(),
            if self.manifest.is_some() {
                " (from manifest)"
            } else {
                ""
            }
        );

        Ok(spaces)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::client::Auth;
    use serde_json::json;
    use url::Url;

    #[test]
    fn test_filter_with_manifest() {
        let url = Url::parse("http://localhost:5601").unwrap();
        let client = KibanaClient::new(url, Auth::None).unwrap();
        let manifest = super::super::SpacesManifest::with_spaces(vec![
            super::super::SpaceEntry::new("default".to_string(), "Default".to_string()),
            super::super::SpaceEntry::new("marketing".to_string(), "Marketing".to_string()),
        ]);

        let extractor = SpacesExtractor::new(client, Some(manifest));

        let spaces = vec![
            json!({"id": "default", "name": "Default"}),
            json!({"id": "marketing", "name": "Marketing"}),
            json!({"id": "engineering", "name": "Engineering"}),
        ];

        let filtered = extractor.filter_spaces(spaces);
        assert_eq!(filtered.len(), 2);

        let ids: Vec<&str> = filtered
            .iter()
            .filter_map(|s| s.get("id").and_then(|v| v.as_str()))
            .collect();

        assert!(ids.contains(&"default"));
        assert!(ids.contains(&"marketing"));
        assert!(!ids.contains(&"engineering"));
    }

    #[test]
    fn test_filter_without_manifest() {
        let url = Url::parse("http://localhost:5601").unwrap();
        let client = KibanaClient::new(url, Auth::None).unwrap();
        let extractor = SpacesExtractor::all(client);

        let spaces = vec![
            json!({"id": "default", "name": "Default"}),
            json!({"id": "marketing", "name": "Marketing"}),
        ];

        let filtered = extractor.filter_spaces(spaces.clone());
        assert_eq!(filtered.len(), 2);
        assert_eq!(filtered, spaces);
    }
}