xbp 10.28.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
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");
    }
}