use reqwest::blocking::{Client, Response};
use serde::{de::DeserializeOwned, Serialize};
use std::time::Duration;
use crate::config::ServiceConfig;
use crate::error::Result;
use crate::output::new_spinner;
#[derive(Debug, serde::Deserialize)]
pub struct Workspace {
pub id: String,
pub name: String,
pub email: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct Project {
pub id: String,
pub name: String,
pub environment: String,
#[allow(dead_code)]
pub fk_workspace: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct ApiEnvVar {
pub key: String,
pub value: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct Service {
pub id: String,
pub name: String,
pub deploy_type: String,
pub runtime: String,
pub external_sd_url: Option<String>,
#[allow(dead_code)]
pub internal_sd_url: Option<String>,
pub repository_url: Option<String>,
pub repository_branch: Option<String>,
pub registry_url: Option<String>,
pub registry_repository_url: Option<String>,
pub fk_service_secret: Option<String>,
pub root_path: Option<String>,
pub build_path: Option<String>,
pub build_command: Option<String>,
pub pre_deploy_command: Option<String>,
pub run_command: Option<String>,
pub fk_region: Option<String>,
pub fk_pod: Option<String>,
pub fk_project: Option<String>,
pub fk_workspace: Option<String>,
pub health_check_path: Option<String>,
pub maintenance_mode: Option<bool>,
pub active: Option<bool>,
pub env: Option<Vec<ApiEnvVar>>,
pub deploy_tag: Option<String>,
#[allow(dead_code)]
pub created_at: Option<String>,
#[allow(dead_code)]
pub updated_at: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PrometheusResult {
pub values: Vec<(f64, String)>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PrometheusData {
pub result: Vec<PrometheusResult>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PrometheusResponse {
pub data: PrometheusData,
}
#[derive(Debug, serde::Deserialize)]
pub struct LogLine {
pub timestamp: String,
pub message: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct LogsResponse {
pub logs: Vec<LogLine>,
}
#[derive(Debug, serde::Deserialize)]
pub struct NetworkMetricsResponse {
pub download: PrometheusResponse,
pub upload: PrometheusResponse,
}
#[derive(Debug, serde::Deserialize)]
pub struct Job {
#[allow(dead_code)]
pub id: String,
#[allow(dead_code)]
pub fk_service: String,
#[serde(rename = "type")]
pub job_type: String,
pub status: String,
pub deploy_ref: Option<String>,
pub created_at: Option<String>,
#[allow(dead_code)]
pub updated_at: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PaginatedJobs {
pub data: Vec<Job>,
#[allow(dead_code)]
pub total: usize,
}
#[derive(Debug, serde::Deserialize)]
pub struct Pod {
pub id: String,
pub name: String,
pub label: Option<String>,
pub cpu: Option<String>,
pub ram: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct Region {
pub id: String,
pub name: String,
pub label: Option<String>,
pub country_code: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct WorkspaceSecret {
pub id: String,
pub name: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct CreateProjectBody<'a> {
name: &'a str,
environment: &'a str,
fk_workspace: &'a str,
}
#[derive(Debug, serde::Serialize)]
struct ServiceBody<'a> {
#[serde(flatten)]
service: &'a ServiceConfig,
fk_project: &'a str,
fk_workspace: &'a str,
}
pub struct ApiClient {
client: Client,
base_url: String,
api_key: String,
}
impl ApiClient {
pub fn new() -> Result<Self> {
let api_key = crate::modules::auth::read_key()
.ok_or("No API key found.\n Run 'partiri auth' to configure your key.")?;
let base_url = std::env::var("PARTIRI_API_URL")
.unwrap_or_else(|_| "https://api.partiri.cloud".to_string());
if !base_url.starts_with("https://") {
return Err(format!("PARTIRI_API_URL must use HTTPS. Got: {base_url}").into());
}
let timeout_secs: u64 = std::env::var("PARTIRI_TIMEOUT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30);
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.build()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
Ok(ApiClient {
client,
base_url,
api_key,
})
}
fn send_with_retry(
&self,
build: impl Fn() -> reqwest::blocking::RequestBuilder,
) -> Result<Response> {
const MAX_RETRIES: u32 = 3;
for attempt in 0..=MAX_RETRIES {
let response = build()
.send()
.map_err(|e| format!("Network request failed: {e}"))?;
if response.status().as_u16() != 429 || attempt == MAX_RETRIES {
return Ok(response);
}
let wait = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1 << attempt);
std::thread::sleep(Duration::from_secs(wait));
}
unreachable!()
}
fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.client
.get(format!("{}{}", self.base_url, path))
.header("x-api-key", &self.api_key)
})?;
spinner.finish_and_clear();
self.handle_response(response)
}
fn get_empty(&self, path: &str) -> Result<()> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.client
.get(format!("{}{}", self.base_url, path))
.header("x-api-key", &self.api_key)
})?;
spinner.finish_and_clear();
self.handle_response_empty(response)
}
fn get_query<T: DeserializeOwned>(&self, path: &str, params: &[(&str, &str)]) -> Result<T> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.client
.get(format!("{}{}", self.base_url, path))
.query(params)
.header("x-api-key", &self.api_key)
})?;
spinner.finish_and_clear();
self.handle_response(response)
}
fn post<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> Result<T> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.client
.post(format!("{}{}", self.base_url, path))
.header("x-api-key", &self.api_key)
.header("Content-Type", "application/json")
.json(body)
})?;
spinner.finish_and_clear();
self.handle_response(response)
}
fn post_empty<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.client
.post(format!("{}{}", self.base_url, path))
.header("x-api-key", &self.api_key)
.header("Content-Type", "application/json")
.json(body)
})?;
spinner.finish_and_clear();
self.handle_response_empty(response)
}
fn put_empty<B: Serialize>(&self, path: &str, body: &B) -> Result<()> {
let spinner = new_spinner();
let response = self.send_with_retry(|| {
self.client
.put(format!("{}{}", self.base_url, path))
.header("x-api-key", &self.api_key)
.header("Content-Type", "application/json")
.json(body)
})?;
spinner.finish_and_clear();
self.handle_response_empty(response)
}
fn error_message(response: Response) -> crate::error::Error {
let status = response.status();
let body = response.text().unwrap_or_default();
let msg = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v["message"].as_str().map(String::from))
.unwrap_or_else(|| "An unexpected error occurred.".to_string());
let hint = match status.as_u16() {
400 => "\n Check that your configuration values are valid.",
401 => "\n Run 'partiri auth' to update your API key.",
402 => "\n Your workspace balance is insufficient. Top up at https://partiri.cloud/settings/billing",
403 => "\n Your account may lack permission, or a workspace limit has been reached.",
404 => "\n The resource was not found. It may have been deleted.",
409 => "\n A conflicting operation is in progress. Wait for it to finish, then retry.",
422 => "\n The request data is invalid. Check your configuration values.",
429 => "\n Rate limit exceeded. Please wait a moment and try again.",
500..=599 => "\n This is a server-side error. Try again later, or contact support.",
_ => "",
};
format!("API error {} — {}{}", status, msg, hint).into()
}
fn handle_response<T: DeserializeOwned>(&self, response: Response) -> Result<T> {
if response.status().is_success() {
let body = response
.text()
.map_err(|e| format!("Failed to read response: {e}"))?;
serde_json::from_str::<T>(&body).map_err(|e| {
let preview = if body.len() > 200 {
&body[..200]
} else {
&body
};
format!("Failed to parse API response: {e}\n Body: {preview}").into()
})
} else {
Err(Self::error_message(response))
}
}
fn handle_response_empty(&self, response: Response) -> Result<()> {
if response.status().is_success() {
Ok(())
} else {
Err(Self::error_message(response))
}
}
pub fn list_workspaces(&self) -> Result<Vec<Workspace>> {
self.get("/workspaces")
}
pub fn list_projects(&self, workspace_id: &str) -> Result<Vec<Project>> {
self.get_query("/projects", &[("workspace", workspace_id)])
}
pub fn create_project(&self, name: &str, environment: &str, workspace_id: &str) -> Result<()> {
let body = CreateProjectBody {
name,
environment,
fk_workspace: workspace_id,
};
self.post_empty("/projects", &body)
}
pub fn list_regions(&self, workspace_id: &str) -> Result<Vec<Region>> {
self.get_query("/resources/regions", &[("workspace", workspace_id)])
}
pub fn list_pods(&self, workspace_id: &str) -> Result<Vec<Pod>> {
self.get_query("/resources/pods", &[("workspace", workspace_id)])
}
pub fn list_services(&self, project_id: &str) -> Result<Vec<Service>> {
self.get_query("/services", &[("project", project_id)])
}
pub fn list_registry_secrets(&self, workspace_id: &str) -> Result<Vec<WorkspaceSecret>> {
self.get(&format!("/workspaces/secrets/registry/{}", workspace_id))
}
pub fn list_repository_secrets(&self, workspace_id: &str) -> Result<Vec<WorkspaceSecret>> {
self.get(&format!("/workspaces/secrets/repository/{}", workspace_id))
}
pub fn create_service(
&self,
service: &ServiceConfig,
project_id: &str,
workspace_id: &str,
) -> Result<Service> {
let body = ServiceBody {
service,
fk_project: project_id,
fk_workspace: workspace_id,
};
self.post("/services", &body)
}
pub fn read_service(&self, id: &str) -> Result<Service> {
self.get(&format!("/services/{}", id))
}
pub fn read_metrics_cpu(
&self,
id: &str,
deploy_tag: Option<&str>,
) -> Result<PrometheusResponse> {
let path = format!("/metrics/cpu/{}", id);
match deploy_tag {
Some(tag) => self.get_query(&path, &[("deployTag", tag)]),
None => self.get(&path),
}
}
pub fn read_metrics_memory(
&self,
id: &str,
deploy_tag: Option<&str>,
) -> Result<PrometheusResponse> {
let path = format!("/metrics/memory/{}", id);
match deploy_tag {
Some(tag) => self.get_query(&path, &[("deployTag", tag)]),
None => self.get(&path),
}
}
pub fn read_metrics_network(
&self,
id: &str,
deploy_tag: Option<&str>,
) -> Result<NetworkMetricsResponse> {
let path = format!("/metrics/network/{}", id);
match deploy_tag {
Some(tag) => self.get_query(&path, &[("deployTag", tag)]),
None => self.get(&path),
}
}
pub fn read_service_logs(&self, id: &str, deploy_tag: Option<&str>) -> Result<LogsResponse> {
let path = format!("/logs/{}", id);
match deploy_tag {
Some(tag) => self.get_query(&path, &[("deployTag", tag)]),
None => self.get(&path),
}
}
pub fn update_service(&self, id: &str, service: &ServiceConfig) -> Result<()> {
self.put_empty(&format!("/services/{}", id), service)
}
pub fn list_service_jobs(&self, id: &str) -> Result<Vec<Job>> {
let resp: PaginatedJobs = self.get(&format!("/jobs/services/{}", id))?;
Ok(resp.data)
}
pub fn deploy_service(&self, id: &str) -> Result<()> {
self.get_empty(&format!("/jobs/services/deploy/{}", id))
}
pub fn pause_service(&self, id: &str) -> Result<()> {
self.get_empty(&format!("/jobs/services/pause/{}", id))
}
pub fn unpause_service(&self, id: &str) -> Result<()> {
self.get_empty(&format!("/jobs/services/unpause/{}", id))
}
pub fn kill_service(&self, id: &str) -> Result<()> {
self.get_empty(&format!("/jobs/services/kill/{}", id))
}
}
#[cfg(test)]
mod tests {
use super::*;
use httpmock::prelude::*;
use serde_json::json;
fn test_client(server: &MockServer) -> ApiClient {
ApiClient {
client: Client::builder()
.timeout(Duration::from_secs(5))
.build()
.unwrap(),
base_url: server.base_url(),
api_key: "test-api-key-123".to_string(),
}
}
#[test]
fn list_workspaces_success_parses_response() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/workspaces")
.header("x-api-key", "test-api-key-123");
then.status(200).json_body(json!([
{"id": "ws-1", "name": "My Workspace", "email": "user@example.com"}
]));
});
let client = test_client(&server);
let result = client.list_workspaces().unwrap();
mock.assert();
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "ws-1");
assert_eq!(result[0].name, "My Workspace");
assert_eq!(result[0].email.as_deref(), Some("user@example.com"));
}
#[test]
fn list_workspaces_with_null_email_parses_as_none() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(200).json_body(json!([
{"id": "ws-1", "name": "No-Email Workspace", "email": null}
]));
});
let client = test_client(&server);
let result = client.list_workspaces().unwrap();
assert!(result[0].email.is_none());
}
#[test]
fn list_workspaces_empty_array_returns_empty_vec() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(200).json_body(json!([]));
});
let result = test_client(&server).list_workspaces().unwrap();
assert!(result.is_empty());
}
#[test]
fn status_401_includes_partiri_key_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(401)
.json_body(json!({"message": "Unauthorized"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("401"), "should mention 401: {err}");
assert!(
err.contains("partiri auth"),
"should include auth hint: {err}"
);
}
#[test]
fn status_403_includes_permission_or_limit_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(403).json_body(json!({"message": "Forbidden"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("403"), "{err}");
assert!(
err.contains("permission") && err.contains("limit"),
"should include permission and limit hint: {err}"
);
}
#[test]
fn status_404_includes_not_found_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(404).body("not found");
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("404"), "{err}");
assert!(
err.to_lowercase().contains("not found") || err.contains("resource"),
"should include not-found hint: {err}"
);
}
#[test]
fn status_402_includes_balance_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/jobs/services/deploy/svc-1");
then.status(402)
.json_body(json!({"message": "Insufficient balance to deploy this service"}));
});
let err = test_client(&server)
.deploy_service("svc-1")
.unwrap_err()
.to_string();
assert!(err.contains("402"), "should mention 402: {err}");
assert!(
err.contains("balance"),
"should include balance hint: {err}"
);
}
#[test]
fn status_409_includes_conflict_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/jobs/services/unpause/svc-1");
then.status(409)
.json_body(json!({"message": "Active job already exists for this service"}));
});
let err = test_client(&server)
.unpause_service("svc-1")
.unwrap_err()
.to_string();
assert!(err.contains("409"), "should mention 409: {err}");
assert!(
err.contains("conflicting operation"),
"should include conflict hint: {err}"
);
}
#[test]
fn status_400_includes_validation_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(400)
.json_body(json!({"message": "Invalid health check URL"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("400"), "should mention 400: {err}");
assert!(
err.contains("configuration values"),
"should include validation hint: {err}"
);
}
#[test]
fn status_429_includes_rate_limit_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(429)
.json_body(json!({"message": "Too many requests"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("429"), "should mention 429: {err}");
assert!(
err.contains("Rate limit"),
"should include rate limit hint: {err}"
);
}
#[test]
fn status_500_includes_server_error_hint() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(500)
.json_body(json!({"message": "Internal server error"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(err.contains("500"), "should mention 500: {err}");
assert!(
err.contains("server-side error"),
"should include server error hint: {err}"
);
}
#[test]
fn json_message_field_extracted_from_error_body() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(400)
.json_body(json!({"message": "Workspace limit exceeded"}));
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(
err.contains("Workspace limit exceeded"),
"should extract message field: {err}"
);
}
#[test]
fn non_json_error_body_returned_raw() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/workspaces");
then.status(503).body("<html>Service Unavailable</html>");
});
let err = test_client(&server)
.list_workspaces()
.unwrap_err()
.to_string();
assert!(
err.contains("Service Unavailable") || err.contains("503"),
"should include raw body or status: {err}"
);
}
#[test]
fn api_key_sent_as_x_api_key_header() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/workspaces")
.header("x-api-key", "test-api-key-123");
then.status(200).json_body(json!([]));
});
test_client(&server).list_workspaces().unwrap();
mock.assert();
}
#[test]
fn list_projects_includes_workspace_query_param() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/projects").query_param("workspace", "ws-uuid-123");
then.status(200).json_body(json!([
{"id": "proj-1", "name": "My Project", "environment": "production", "fk_workspace": "ws-uuid-123"}
]));
});
let client = test_client(&server);
let result = client.list_projects("ws-uuid-123").unwrap();
mock.assert();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "My Project");
}
#[test]
fn list_service_jobs_parses_paginated_response() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/jobs/services/svc-42");
then.status(200).json_body(json!({
"data": [
{
"id": "job-1",
"fk_service": "svc-42",
"type": "deploy",
"status": "succeeded",
"cluster": "us-east-1",
"deploy_ref": "abc1234def5678",
"notes": [],
"created_at": "2025-03-18T10:00:00Z",
"updated_at": "2025-03-18T10:05:00Z"
},
{
"id": "job-2",
"fk_service": "svc-42",
"type": "kill",
"status": "open",
"cluster": "any",
"deploy_ref": null,
"notes": null,
"created_at": "2025-03-18T11:00:00Z",
"updated_at": null
}
],
"total": 2
}));
});
let client = test_client(&server);
let jobs = client.list_service_jobs("svc-42").unwrap();
mock.assert();
assert_eq!(jobs.len(), 2);
assert_eq!(jobs[0].id, "job-1");
assert_eq!(jobs[0].job_type, "deploy");
assert_eq!(jobs[0].status, "succeeded");
assert_eq!(jobs[0].deploy_ref.as_deref(), Some("abc1234def5678"));
assert_eq!(jobs[1].job_type, "kill");
assert!(jobs[1].deploy_ref.is_none());
}
#[test]
fn read_service_logs_with_deploy_tag_sends_query_param() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/logs/svc-123")
.query_param("deployTag", "ab12c");
then.status(200).json_body(json!({ "logs": [] }));
});
let result = test_client(&server)
.read_service_logs("svc-123", Some("ab12c"))
.unwrap();
mock.assert();
assert!(result.logs.is_empty());
}
#[test]
fn read_service_logs_without_deploy_tag_omits_query_param() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET).path("/logs/svc-123");
then.status(200).json_body(json!({ "logs": [] }));
});
test_client(&server)
.read_service_logs("svc-123", None)
.unwrap();
mock.assert();
}
#[test]
fn read_metrics_cpu_with_deploy_tag_sends_query_param() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/metrics/cpu/svc-123")
.query_param("deployTag", "ab12c");
then.status(200)
.json_body(json!({ "data": { "result": [] } }));
});
test_client(&server)
.read_metrics_cpu("svc-123", Some("ab12c"))
.unwrap();
mock.assert();
}
#[test]
fn list_service_jobs_empty_data_returns_empty_vec() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/jobs/services/svc-99");
then.status(200).json_body(json!({
"data": [],
"total": 0
}));
});
let jobs = test_client(&server).list_service_jobs("svc-99").unwrap();
assert!(jobs.is_empty());
}
#[test]
fn new_succeeds_when_credentials_file_exists() {
let has_key = crate::modules::auth::credentials_path()
.and_then(|p| std::fs::read_to_string(&p).ok())
.filter(|s| !s.trim().is_empty())
.is_some();
if has_key {
assert!(ApiClient::new().is_ok(), "should succeed with key file");
}
}
}