use crate::api::types::*;
use crate::config::Config;
use crate::utils::error::{CarpError, CarpResult};
use reqwest::{Client, ClientBuilder, Response};
use std::time::Duration;
use tokio::time::sleep;
#[derive(Debug, Clone)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_delay: Duration,
pub max_delay: Duration,
pub backoff_multiplier: f64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_delay: Duration::from_millis(100),
max_delay: Duration::from_secs(5),
backoff_multiplier: 2.0,
}
}
}
pub struct ApiClient {
client: Client,
base_url: String,
api_key: Option<String>,
retry_config: RetryConfig,
}
impl ApiClient {
pub fn new(config: &Config) -> CarpResult<Self> {
Self::with_retry_config(config, RetryConfig::default())
}
pub fn with_retry_config(config: &Config, mut retry_config: RetryConfig) -> CarpResult<Self> {
retry_config.max_retries = config.retry.max_retries;
retry_config.initial_delay = Duration::from_millis(config.retry.initial_delay_ms);
retry_config.max_delay = Duration::from_millis(config.retry.max_delay_ms);
retry_config.backoff_multiplier = config.retry.backoff_multiplier;
let client = ClientBuilder::new()
.timeout(Duration::from_secs(config.timeout))
.user_agent(format!("carp-cli/{}", env!("CARGO_PKG_VERSION")))
.danger_accept_invalid_certs(!config.verify_ssl)
.connect_timeout(Duration::from_secs(10))
.tcp_keepalive(Duration::from_secs(60))
.pool_idle_timeout(Duration::from_secs(90))
.pool_max_idle_per_host(8)
.build()?;
if config.registry_url.is_empty() {
return Err(CarpError::Config(
"Registry URL cannot be empty".to_string(),
));
}
let base_url = config.registry_url.trim_end_matches('/');
Ok(Self {
client,
base_url: base_url.to_string(),
api_key: config.api_key.clone(),
retry_config,
})
}
pub fn with_api_key(mut self, api_key: Option<String>) -> Self {
if let Some(ref key) = api_key {
if let Err(e) = crate::config::ConfigManager::validate_api_key(key) {
eprintln!("Warning: Invalid API key format: {e}");
}
}
self.api_key = api_key;
self
}
pub async fn search(
&self,
query: &str,
limit: Option<usize>,
exact: bool,
) -> CarpResult<SearchResponse> {
let url = format!("{}/api/v1/agents/search", self.base_url);
let mut params = vec![];
if !query.trim().is_empty() {
params.push(("q", query.trim()));
}
let limit_str;
if let Some(limit) = limit {
if limit == 0 {
return Err(CarpError::InvalidAgent(
"Limit must be greater than 0".to_string(),
));
}
limit_str = limit.to_string();
params.push(("limit", &limit_str));
}
if exact {
params.push(("exact", "true"));
}
self.make_request_with_retry(|| async {
let response = self.client.get(&url).query(¶ms).send().await?;
self.handle_response(response).await
})
.await
}
#[allow(dead_code)]
pub async fn get_agent_download(
&self,
name: &str,
version: Option<&str>,
) -> CarpResult<AgentDownload> {
self.validate_agent_name(name)?;
let version = version.unwrap_or("latest");
if !version.is_empty() && version != "latest" {
self.validate_version(version)?;
}
let url = format!(
"{}/api/v1/agents/{}/{}/download",
self.base_url,
urlencoding::encode(name),
urlencoding::encode(version)
);
self.make_request_with_retry(|| async {
let response = self.client.get(&url).send().await?;
self.handle_response(response).await
})
.await
}
#[allow(dead_code)]
pub async fn download_agent(&self, download_url: &str) -> CarpResult<bytes::Bytes> {
if download_url.is_empty() {
return Err(CarpError::Network(
"Download URL cannot be empty".to_string(),
));
}
let parsed_url = download_url
.parse::<reqwest::Url>()
.map_err(|_| CarpError::Network("Invalid download URL format".to_string()))?;
if parsed_url.scheme() != "https" && parsed_url.scheme() != "http" {
return Err(CarpError::Network(
"Download URLs must use HTTP or HTTPS".to_string(),
));
}
if parsed_url.scheme() == "http" {
return Err(CarpError::Network(
"HTTP download URLs are not allowed for security reasons".to_string(),
));
}
self.make_request_with_retry(|| async {
let response = self.client.get(download_url).send().await?;
if !response.status().is_success() {
return Err(CarpError::Api {
status: response.status().as_u16(),
message: format!("Failed to download agent: HTTP {}", response.status()),
});
}
if let Some(content_length) = response.content_length() {
const MAX_DOWNLOAD_SIZE: u64 = 100 * 1024 * 1024; if content_length > MAX_DOWNLOAD_SIZE {
return Err(CarpError::Network(format!(
"Download size ({content_length} bytes) exceeds maximum allowed size ({MAX_DOWNLOAD_SIZE} bytes)"
)));
}
}
let bytes = response.bytes().await?;
Ok(bytes)
}).await
}
pub async fn upload(&self, request: UploadAgentRequest) -> CarpResult<UploadAgentResponse> {
let api_key = self.api_key.as_ref().ok_or_else(|| {
CarpError::Auth("No API key configured. Please set your API key via command line, environment variable, or config file.".to_string())
})?;
self.validate_upload_request(&request)?;
let url = format!("{}/api/v1/agents/upload", self.base_url);
self.make_request_with_retry(|| async {
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.header("Content-Type", "application/json")
.json(&request)
.send()
.await?;
self.handle_response(response).await
})
.await
}
#[allow(dead_code)]
pub async fn publish(
&self,
_request: PublishRequest,
_content: Vec<u8>,
) -> CarpResult<PublishResponse> {
Err(CarpError::Api {
status: 503,
message: "Publishing is temporarily disabled pending security hardening. Please check back later.".to_string(),
})
}
#[allow(dead_code)]
async fn publish_internal(
&self,
request: PublishRequest,
content: Vec<u8>,
) -> CarpResult<PublishResponse> {
let api_key = self.api_key.as_ref().ok_or_else(|| {
CarpError::Auth("No API key configured. Please set your API key via command line, environment variable, or config file.".to_string())
})?;
self.validate_publish_request(&request)?;
const MAX_PUBLISH_SIZE: usize = 50 * 1024 * 1024;
if content.len() > MAX_PUBLISH_SIZE {
return Err(CarpError::Api {
status: 413,
message: format!(
"Agent package size ({} bytes) exceeds maximum allowed size ({} bytes)",
content.len(),
MAX_PUBLISH_SIZE
),
});
}
let url = format!("{}/api/v1/agents/publish", self.base_url);
let form = reqwest::multipart::Form::new()
.text("metadata", serde_json::to_string(&request)?)
.part(
"content",
reqwest::multipart::Part::bytes(content)
.file_name("agent.zip")
.mime_str("application/zip")?,
);
let response = self
.client
.post(&url)
.header("Authorization", format!("Bearer {api_key}"))
.multipart(form)
.send()
.await?;
self.handle_response(response).await
}
#[allow(dead_code)]
pub async fn authenticate(&self, username: &str, password: &str) -> CarpResult<AuthResponse> {
if username.trim().is_empty() {
return Err(CarpError::Auth("Username cannot be empty".to_string()));
}
if password.is_empty() {
return Err(CarpError::Auth("Password cannot be empty".to_string()));
}
let url = format!("{}/api/v1/auth/login", self.base_url);
let request = AuthRequest {
username: username.trim().to_string(),
password: password.to_string(),
};
let response = self.client.post(&url).json(&request).send().await?;
self.handle_response(response).await
}
pub async fn health_check(&self) -> CarpResult<HealthResponse> {
let url = format!("{}/api/health", self.base_url);
let mut attempts = 0;
let max_attempts = 2;
loop {
attempts += 1;
match self.client.get(&url).send().await {
Ok(response) => return self.handle_response(response).await,
Err(e) if attempts < max_attempts && self.is_retryable_error(&e) => {
sleep(Duration::from_millis(500)).await;
continue;
}
Err(e) => return Err(CarpError::from(e)),
}
}
}
async fn make_request_with_retry<T, F, Fut>(&self, request_fn: F) -> CarpResult<T>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = CarpResult<T>>,
{
let mut attempts = 0;
let mut delay = self.retry_config.initial_delay;
loop {
attempts += 1;
match request_fn().await {
Ok(result) => return Ok(result),
Err(e) if attempts <= self.retry_config.max_retries && self.should_retry(&e) => {
if attempts < self.retry_config.max_retries {
sleep(delay).await;
delay = std::cmp::min(
Duration::from_millis(
(delay.as_millis() as f64 * self.retry_config.backoff_multiplier)
as u64,
),
self.retry_config.max_delay,
);
} else {
return Err(e);
}
}
Err(e) => return Err(e),
}
}
}
fn should_retry(&self, error: &CarpError) -> bool {
match error {
CarpError::Http(e) => self.is_retryable_error(e),
CarpError::Api { status, .. } => {
(500..600).contains(status) ||
*status == 429 || *status == 408 }
CarpError::Network(_) => true,
_ => false,
}
}
fn is_retryable_error(&self, error: &reqwest::Error) -> bool {
if error.is_timeout() || error.is_connect() {
return true;
}
if let Some(status) = error.status() {
let status_code = status.as_u16();
return (500..600).contains(&status_code) || status_code == 429 || status_code == 408;
}
false
}
fn validate_agent_name(&self, name: &str) -> CarpResult<()> {
if name.trim().is_empty() {
return Err(CarpError::InvalidAgent(
"Agent name cannot be empty".to_string(),
));
}
if !name
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Err(CarpError::InvalidAgent(
"Agent name can only contain alphanumeric characters, hyphens, and underscores"
.to_string(),
));
}
if name.len() > 100 {
return Err(CarpError::InvalidAgent(
"Agent name cannot exceed 100 characters".to_string(),
));
}
Ok(())
}
fn validate_version(&self, version: &str) -> CarpResult<()> {
if version.trim().is_empty() {
return Err(CarpError::InvalidAgent(
"Version cannot be empty".to_string(),
));
}
if !version
.chars()
.all(|c| c.is_alphanumeric() || ".-_+".contains(c))
{
return Err(CarpError::InvalidAgent(
"Version can only contain alphanumeric characters, dots, hyphens, underscores, and plus signs".to_string()
));
}
if version.len() > 50 {
return Err(CarpError::InvalidAgent(
"Version cannot exceed 50 characters".to_string(),
));
}
Ok(())
}
fn validate_upload_request(&self, request: &UploadAgentRequest) -> CarpResult<()> {
self.validate_agent_name(&request.name)?;
if request.description.trim().is_empty() {
return Err(CarpError::InvalidAgent(
"Description cannot be empty".to_string(),
));
}
if request.description.len() > 1000 {
return Err(CarpError::InvalidAgent(
"Description cannot exceed 1000 characters".to_string(),
));
}
if request.content.trim().is_empty() {
return Err(CarpError::InvalidAgent(
"Content cannot be empty".to_string(),
));
}
const MAX_CONTENT_SIZE: usize = 1024 * 1024;
if request.content.len() > MAX_CONTENT_SIZE {
return Err(CarpError::InvalidAgent(format!(
"Content size ({} bytes) exceeds maximum allowed size ({} bytes)",
request.content.len(),
MAX_CONTENT_SIZE
)));
}
self.validate_frontmatter_consistency(request)?;
if let Some(version) = &request.version {
self.validate_version(version)?;
}
for tag in &request.tags {
if tag.trim().is_empty() {
return Err(CarpError::InvalidAgent("Tags cannot be empty".to_string()));
}
if tag.len() > 50 {
return Err(CarpError::InvalidAgent(
"Tags cannot exceed 50 characters".to_string(),
));
}
}
if request.tags.len() > 20 {
return Err(CarpError::InvalidAgent(
"Cannot have more than 20 tags".to_string(),
));
}
Ok(())
}
fn validate_frontmatter_consistency(&self, request: &UploadAgentRequest) -> CarpResult<()> {
if !request.content.starts_with("---") {
return Err(CarpError::InvalidAgent(
"Content must contain YAML frontmatter starting with ---".to_string(),
));
}
let lines: Vec<&str> = request.content.lines().collect();
let mut frontmatter_end = None;
for (i, line) in lines.iter().enumerate().skip(1) {
if line.trim() == "---" {
frontmatter_end = Some(i);
break;
}
}
let frontmatter_end = frontmatter_end.ok_or_else(|| {
CarpError::InvalidAgent("Invalid YAML frontmatter: missing closing ---".to_string())
})?;
let frontmatter_lines = &lines[1..frontmatter_end];
let frontmatter_content = frontmatter_lines.join("\n");
let frontmatter: serde_json::Value = serde_yaml::from_str(&frontmatter_content)
.map_err(|e| CarpError::InvalidAgent(format!("Invalid YAML frontmatter: {e}")))?;
if let Some(frontmatter_name) = frontmatter.get("name").and_then(|v| v.as_str()) {
if frontmatter_name != request.name {
return Err(CarpError::InvalidAgent(format!(
"Name mismatch: frontmatter contains '{}' but request contains '{}'",
frontmatter_name, request.name
)));
}
} else {
return Err(CarpError::InvalidAgent(
"YAML frontmatter must contain a 'name' field".to_string(),
));
}
if let Some(frontmatter_desc) = frontmatter.get("description").and_then(|v| v.as_str()) {
if frontmatter_desc != request.description {
return Err(CarpError::InvalidAgent(format!(
"Description mismatch: frontmatter contains '{}' but request contains '{}'",
frontmatter_desc, request.description
)));
}
} else {
return Err(CarpError::InvalidAgent(
"YAML frontmatter must contain a 'description' field".to_string(),
));
}
Ok(())
}
fn validate_publish_request(&self, request: &PublishRequest) -> CarpResult<()> {
self.validate_agent_name(&request.name)?;
self.validate_version(&request.version)?;
if request.description.trim().is_empty() {
return Err(CarpError::InvalidAgent(
"Description cannot be empty".to_string(),
));
}
if request.description.len() > 1000 {
return Err(CarpError::InvalidAgent(
"Description cannot exceed 1000 characters".to_string(),
));
}
for tag in &request.tags {
if tag.trim().is_empty() {
return Err(CarpError::InvalidAgent("Tags cannot be empty".to_string()));
}
if tag.len() > 50 {
return Err(CarpError::InvalidAgent(
"Tags cannot exceed 50 characters".to_string(),
));
}
}
if request.tags.len() > 10 {
return Err(CarpError::InvalidAgent(
"Cannot have more than 10 tags".to_string(),
));
}
Ok(())
}
async fn handle_response<T>(&self, response: Response) -> CarpResult<T>
where
T: serde::de::DeserializeOwned,
{
let status = response.status();
let text = response.text().await?;
if status.is_success() {
serde_json::from_str(&text).map_err(CarpError::Json)
} else {
if status.as_u16() == 401 {
let auth_error = if text.contains("invalid") || text.contains("expired") {
"Invalid or expired API key. Please check your API key and try again."
} else if text.contains("missing") || text.contains("required") {
"API key required. Please provide your API key via --api-key option, CARP_API_KEY environment variable, or config file."
} else {
"Authentication failed. Please verify your API key is correct."
};
return Err(CarpError::Auth(format!(
"{auth_error}\n\nTo fix this:\n 1. Get your API key from the registry dashboard\n 2. Set it via: carp auth set-api-key\n 3. Or use: --api-key <your-key>\n 4. Or set CARP_API_KEY environment variable"
)));
}
if status.as_u16() == 403 {
return Err(CarpError::Auth(
"Access forbidden. Your API key may not have sufficient permissions for this operation.".to_string()
));
}
match serde_json::from_str::<ApiError>(&text) {
Ok(api_error) => {
let mut error_message = api_error.message;
if let Some(details) = api_error.details {
error_message.push_str(&format!(
"\n\nDetails: {}",
serde_json::to_string_pretty(&details).unwrap_or_default()
));
}
Err(CarpError::Api {
status: status.as_u16(),
message: error_message,
})
}
Err(_) => {
let error_message = if text.is_empty() {
format!("HTTP {} error", status.as_u16())
} else {
format!("HTTP {} error: {}", status.as_u16(), text)
};
Err(CarpError::Api {
status: status.as_u16(),
message: error_message,
})
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use mockito::Server;
fn create_test_config(server_url: String, api_key: Option<String>) -> Config {
Config {
registry_url: server_url,
api_key,
api_token: None,
timeout: 30,
verify_ssl: true,
default_output_dir: None,
max_concurrent_downloads: 4,
retry: crate::config::RetrySettings::default(),
security: crate::config::SecuritySettings::default(),
}
}
fn create_valid_upload_request() -> UploadAgentRequest {
UploadAgentRequest {
name: "test-agent".to_string(),
description: "A test agent".to_string(),
content: r#"---
name: test-agent
description: A test agent
---
# Test Agent
This is a test agent.
"#
.to_string(),
version: Some("1.0.0".to_string()),
tags: vec!["test".to_string()],
homepage: Some("https://example.com".to_string()),
repository: Some("https://github.com/user/repo".to_string()),
license: Some("MIT".to_string()),
}
}
#[tokio::test]
async fn test_search_request() {
let mut server = Server::new_async().await;
let config = create_test_config(server.url(), None);
let _m = server
.mock("GET", "/api/v1/agents/search")
.match_query(mockito::Matcher::UrlEncoded("q".into(), "test".into()))
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"agents": [], "total": 0, "page": 1, "per_page": 10}"#)
.create_async()
.await;
let client = ApiClient::new(&config).unwrap();
let result = client.search("test", Some(10), false).await;
match result {
Ok(response) => {
assert_eq!(response.agents.len(), 0);
assert_eq!(response.total, 0);
println!("Test passed successfully");
}
Err(e) => {
println!("Test error: {:?}", e);
}
}
}
#[test]
fn test_validate_upload_request_valid() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let request = create_valid_upload_request();
assert!(client.validate_upload_request(&request).is_ok());
}
#[test]
fn test_validate_upload_request_empty_name() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let mut request = create_valid_upload_request();
request.name = "".to_string();
let result = client.validate_upload_request(&request);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Agent name cannot be empty"));
}
#[test]
fn test_validate_upload_request_invalid_name() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let mut request = create_valid_upload_request();
request.name = "invalid name!".to_string();
let result = client.validate_upload_request(&request);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("alphanumeric characters"));
}
#[test]
fn test_validate_upload_request_empty_description() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let mut request = create_valid_upload_request();
request.description = "".to_string();
let result = client.validate_upload_request(&request);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Description cannot be empty"));
}
#[test]
fn test_validate_upload_request_empty_content() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let mut request = create_valid_upload_request();
request.content = "".to_string();
let result = client.validate_upload_request(&request);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Content cannot be empty"));
}
#[test]
fn test_validate_upload_request_no_frontmatter() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let mut request = create_valid_upload_request();
request.content = "# Test Agent\n\nNo frontmatter here.".to_string();
let result = client.validate_upload_request(&request);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("YAML frontmatter"));
}
#[test]
fn test_validate_upload_request_mismatched_name() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let mut request = create_valid_upload_request();
request.content = r#"---
name: different-name
description: A test agent
---
# Test Agent
"#
.to_string();
let result = client.validate_upload_request(&request);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Name mismatch"));
}
#[test]
fn test_validate_upload_request_mismatched_description() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let mut request = create_valid_upload_request();
request.content = r#"---
name: test-agent
description: Different description
---
# Test Agent
"#
.to_string();
let result = client.validate_upload_request(&request);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Description mismatch"));
}
#[test]
fn test_validate_upload_request_too_many_tags() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let mut request = create_valid_upload_request();
request.tags = (0..25).map(|i| format!("tag{}", i)).collect();
let result = client.validate_upload_request(&request);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Cannot have more than 20 tags"));
}
#[test]
fn test_validate_upload_request_large_content() {
let config =
create_test_config("https://example.com".to_string(), Some("token".to_string()));
let client = ApiClient::new(&config).unwrap();
let mut request = create_valid_upload_request();
let large_content = "x".repeat(2 * 1024 * 1024);
request.content = format!(
r#"---
name: test-agent
description: A test agent
---
{}
"#,
large_content
);
let result = client.validate_upload_request(&request);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("exceeds maximum allowed size"));
}
#[tokio::test]
async fn test_upload_no_token() {
let mut server = Server::new_async().await;
let config = create_test_config(server.url(), None);
let client = ApiClient::new(&config).unwrap();
let request = create_valid_upload_request();
let result = client.upload(request).await;
assert!(result.is_err());
if let Err(CarpError::Auth(msg)) = result {
assert!(msg.contains("No API key configured"));
} else {
panic!("Expected Auth error");
}
}
#[tokio::test]
async fn test_upload_success() {
let mut server = Server::new_async().await;
let config = create_test_config(server.url(), Some("test-token".to_string()));
let _m = server
.mock("POST", "/api/v1/agents/upload")
.match_header("authorization", "Bearer test-token")
.match_header("content-type", "application/json")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{"success": true, "message": "Agent uploaded successfully", "agent": null}"#,
)
.create_async()
.await;
let client = ApiClient::new(&config).unwrap();
let request = create_valid_upload_request();
let result = client.upload(request).await;
match result {
Ok(response) => {
assert!(response.success);
assert_eq!(response.message, "Agent uploaded successfully");
}
Err(e) => {
println!("Upload test error: {:?}", e);
}
}
}
}