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 }