backstage-client 0.1.4

A Rust client library for interacting with the Backstage Catalog API. Provides type-safe access to Backstage entities with async support, filtering, and comprehensive error handling.
Documentation
use crate::error::{ClientError, Result};
use log::{debug, error};
use reqwest::{header::AUTHORIZATION, Client as ReqwestClient};
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use url::Url;

/// A client for interacting with the Backstage API.
pub struct BackstageClient {
    base_url: String,
    token: String,
    client: ReqwestClient,
}

impl BackstageClient {
    /// Creates a new instance of the Backstage client.
    ///
    /// # Arguments
    ///
    /// * `base_url` - The base URL of the Backstage API.
    /// * `token` - The authentication token for accessing the API.
    ///
    /// # Returns
    ///
    /// A new instance of `BackstageClient`.
    ///
    /// # Errors
    ///
    /// Returns `ClientError::InvalidUrl` if the base URL is invalid.
    pub fn new(base_url: &str, token: &str) -> Result<Self> {
        // Validate base URL
        let parsed_url = Url::parse(base_url)
            .map_err(|_| ClientError::InvalidUrl(format!("Invalid base URL: {}", base_url)))?;

        // Ensure URL has scheme
        if parsed_url.scheme() != "http" && parsed_url.scheme() != "https" {
            return Err(ClientError::InvalidUrl(
                "URL must use http or https scheme".to_string(),
            ));
        }

        // Validate token is not empty
        if token.trim().is_empty() {
            return Err(ClientError::Authentication(
                "Token cannot be empty".to_string(),
            ));
        }

        let client = ReqwestClient::builder()
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .map_err(ClientError::Http)?;

        Ok(Self {
            base_url: base_url.trim_end_matches('/').to_string(),
            token: token.to_string(),
            client,
        })
    }

    /// Builds a request with the authorization header and proper error handling.
    ///
    /// # Arguments
    ///
    /// * `url` - The URL to send the request to.
    ///
    /// # Returns
    ///
    /// A `reqwest::RequestBuilder` with the authorization header set.
    fn build_request(&self, url: &str) -> reqwest::RequestBuilder {
        self.client
            .get(url)
            .header(AUTHORIZATION, format!("Bearer {}", self.token))
            .header("Accept", "application/json")
            .header("Content-Type", "application/json")
    }

    /// Validates filter parameters.
    ///
    /// # Arguments
    ///
    /// * `filters` - A map of filters to validate.
    ///
    /// # Returns
    ///
    /// `Ok(())` if filters are valid, otherwise `ClientError::InvalidFilter`.
    fn validate_filters(filters: &HashMap<String, String>) -> Result<()> {
        for (key, value) in filters {
            if key.trim().is_empty() {
                return Err(ClientError::InvalidFilter(
                    "Filter key cannot be empty".to_string(),
                ));
            }
            if value.trim().is_empty() {
                return Err(ClientError::InvalidFilter(format!(
                    "Filter value for key '{}' cannot be empty",
                    key
                )));
            }
        }
        Ok(())
    }

    /// Constructs the appropriate query parameters for filtering.
    ///
    /// # Arguments
    ///
    /// * `filters` - A map of filters to apply to the request.
    ///
    /// # Returns
    ///
    /// A string representing the query parameters.
    fn build_filter_query(filters: &Option<HashMap<String, String>>) -> Result<String> {
        if let Some(filter_map) = filters {
            Self::validate_filters(filter_map)?;

            let filter_str: String = filter_map
                .iter()
                .map(|(key, value)| {
                    format!(
                        "{}={}",
                        urlencoding::encode(key),
                        urlencoding::encode(value)
                    )
                })
                .collect::<Vec<String>>()
                .join(",");

            Ok(format!("filter={}", urlencoding::encode(&filter_str)))
        } else {
            Ok(String::new())
        }
    }

    /// Fetches entities from the Backstage API based on filters.
    ///
    /// # Arguments
    ///
    /// * `filters` - Optional filters to apply to the request.
    ///
    /// # Returns
    ///
    /// A `Result` containing a vector of entities or a `ClientError`.
    ///
    /// # Examples
    ///
    /// ```rust,no_run
    /// use backstage_client::{BackstageClient, entities::Entity};
    /// use std::collections::HashMap;
    ///
    /// # #[tokio::main]
    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// let client = BackstageClient::new("https://backstage.example.com", "token")?;
    ///
    /// // Fetch all entities
    /// let entities = client.fetch_entities::<Entity>(None).await?;
    ///
    /// // Fetch only components
    /// let mut filters = HashMap::new();
    /// filters.insert("kind".to_string(), "Component".to_string());
    /// let components = client.fetch_entities::<Entity>(Some(filters)).await?;
    /// # Ok(())
    /// # }
    /// ```
    pub async fn fetch_entities<T: DeserializeOwned>(
        &self,
        filters: Option<HashMap<String, String>>,
    ) -> Result<Vec<T>> {
        let mut url = format!("{}/api/catalog/entities", self.base_url);
        let filter_query = Self::build_filter_query(&filters)?;

        if !filter_query.is_empty() {
            url = format!("{}?{}", url, filter_query);
        }

        debug!("Requesting Backstage API: {}", url);

        let response = self.build_request(&url).send().await?;

        // Check response status
        if !response.status().is_success() {
            let status = response.status();
            let error_text = response
                .text()
                .await
                .unwrap_or_else(|_| "Unknown error".to_string());

            return Err(match status.as_u16() {
                401 => ClientError::Authentication("Invalid or expired token".to_string()),
                403 => ClientError::Authentication("Access forbidden".to_string()),
                404 => ClientError::ApiError {
                    status: 404,
                    message: "API endpoint not found".to_string(),
                },
                _ => ClientError::ApiError {
                    status: status.as_u16(),
                    message: error_text,
                },
            });
        }

        let text = response.text().await?;
        debug!("Raw response length: {} characters", text.len());

        // Try to parse as JSON
        let entities: Vec<T> = serde_json::from_str(&text).map_err(|e| {
            error!("Failed to parse JSON response: {}", e);
            debug!(
                "Response text (first 500 chars): {}",
                &text.chars().take(500).collect::<String>()
            );
            ClientError::Json(e)
        })?;

        debug!("Successfully parsed {} entities", entities.len());
        Ok(entities)
    }

    /// Gets a specific entity by its compound key.
    ///
    /// # Arguments
    ///
    /// * `kind` - The kind of entity (e.g., "Component", "API").
    /// * `namespace` - The namespace of the entity (optional, defaults to "default").
    /// * `name` - The name of the entity.
    ///
    /// # Returns
    ///
    /// A `Result` containing the entity or a `ClientError`.
    pub async fn get_entity<T: DeserializeOwned>(
        &self,
        kind: &str,
        namespace: Option<&str>,
        name: &str,
    ) -> Result<T> {
        if kind.trim().is_empty() {
            return Err(ClientError::InvalidFilter(
                "Kind cannot be empty".to_string(),
            ));
        }
        if name.trim().is_empty() {
            return Err(ClientError::InvalidFilter(
                "Name cannot be empty".to_string(),
            ));
        }

        let namespace = namespace.unwrap_or("default");
        let entity_ref = format!("{}:{}/{}", kind.to_lowercase(), namespace, name);
        let url = format!(
            "{}/api/catalog/entities/by-name/{}",
            self.base_url,
            urlencoding::encode(&entity_ref)
        );

        debug!("Requesting entity: {}", url);

        let response = self.build_request(&url).send().await?;

        if !response.status().is_success() {
            let status = response.status();
            return Err(match status.as_u16() {
                404 => ClientError::ApiError {
                    status: 404,
                    message: format!("Entity not found: {}", entity_ref),
                },
                _ => ClientError::ApiError {
                    status: status.as_u16(),
                    message: response
                        .text()
                        .await
                        .unwrap_or_else(|_| "Unknown error".to_string()),
                },
            });
        }

        let text = response.text().await?;
        let entity: T = serde_json::from_str(&text)?;

        debug!("Successfully retrieved entity: {}", entity_ref);
        Ok(entity)
    }
}

impl std::fmt::Debug for BackstageClient {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("BackstageClient")
            .field("base_url", &self.base_url)
            .field("token", &"***") // Hide token for security
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_new_client_valid_url() {
        let client = BackstageClient::new("https://backstage.example.com", "valid_token");
        assert!(client.is_ok());
    }

    #[test]
    fn test_new_client_invalid_url() {
        let client = BackstageClient::new("not-a-url", "valid_token");
        assert!(client.is_err());
        assert!(matches!(client.unwrap_err(), ClientError::InvalidUrl(_)));
    }

    #[test]
    fn test_new_client_empty_token() {
        let client = BackstageClient::new("https://backstage.example.com", "");
        assert!(client.is_err());
        assert!(matches!(
            client.unwrap_err(),
            ClientError::Authentication(_)
        ));
    }

    #[test]
    fn test_validate_filters_empty_key() {
        let mut filters = HashMap::new();
        filters.insert("".to_string(), "value".to_string());
        let result = BackstageClient::validate_filters(&filters);
        assert!(result.is_err());
        assert!(matches!(result.unwrap_err(), ClientError::InvalidFilter(_)));
    }

    #[test]
    fn test_validate_filters_empty_value() {
        let mut filters = HashMap::new();
        filters.insert("key".to_string(), "".to_string());
        let result = BackstageClient::validate_filters(&filters);
        assert!(result.is_err());
        assert!(matches!(result.unwrap_err(), ClientError::InvalidFilter(_)));
    }

    #[test]
    fn test_build_filter_query() {
        let mut filters = HashMap::new();
        filters.insert("kind".to_string(), "Component".to_string());
        filters.insert("metadata.name".to_string(), "test-service".to_string());

        let result = BackstageClient::build_filter_query(&Some(filters));
        assert!(result.is_ok());
        let query = result.unwrap();
        assert!(query.contains("filter="));
        assert!(query.contains("kind%3DComponent"));
    }
}