backstage_client/
client.rs

1use crate::error::{ClientError, Result};
2use log::{debug, error};
3use reqwest::{header::AUTHORIZATION, Client as ReqwestClient};
4use serde::de::DeserializeOwned;
5use std::collections::HashMap;
6use url::Url;
7
8/// A client for interacting with the Backstage API.
9pub struct BackstageClient {
10    base_url: String,
11    token: String,
12    client: ReqwestClient,
13}
14
15impl BackstageClient {
16    /// Creates a new instance of the Backstage client.
17    ///
18    /// # Arguments
19    ///
20    /// * `base_url` - The base URL of the Backstage API.
21    /// * `token` - The authentication token for accessing the API.
22    ///
23    /// # Returns
24    ///
25    /// A new instance of `BackstageClient`.
26    ///
27    /// # Errors
28    ///
29    /// Returns `ClientError::InvalidUrl` if the base URL is invalid.
30    pub fn new(base_url: &str, token: &str) -> Result<Self> {
31        // Validate base URL
32        let parsed_url = Url::parse(base_url)
33            .map_err(|_| ClientError::InvalidUrl(format!("Invalid base URL: {}", base_url)))?;
34
35        // Ensure URL has scheme
36        if parsed_url.scheme() != "http" && parsed_url.scheme() != "https" {
37            return Err(ClientError::InvalidUrl(
38                "URL must use http or https scheme".to_string(),
39            ));
40        }
41
42        // Validate token is not empty
43        if token.trim().is_empty() {
44            return Err(ClientError::Authentication(
45                "Token cannot be empty".to_string(),
46            ));
47        }
48
49        let client = ReqwestClient::builder()
50            .timeout(std::time::Duration::from_secs(30))
51            .build()
52            .map_err(ClientError::Http)?;
53
54        Ok(Self {
55            base_url: base_url.trim_end_matches('/').to_string(),
56            token: token.to_string(),
57            client,
58        })
59    }
60
61    /// Builds a request with the authorization header and proper error handling.
62    ///
63    /// # Arguments
64    ///
65    /// * `url` - The URL to send the request to.
66    ///
67    /// # Returns
68    ///
69    /// A `reqwest::RequestBuilder` with the authorization header set.
70    fn build_request(&self, url: &str) -> reqwest::RequestBuilder {
71        self.client
72            .get(url)
73            .header(AUTHORIZATION, format!("Bearer {}", self.token))
74            .header("Accept", "application/json")
75            .header("Content-Type", "application/json")
76    }
77
78    /// Validates filter parameters.
79    ///
80    /// # Arguments
81    ///
82    /// * `filters` - A map of filters to validate.
83    ///
84    /// # Returns
85    ///
86    /// `Ok(())` if filters are valid, otherwise `ClientError::InvalidFilter`.
87    fn validate_filters(filters: &HashMap<String, String>) -> Result<()> {
88        for (key, value) in filters {
89            if key.trim().is_empty() {
90                return Err(ClientError::InvalidFilter(
91                    "Filter key cannot be empty".to_string(),
92                ));
93            }
94            if value.trim().is_empty() {
95                return Err(ClientError::InvalidFilter(format!(
96                    "Filter value for key '{}' cannot be empty",
97                    key
98                )));
99            }
100        }
101        Ok(())
102    }
103
104    /// Constructs the appropriate query parameters for filtering.
105    ///
106    /// # Arguments
107    ///
108    /// * `filters` - A map of filters to apply to the request.
109    ///
110    /// # Returns
111    ///
112    /// A string representing the query parameters.
113    fn build_filter_query(filters: &Option<HashMap<String, String>>) -> Result<String> {
114        if let Some(filter_map) = filters {
115            Self::validate_filters(filter_map)?;
116
117            let filter_str: String = filter_map
118                .iter()
119                .map(|(key, value)| {
120                    format!(
121                        "{}={}",
122                        urlencoding::encode(key),
123                        urlencoding::encode(value)
124                    )
125                })
126                .collect::<Vec<String>>()
127                .join(",");
128
129            Ok(format!("filter={}", urlencoding::encode(&filter_str)))
130        } else {
131            Ok(String::new())
132        }
133    }
134
135    /// Fetches entities from the Backstage API based on filters.
136    ///
137    /// # Arguments
138    ///
139    /// * `filters` - Optional filters to apply to the request.
140    ///
141    /// # Returns
142    ///
143    /// A `Result` containing a vector of entities or a `ClientError`.
144    ///
145    /// # Examples
146    ///
147    /// ```rust,no_run
148    /// use backstage_client::{BackstageClient, entities::Entity};
149    /// use std::collections::HashMap;
150    ///
151    /// # #[tokio::main]
152    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
153    /// let client = BackstageClient::new("https://backstage.example.com", "token")?;
154    ///
155    /// // Fetch all entities
156    /// let entities = client.fetch_entities::<Entity>(None).await?;
157    ///
158    /// // Fetch only components
159    /// let mut filters = HashMap::new();
160    /// filters.insert("kind".to_string(), "Component".to_string());
161    /// let components = client.fetch_entities::<Entity>(Some(filters)).await?;
162    /// # Ok(())
163    /// # }
164    /// ```
165    pub async fn fetch_entities<T: DeserializeOwned>(
166        &self,
167        filters: Option<HashMap<String, String>>,
168    ) -> Result<Vec<T>> {
169        let mut url = format!("{}/api/catalog/entities", self.base_url);
170        let filter_query = Self::build_filter_query(&filters)?;
171
172        if !filter_query.is_empty() {
173            url = format!("{}?{}", url, filter_query);
174        }
175
176        debug!("Requesting Backstage API: {}", url);
177
178        let response = self.build_request(&url).send().await?;
179
180        // Check response status
181        if !response.status().is_success() {
182            let status = response.status();
183            let error_text = response
184                .text()
185                .await
186                .unwrap_or_else(|_| "Unknown error".to_string());
187
188            return Err(match status.as_u16() {
189                401 => ClientError::Authentication("Invalid or expired token".to_string()),
190                403 => ClientError::Authentication("Access forbidden".to_string()),
191                404 => ClientError::ApiError {
192                    status: 404,
193                    message: "API endpoint not found".to_string(),
194                },
195                _ => ClientError::ApiError {
196                    status: status.as_u16(),
197                    message: error_text,
198                },
199            });
200        }
201
202        let text = response.text().await?;
203        debug!("Raw response length: {} characters", text.len());
204
205        // Try to parse as JSON
206        let entities: Vec<T> = serde_json::from_str(&text).map_err(|e| {
207            error!("Failed to parse JSON response: {}", e);
208            debug!(
209                "Response text (first 500 chars): {}",
210                &text.chars().take(500).collect::<String>()
211            );
212            ClientError::Json(e)
213        })?;
214
215        debug!("Successfully parsed {} entities", entities.len());
216        Ok(entities)
217    }
218
219    /// Gets a specific entity by its compound key.
220    ///
221    /// # Arguments
222    ///
223    /// * `kind` - The kind of entity (e.g., "Component", "API").
224    /// * `namespace` - The namespace of the entity (optional, defaults to "default").
225    /// * `name` - The name of the entity.
226    ///
227    /// # Returns
228    ///
229    /// A `Result` containing the entity or a `ClientError`.
230    pub async fn get_entity<T: DeserializeOwned>(
231        &self,
232        kind: &str,
233        namespace: Option<&str>,
234        name: &str,
235    ) -> Result<T> {
236        if kind.trim().is_empty() {
237            return Err(ClientError::InvalidFilter(
238                "Kind cannot be empty".to_string(),
239            ));
240        }
241        if name.trim().is_empty() {
242            return Err(ClientError::InvalidFilter(
243                "Name cannot be empty".to_string(),
244            ));
245        }
246
247        let namespace = namespace.unwrap_or("default");
248        let entity_ref = format!("{}:{}/{}", kind.to_lowercase(), namespace, name);
249        let url = format!(
250            "{}/api/catalog/entities/by-name/{}",
251            self.base_url,
252            urlencoding::encode(&entity_ref)
253        );
254
255        debug!("Requesting entity: {}", url);
256
257        let response = self.build_request(&url).send().await?;
258
259        if !response.status().is_success() {
260            let status = response.status();
261            return Err(match status.as_u16() {
262                404 => ClientError::ApiError {
263                    status: 404,
264                    message: format!("Entity not found: {}", entity_ref),
265                },
266                _ => ClientError::ApiError {
267                    status: status.as_u16(),
268                    message: response
269                        .text()
270                        .await
271                        .unwrap_or_else(|_| "Unknown error".to_string()),
272                },
273            });
274        }
275
276        let text = response.text().await?;
277        let entity: T = serde_json::from_str(&text)?;
278
279        debug!("Successfully retrieved entity: {}", entity_ref);
280        Ok(entity)
281    }
282}
283
284impl std::fmt::Debug for BackstageClient {
285    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286        f.debug_struct("BackstageClient")
287            .field("base_url", &self.base_url)
288            .field("token", &"***") // Hide token for security
289            .finish()
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_new_client_valid_url() {
299        let client = BackstageClient::new("https://backstage.example.com", "valid_token");
300        assert!(client.is_ok());
301    }
302
303    #[test]
304    fn test_new_client_invalid_url() {
305        let client = BackstageClient::new("not-a-url", "valid_token");
306        assert!(client.is_err());
307        assert!(matches!(client.unwrap_err(), ClientError::InvalidUrl(_)));
308    }
309
310    #[test]
311    fn test_new_client_empty_token() {
312        let client = BackstageClient::new("https://backstage.example.com", "");
313        assert!(client.is_err());
314        assert!(matches!(
315            client.unwrap_err(),
316            ClientError::Authentication(_)
317        ));
318    }
319
320    #[test]
321    fn test_validate_filters_empty_key() {
322        let mut filters = HashMap::new();
323        filters.insert("".to_string(), "value".to_string());
324        let result = BackstageClient::validate_filters(&filters);
325        assert!(result.is_err());
326        assert!(matches!(result.unwrap_err(), ClientError::InvalidFilter(_)));
327    }
328
329    #[test]
330    fn test_validate_filters_empty_value() {
331        let mut filters = HashMap::new();
332        filters.insert("key".to_string(), "".to_string());
333        let result = BackstageClient::validate_filters(&filters);
334        assert!(result.is_err());
335        assert!(matches!(result.unwrap_err(), ClientError::InvalidFilter(_)));
336    }
337
338    #[test]
339    fn test_build_filter_query() {
340        let mut filters = HashMap::new();
341        filters.insert("kind".to_string(), "Component".to_string());
342        filters.insert("metadata.name".to_string(), "test-service".to_string());
343
344        let result = BackstageClient::build_filter_query(&Some(filters));
345        assert!(result.is_ok());
346        let query = result.unwrap();
347        assert!(query.contains("filter="));
348        assert!(query.contains("kind%3DComponent"));
349    }
350}