/ src / client.rs
client.rs
  1  use crate::error::{ClientError, Result};
  2  use log::{debug, error};
  3  use reqwest::{header::AUTHORIZATION, Client as ReqwestClient};
  4  use serde::de::DeserializeOwned;
  5  use std::collections::HashMap;
  6  use url::Url;
  7  
  8  /// A client for interacting with the Backstage API.
  9  pub struct BackstageClient {
 10      base_url: String,
 11      token: String,
 12      client: ReqwestClient,
 13  }
 14  
 15  impl 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  
284  impl 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)]
294  mod 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  }