1use crate::cli::commands::{
2 ApiDaemonsCmd, ApiDaemonsHeartbeatCmd, ApiDaemonsRegisterCmd, ApiDaemonsSubCommand,
3 ApiDaemonsUpdateStatusCmd, ApiHealthCmd, ApiJobsCmd, ApiJobsCreateCmd, ApiJobsListCmd,
4 ApiJobsSubCommand, ApiJobsUpdateCmd, ApiProjectsCmd, ApiProjectsCreateCmd, ApiProjectsListCmd,
5 ApiProjectsSubCommand, ApiRoutesCmd, ApiRoutesCreateCmd, ApiRoutesDeleteCmd,
6 ApiRoutesSubCommand, ApiTargetOptions,
7};
8use crate::commands::api_request::{execute_api_request, ApiRequestExecution};
9use reqwest::Method;
10use serde_json::{Map, Value};
11
12pub async fn run_api_health(cmd: &ApiHealthCmd) -> Result<(), String> {
13 execute_without_body("/health", Method::GET, &cmd.target).await
14}
15
16pub async fn run_api_projects(cmd: &ApiProjectsCmd) -> Result<(), String> {
17 match &cmd.command {
18 ApiProjectsSubCommand::List(list_cmd) => run_api_projects_list(list_cmd).await,
19 ApiProjectsSubCommand::Create(create_cmd) => run_api_projects_create(create_cmd).await,
20 }
21}
22
23pub async fn run_api_daemons(cmd: &ApiDaemonsCmd) -> Result<(), String> {
24 match &cmd.command {
25 ApiDaemonsSubCommand::List(list_cmd) => {
26 execute_without_body("/daemons", Method::GET, &list_cmd.target).await
27 }
28 ApiDaemonsSubCommand::Register(register_cmd) => {
29 run_api_daemons_register(register_cmd).await
30 }
31 ApiDaemonsSubCommand::Heartbeat(heartbeat_cmd) => {
32 run_api_daemons_heartbeat(heartbeat_cmd).await
33 }
34 ApiDaemonsSubCommand::UpdateStatus(update_cmd) => {
35 run_api_daemons_update_status(update_cmd).await
36 }
37 }
38}
39
40pub async fn run_api_jobs(cmd: &ApiJobsCmd) -> Result<(), String> {
41 match &cmd.command {
42 ApiJobsSubCommand::List(list_cmd) => run_api_jobs_list(list_cmd).await,
43 ApiJobsSubCommand::Create(create_cmd) => run_api_jobs_create(create_cmd).await,
44 ApiJobsSubCommand::Claim(claim_cmd) => {
45 let mut body = Map::new();
46 insert_string(&mut body, "daemon_id", &claim_cmd.daemon_id);
47 insert_optional_string(&mut body, "locked_by", claim_cmd.locked_by.as_deref());
48 execute_with_json_body(
49 "/deployment-jobs/claim",
50 Method::POST,
51 Value::Object(body),
52 &claim_cmd.target,
53 )
54 .await
55 }
56 ApiJobsSubCommand::Update(update_cmd) => run_api_jobs_update(update_cmd).await,
57 }
58}
59
60pub async fn run_api_routes(cmd: &ApiRoutesCmd) -> Result<(), String> {
61 match &cmd.command {
62 ApiRoutesSubCommand::List(list_cmd) => {
63 execute_without_body("/routes", Method::GET, &list_cmd.target).await
64 }
65 ApiRoutesSubCommand::Create(create_cmd) => run_api_routes_create(create_cmd).await,
66 ApiRoutesSubCommand::Delete(delete_cmd) => run_api_routes_delete(delete_cmd).await,
67 }
68}
69
70async fn run_api_projects_list(cmd: &ApiProjectsListCmd) -> Result<(), String> {
71 let path = with_query(
72 "/projects",
73 &[("organization_id", cmd.organization_id.as_deref())],
74 );
75 execute_without_body(&path, Method::GET, &cmd.target).await
76}
77
78async fn run_api_projects_create(cmd: &ApiProjectsCreateCmd) -> Result<(), String> {
79 let mut body = Map::new();
80 insert_string(&mut body, "name", &cmd.name);
81 insert_string(&mut body, "path", &cmd.path);
82 insert_optional_string(&mut body, "organization_id", cmd.organization_id.as_deref());
83 insert_optional_string(&mut body, "slug", cmd.slug.as_deref());
84 insert_optional_string(&mut body, "version", cmd.version.as_deref());
85 insert_optional_string(&mut body, "build_dir", cmd.build_dir.as_deref());
86 insert_optional_string(&mut body, "runtime", cmd.runtime.as_deref());
87 insert_optional_string(&mut body, "default_branch", cmd.default_branch.as_deref());
88 insert_optional_string(&mut body, "root_directory", cmd.root_directory.as_deref());
89 insert_optional_string(&mut body, "build_command", cmd.build_command.as_deref());
90 insert_optional_string(&mut body, "install_command", cmd.install_command.as_deref());
91 insert_optional_string(&mut body, "start_command", cmd.start_command.as_deref());
92 insert_optional_string(
93 &mut body,
94 "output_directory",
95 cmd.output_directory.as_deref(),
96 );
97 insert_optional_json(&mut body, "repository", cmd.repository_json.as_deref())?;
98 insert_optional_json(
99 &mut body,
100 "runtime_policy",
101 cmd.runtime_policy_json.as_deref(),
102 )?;
103 insert_optional_json(&mut body, "metadata", cmd.metadata_json.as_deref())?;
104
105 execute_with_json_body("/projects", Method::POST, Value::Object(body), &cmd.target).await
106}
107
108async fn run_api_daemons_register(cmd: &ApiDaemonsRegisterCmd) -> Result<(), String> {
109 let mut body = Map::new();
110 insert_string(&mut body, "node_name", &cmd.node_name);
111 insert_string(&mut body, "hostname", &cmd.hostname);
112 insert_string(&mut body, "version", &cmd.version);
113 insert_optional_string(&mut body, "region", cmd.region.as_deref());
114 insert_optional_string(&mut body, "public_ip", cmd.public_ip.as_deref());
115 insert_optional_string(&mut body, "internal_ip", cmd.internal_ip.as_deref());
116 insert_optional_string(&mut body, "status", cmd.status.as_deref());
117 insert_optional_i32(&mut body, "cpu_cores", cmd.cpu_cores);
118 insert_optional_i32(&mut body, "memory_total_mb", cmd.memory_total_mb);
119 insert_optional_i32(&mut body, "disk_total_gb", cmd.disk_total_gb);
120 insert_optional_json(&mut body, "labels", cmd.labels_json.as_deref())?;
121 insert_optional_json(&mut body, "metadata", cmd.metadata_json.as_deref())?;
122
123 execute_with_json_body("/daemons", Method::POST, Value::Object(body), &cmd.target).await
124}
125
126async fn run_api_daemons_heartbeat(cmd: &ApiDaemonsHeartbeatCmd) -> Result<(), String> {
127 let mut body = Map::new();
128 insert_optional_string(&mut body, "status", cmd.status.as_deref());
129 insert_optional_string(&mut body, "version", cmd.version.as_deref());
130 insert_optional_string(&mut body, "public_ip", cmd.public_ip.as_deref());
131 insert_optional_string(&mut body, "internal_ip", cmd.internal_ip.as_deref());
132 insert_optional_i32(&mut body, "cpu_cores", cmd.cpu_cores);
133 insert_optional_i32(&mut body, "memory_total_mb", cmd.memory_total_mb);
134 insert_optional_i32(&mut body, "disk_total_gb", cmd.disk_total_gb);
135 insert_optional_json(&mut body, "labels", cmd.labels_json.as_deref())?;
136
137 execute_with_json_body(
138 &format!("/daemons/{}/heartbeat", cmd.daemon_id),
139 Method::POST,
140 Value::Object(body),
141 &cmd.target,
142 )
143 .await
144}
145
146async fn run_api_daemons_update_status(cmd: &ApiDaemonsUpdateStatusCmd) -> Result<(), String> {
147 let mut body = Map::new();
148 insert_string(&mut body, "status", &cmd.status);
149 execute_with_json_body(
150 &format!("/daemons/{}", cmd.daemon_id),
151 Method::PATCH,
152 Value::Object(body),
153 &cmd.target,
154 )
155 .await
156}
157
158async fn run_api_jobs_list(cmd: &ApiJobsListCmd) -> Result<(), String> {
159 let path = with_query(
160 "/deployment-jobs",
161 &[
162 ("project_id", cmd.project_id.as_deref()),
163 ("deployment_id", cmd.deployment_id.as_deref()),
164 ("daemon_id", cmd.daemon_id.as_deref()),
165 ("status", cmd.status.as_deref()),
166 ("limit", cmd.limit.map(|value| value.to_string()).as_deref()),
167 ],
168 );
169 execute_without_body(&path, Method::GET, &cmd.target).await
170}
171
172async fn run_api_jobs_create(cmd: &ApiJobsCreateCmd) -> Result<(), String> {
173 let mut body = Map::new();
174 insert_string(&mut body, "deployment_id", &cmd.deployment_id);
175 insert_optional_string(&mut body, "daemon_id", cmd.daemon_id.as_deref());
176 insert_optional_i32(&mut body, "priority", cmd.priority);
177 insert_optional_i32(&mut body, "max_attempts", cmd.max_attempts);
178 insert_optional_string(&mut body, "run_after", cmd.run_after.as_deref());
179 insert_optional_json(&mut body, "payload", cmd.payload_json.as_deref())?;
180
181 execute_with_json_body(
182 &format!("/projects/{}/deployment-jobs", cmd.project_id),
183 Method::POST,
184 Value::Object(body),
185 &cmd.target,
186 )
187 .await
188}
189
190async fn run_api_jobs_update(cmd: &ApiJobsUpdateCmd) -> Result<(), String> {
191 let mut body = Map::new();
192 insert_string(&mut body, "status", &cmd.status);
193 insert_optional_string(&mut body, "error_text", cmd.error_text.as_deref());
194 execute_with_json_body(
195 &format!("/deployment-jobs/{}", cmd.job_id),
196 Method::PATCH,
197 Value::Object(body),
198 &cmd.target,
199 )
200 .await
201}
202
203async fn run_api_routes_create(cmd: &ApiRoutesCreateCmd) -> Result<(), String> {
204 let mut targets = Vec::new();
205 for target in &cmd.target {
206 targets.push(serde_json::json!({
207 "url": target,
208 "weight": 1
209 }));
210 }
211 for entry in &cmd.weighted_target {
212 let (url, weight) = parse_weighted_target(entry)?;
213 targets.push(serde_json::json!({
214 "url": url,
215 "weight": weight
216 }));
217 }
218
219 if targets.is_empty() {
220 return Err("At least one --target or --weighted-target value is required.".to_string());
221 }
222
223 let mut body = Map::new();
224 insert_string(&mut body, "domain", &cmd.domain);
225 body.insert("targets".to_string(), Value::Array(targets));
226 let mut conditions = Map::new();
227 insert_optional_string(&mut conditions, "header", cmd.header_condition.as_deref());
228 insert_optional_string(&mut conditions, "path_prefix", cmd.path_prefix.as_deref());
229 if !conditions.is_empty() {
230 body.insert("conditions".to_string(), Value::Object(conditions));
231 }
232
233 execute_with_json_body(
234 "/routes",
235 Method::POST,
236 Value::Object(body),
237 &cmd.target_options,
238 )
239 .await
240}
241
242async fn run_api_routes_delete(cmd: &ApiRoutesDeleteCmd) -> Result<(), String> {
243 execute_without_body(
244 &format!("/routes/{}", cmd.domain),
245 Method::DELETE,
246 &cmd.target,
247 )
248 .await
249}
250
251async fn execute_without_body(
252 path: &str,
253 method: Method,
254 target: &ApiTargetOptions,
255) -> Result<(), String> {
256 execute_api_request(ApiRequestExecution {
257 path: path.to_string(),
258 method,
259 body: None,
260 body_file: None,
261 target: target.clone(),
262 })
263 .await
264}
265
266async fn execute_with_json_body(
267 path: &str,
268 method: Method,
269 body: Value,
270 target: &ApiTargetOptions,
271) -> Result<(), String> {
272 let body = serde_json::to_string(&body)
273 .map_err(|e| format!("Failed to serialize API request body: {}", e))?;
274 execute_api_request(ApiRequestExecution {
275 path: path.to_string(),
276 method,
277 body: Some(body),
278 body_file: None,
279 target: target.clone(),
280 })
281 .await
282}
283
284fn insert_string(map: &mut Map<String, Value>, key: &str, value: &str) {
285 map.insert(key.to_string(), Value::String(value.to_string()));
286}
287
288fn insert_optional_string(map: &mut Map<String, Value>, key: &str, value: Option<&str>) {
289 if let Some(value) = value {
290 insert_string(map, key, value);
291 }
292}
293
294fn insert_optional_i32(map: &mut Map<String, Value>, key: &str, value: Option<i32>) {
295 if let Some(value) = value {
296 map.insert(key.to_string(), Value::Number(value.into()));
297 }
298}
299
300fn insert_optional_json(
301 map: &mut Map<String, Value>,
302 key: &str,
303 raw: Option<&str>,
304) -> Result<(), String> {
305 if let Some(raw) = raw {
306 let parsed: Value = serde_json::from_str(raw)
307 .map_err(|e| format!("Failed to parse {} as JSON: {}", key, e))?;
308 map.insert(key.to_string(), parsed);
309 }
310 Ok(())
311}
312
313fn parse_weighted_target(input: &str) -> Result<(String, u32), String> {
314 let (url, weight) = input
315 .rsplit_once('=')
316 .ok_or_else(|| format!("Invalid weighted target `{}`. Use url=weight.", input))?;
317 let weight = weight
318 .trim()
319 .parse::<u32>()
320 .map_err(|e| format!("Invalid weighted target weight `{}`: {}", weight.trim(), e))?;
321 let url = url.trim();
322 if url.is_empty() {
323 return Err(format!(
324 "Invalid weighted target `{}`. URL is empty.",
325 input
326 ));
327 }
328 Ok((url.to_string(), weight))
329}
330
331fn with_query(path: &str, params: &[(&str, Option<&str>)]) -> String {
332 let rendered: Vec<String> = params
333 .iter()
334 .filter_map(|(key, value)| value.map(|value| format!("{}={}", key, value)))
335 .collect();
336 if rendered.is_empty() {
337 path.to_string()
338 } else {
339 format!("{}?{}", path, rendered.join("&"))
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::{parse_weighted_target, with_query};
346
347 #[test]
348 fn weighted_target_parser_accepts_url_equals_weight() {
349 let (url, weight) =
350 parse_weighted_target("http://127.0.0.1:3000=5").expect("parse weighted target");
351 assert_eq!(url, "http://127.0.0.1:3000");
352 assert_eq!(weight, 5);
353 }
354
355 #[test]
356 fn query_builder_skips_empty_values() {
357 let rendered = with_query(
358 "/deployment-jobs",
359 &[("status", Some("queued")), ("project_id", None)],
360 );
361 assert_eq!(rendered, "/deployment-jobs?status=queued");
362 }
363}