backstage_client/
client.rs1use 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
8pub struct BackstageClient {
10 base_url: String,
11 token: String,
12 client: ReqwestClient,
13}
14
15impl BackstageClient {
16 pub fn new(base_url: &str, token: &str) -> Result<Self> {
31 let parsed_url = Url::parse(base_url)
33 .map_err(|_| ClientError::InvalidUrl(format!("Invalid base URL: {}", base_url)))?;
34
35 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 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 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 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 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 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 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 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 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", &"***") .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}