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;
pub struct BackstageClient {
base_url: String,
token: String,
client: ReqwestClient,
}
impl BackstageClient {
pub fn new(base_url: &str, token: &str) -> Result<Self> {
let parsed_url = Url::parse(base_url)
.map_err(|_| ClientError::InvalidUrl(format!("Invalid base URL: {}", base_url)))?;
if parsed_url.scheme() != "http" && parsed_url.scheme() != "https" {
return Err(ClientError::InvalidUrl(
"URL must use http or https scheme".to_string(),
));
}
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,
})
}
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")
}
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(())
}
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())
}
}
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?;
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());
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)
}
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", &"***") .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"));
}
}