use crate::cli::commands::{
ApiDaemonsCmd, ApiDaemonsHeartbeatCmd, ApiDaemonsRegisterCmd, ApiDaemonsSubCommand,
ApiDaemonsUpdateStatusCmd, ApiHealthCmd, ApiJobsCmd, ApiJobsCreateCmd, ApiJobsListCmd,
ApiJobsSubCommand, ApiJobsUpdateCmd, ApiProjectsCmd, ApiProjectsCreateCmd, ApiProjectsListCmd,
ApiProjectsSubCommand, ApiRoutesCmd, ApiRoutesCreateCmd, ApiRoutesDeleteCmd,
ApiRoutesSubCommand, ApiTargetOptions,
};
use crate::commands::api_request::{execute_api_request, ApiRequestExecution};
use reqwest::Method;
use serde_json::{Map, Value};
pub async fn run_api_health(cmd: &ApiHealthCmd) -> Result<(), String> {
execute_without_body("/health", Method::GET, &cmd.target).await
}
pub async fn run_api_projects(cmd: &ApiProjectsCmd) -> Result<(), String> {
match &cmd.command {
ApiProjectsSubCommand::List(list_cmd) => run_api_projects_list(list_cmd).await,
ApiProjectsSubCommand::Create(create_cmd) => run_api_projects_create(create_cmd).await,
}
}
pub async fn run_api_daemons(cmd: &ApiDaemonsCmd) -> Result<(), String> {
match &cmd.command {
ApiDaemonsSubCommand::List(list_cmd) => {
execute_without_body("/daemons", Method::GET, &list_cmd.target).await
}
ApiDaemonsSubCommand::Register(register_cmd) => {
run_api_daemons_register(register_cmd).await
}
ApiDaemonsSubCommand::Heartbeat(heartbeat_cmd) => {
run_api_daemons_heartbeat(heartbeat_cmd).await
}
ApiDaemonsSubCommand::UpdateStatus(update_cmd) => {
run_api_daemons_update_status(update_cmd).await
}
}
}
pub async fn run_api_jobs(cmd: &ApiJobsCmd) -> Result<(), String> {
match &cmd.command {
ApiJobsSubCommand::List(list_cmd) => run_api_jobs_list(list_cmd).await,
ApiJobsSubCommand::Create(create_cmd) => run_api_jobs_create(create_cmd).await,
ApiJobsSubCommand::Claim(claim_cmd) => {
let mut body = Map::new();
insert_string(&mut body, "daemon_id", &claim_cmd.daemon_id);
insert_optional_string(&mut body, "locked_by", claim_cmd.locked_by.as_deref());
execute_with_json_body(
"/deployment-jobs/claim",
Method::POST,
Value::Object(body),
&claim_cmd.target,
)
.await
}
ApiJobsSubCommand::Update(update_cmd) => run_api_jobs_update(update_cmd).await,
}
}
pub async fn run_api_routes(cmd: &ApiRoutesCmd) -> Result<(), String> {
match &cmd.command {
ApiRoutesSubCommand::List(list_cmd) => {
execute_without_body("/routes", Method::GET, &list_cmd.target).await
}
ApiRoutesSubCommand::Create(create_cmd) => run_api_routes_create(create_cmd).await,
ApiRoutesSubCommand::Delete(delete_cmd) => run_api_routes_delete(delete_cmd).await,
}
}
async fn run_api_projects_list(cmd: &ApiProjectsListCmd) -> Result<(), String> {
let path = with_query(
"/projects",
&[("organization_id", cmd.organization_id.as_deref())],
);
execute_without_body(&path, Method::GET, &cmd.target).await
}
async fn run_api_projects_create(cmd: &ApiProjectsCreateCmd) -> Result<(), String> {
let mut body = Map::new();
insert_string(&mut body, "name", &cmd.name);
insert_string(&mut body, "path", &cmd.path);
insert_optional_string(&mut body, "organization_id", cmd.organization_id.as_deref());
insert_optional_string(&mut body, "slug", cmd.slug.as_deref());
insert_optional_string(&mut body, "version", cmd.version.as_deref());
insert_optional_string(&mut body, "build_dir", cmd.build_dir.as_deref());
insert_optional_string(&mut body, "runtime", cmd.runtime.as_deref());
insert_optional_string(&mut body, "default_branch", cmd.default_branch.as_deref());
insert_optional_string(&mut body, "root_directory", cmd.root_directory.as_deref());
insert_optional_string(&mut body, "build_command", cmd.build_command.as_deref());
insert_optional_string(&mut body, "install_command", cmd.install_command.as_deref());
insert_optional_string(&mut body, "start_command", cmd.start_command.as_deref());
insert_optional_string(
&mut body,
"output_directory",
cmd.output_directory.as_deref(),
);
insert_optional_json(&mut body, "repository", cmd.repository_json.as_deref())?;
insert_optional_json(
&mut body,
"runtime_policy",
cmd.runtime_policy_json.as_deref(),
)?;
insert_optional_json(&mut body, "metadata", cmd.metadata_json.as_deref())?;
execute_with_json_body("/projects", Method::POST, Value::Object(body), &cmd.target).await
}
async fn run_api_daemons_register(cmd: &ApiDaemonsRegisterCmd) -> Result<(), String> {
let mut body = Map::new();
insert_string(&mut body, "node_name", &cmd.node_name);
insert_string(&mut body, "hostname", &cmd.hostname);
insert_string(&mut body, "version", &cmd.version);
insert_optional_string(&mut body, "region", cmd.region.as_deref());
insert_optional_string(&mut body, "public_ip", cmd.public_ip.as_deref());
insert_optional_string(&mut body, "internal_ip", cmd.internal_ip.as_deref());
insert_optional_string(&mut body, "status", cmd.status.as_deref());
insert_optional_i32(&mut body, "cpu_cores", cmd.cpu_cores);
insert_optional_i32(&mut body, "memory_total_mb", cmd.memory_total_mb);
insert_optional_i32(&mut body, "disk_total_gb", cmd.disk_total_gb);
insert_optional_json(&mut body, "labels", cmd.labels_json.as_deref())?;
insert_optional_json(&mut body, "metadata", cmd.metadata_json.as_deref())?;
execute_with_json_body("/daemons", Method::POST, Value::Object(body), &cmd.target).await
}
async fn run_api_daemons_heartbeat(cmd: &ApiDaemonsHeartbeatCmd) -> Result<(), String> {
let mut body = Map::new();
insert_optional_string(&mut body, "status", cmd.status.as_deref());
insert_optional_string(&mut body, "version", cmd.version.as_deref());
insert_optional_string(&mut body, "public_ip", cmd.public_ip.as_deref());
insert_optional_string(&mut body, "internal_ip", cmd.internal_ip.as_deref());
insert_optional_i32(&mut body, "cpu_cores", cmd.cpu_cores);
insert_optional_i32(&mut body, "memory_total_mb", cmd.memory_total_mb);
insert_optional_i32(&mut body, "disk_total_gb", cmd.disk_total_gb);
insert_optional_json(&mut body, "labels", cmd.labels_json.as_deref())?;
execute_with_json_body(
&format!("/daemons/{}/heartbeat", cmd.daemon_id),
Method::POST,
Value::Object(body),
&cmd.target,
)
.await
}
async fn run_api_daemons_update_status(cmd: &ApiDaemonsUpdateStatusCmd) -> Result<(), String> {
let mut body = Map::new();
insert_string(&mut body, "status", &cmd.status);
execute_with_json_body(
&format!("/daemons/{}", cmd.daemon_id),
Method::PATCH,
Value::Object(body),
&cmd.target,
)
.await
}
async fn run_api_jobs_list(cmd: &ApiJobsListCmd) -> Result<(), String> {
let path = with_query(
"/deployment-jobs",
&[
("project_id", cmd.project_id.as_deref()),
("deployment_id", cmd.deployment_id.as_deref()),
("daemon_id", cmd.daemon_id.as_deref()),
("status", cmd.status.as_deref()),
("limit", cmd.limit.map(|value| value.to_string()).as_deref()),
],
);
execute_without_body(&path, Method::GET, &cmd.target).await
}
async fn run_api_jobs_create(cmd: &ApiJobsCreateCmd) -> Result<(), String> {
let mut body = Map::new();
insert_string(&mut body, "deployment_id", &cmd.deployment_id);
insert_optional_string(&mut body, "daemon_id", cmd.daemon_id.as_deref());
insert_optional_i32(&mut body, "priority", cmd.priority);
insert_optional_i32(&mut body, "max_attempts", cmd.max_attempts);
insert_optional_string(&mut body, "run_after", cmd.run_after.as_deref());
insert_optional_json(&mut body, "payload", cmd.payload_json.as_deref())?;
execute_with_json_body(
&format!("/projects/{}/deployment-jobs", cmd.project_id),
Method::POST,
Value::Object(body),
&cmd.target,
)
.await
}
async fn run_api_jobs_update(cmd: &ApiJobsUpdateCmd) -> Result<(), String> {
let mut body = Map::new();
insert_string(&mut body, "status", &cmd.status);
insert_optional_string(&mut body, "error_text", cmd.error_text.as_deref());
execute_with_json_body(
&format!("/deployment-jobs/{}", cmd.job_id),
Method::PATCH,
Value::Object(body),
&cmd.target,
)
.await
}
async fn run_api_routes_create(cmd: &ApiRoutesCreateCmd) -> Result<(), String> {
let mut targets = Vec::new();
for target in &cmd.target {
targets.push(serde_json::json!({
"url": target,
"weight": 1
}));
}
for entry in &cmd.weighted_target {
let (url, weight) = parse_weighted_target(entry)?;
targets.push(serde_json::json!({
"url": url,
"weight": weight
}));
}
if targets.is_empty() {
return Err("At least one --target or --weighted-target value is required.".to_string());
}
let mut body = Map::new();
insert_string(&mut body, "domain", &cmd.domain);
body.insert("targets".to_string(), Value::Array(targets));
let mut conditions = Map::new();
insert_optional_string(&mut conditions, "header", cmd.header_condition.as_deref());
insert_optional_string(&mut conditions, "path_prefix", cmd.path_prefix.as_deref());
if !conditions.is_empty() {
body.insert("conditions".to_string(), Value::Object(conditions));
}
execute_with_json_body(
"/routes",
Method::POST,
Value::Object(body),
&cmd.target_options,
)
.await
}
async fn run_api_routes_delete(cmd: &ApiRoutesDeleteCmd) -> Result<(), String> {
execute_without_body(
&format!("/routes/{}", cmd.domain),
Method::DELETE,
&cmd.target,
)
.await
}
async fn execute_without_body(
path: &str,
method: Method,
target: &ApiTargetOptions,
) -> Result<(), String> {
execute_api_request(ApiRequestExecution {
path: path.to_string(),
method,
body: None,
body_file: None,
target: target.clone(),
})
.await
}
async fn execute_with_json_body(
path: &str,
method: Method,
body: Value,
target: &ApiTargetOptions,
) -> Result<(), String> {
let body = serde_json::to_string(&body)
.map_err(|e| format!("Failed to serialize API request body: {}", e))?;
execute_api_request(ApiRequestExecution {
path: path.to_string(),
method,
body: Some(body),
body_file: None,
target: target.clone(),
})
.await
}
fn insert_string(map: &mut Map<String, Value>, key: &str, value: &str) {
map.insert(key.to_string(), Value::String(value.to_string()));
}
fn insert_optional_string(map: &mut Map<String, Value>, key: &str, value: Option<&str>) {
if let Some(value) = value {
insert_string(map, key, value);
}
}
fn insert_optional_i32(map: &mut Map<String, Value>, key: &str, value: Option<i32>) {
if let Some(value) = value {
map.insert(key.to_string(), Value::Number(value.into()));
}
}
fn insert_optional_json(
map: &mut Map<String, Value>,
key: &str,
raw: Option<&str>,
) -> Result<(), String> {
if let Some(raw) = raw {
let parsed: Value = serde_json::from_str(raw)
.map_err(|e| format!("Failed to parse {} as JSON: {}", key, e))?;
map.insert(key.to_string(), parsed);
}
Ok(())
}
fn parse_weighted_target(input: &str) -> Result<(String, u32), String> {
let (url, weight) = input
.rsplit_once('=')
.ok_or_else(|| format!("Invalid weighted target `{}`. Use url=weight.", input))?;
let weight = weight
.trim()
.parse::<u32>()
.map_err(|e| format!("Invalid weighted target weight `{}`: {}", weight.trim(), e))?;
let url = url.trim();
if url.is_empty() {
return Err(format!(
"Invalid weighted target `{}`. URL is empty.",
input
));
}
Ok((url.to_string(), weight))
}
fn with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
let rendered: Vec<String> = params
.iter()
.filter_map(|(key, value)| value.map(|value| format!("{}={}", key, value)))
.collect();
if rendered.is_empty() {
path.to_string()
} else {
format!("{}?{}", path, rendered.join("&"))
}
}
#[cfg(test)]
mod tests {
use super::{parse_weighted_target, with_query};
#[test]
fn weighted_target_parser_accepts_url_equals_weight() {
let (url, weight) =
parse_weighted_target("http://127.0.0.1:3000=5").expect("parse weighted target");
assert_eq!(url, "http://127.0.0.1:3000");
assert_eq!(weight, 5);
}
#[test]
fn query_builder_skips_empty_values() {
let rendered = with_query(
"/deployment-jobs",
&[("status", Some("queued")), ("project_id", None)],
);
assert_eq!(rendered, "/deployment-jobs?status=queued");
}
}