1use clap::Args;
2
3use crate::util::{
4 admin_surface_enabled, api_request, exit_error, is_admin_api_path, read_json_from_file,
5 resolve_token,
6};
7
8#[derive(Args)]
9pub struct ApiArgs {
10 pub method: String,
12
13 pub path: String,
15
16 #[arg(long, short = 'd')]
18 pub data: Option<String>,
19
20 #[arg(long, short = 'f', conflicts_with = "data")]
22 pub data_file: Option<String>,
23
24 #[arg(long, short = 'q')]
26 pub query: Vec<String>,
27
28 #[arg(long, short = 'H')]
30 pub header: Vec<String>,
31
32 #[arg(long)]
34 pub raw: bool,
35
36 #[arg(long, short = 'i')]
38 pub include: bool,
39
40 #[arg(long)]
42 pub no_auth: bool,
43
44 #[arg(long)]
46 pub allow_async_analysis_job: bool,
47}
48
49pub async fn run(api_url: &str, args: ApiArgs) -> i32 {
50 if is_admin_api_path(&args.path) && !admin_surface_enabled() {
51 exit_error(
52 "Admin API paths are disabled in CLI by default.",
53 Some("Set KURA_ENABLE_ADMIN_SURFACE=1 only in trusted developer/admin sessions."),
54 );
55 }
56
57 let method = match args.method.to_uppercase().as_str() {
59 "GET" => reqwest::Method::GET,
60 "POST" => reqwest::Method::POST,
61 "PUT" => reqwest::Method::PUT,
62 "DELETE" => reqwest::Method::DELETE,
63 "PATCH" => reqwest::Method::PATCH,
64 "HEAD" => reqwest::Method::HEAD,
65 "OPTIONS" => reqwest::Method::OPTIONS,
66 other => exit_error(
67 &format!("Unknown HTTP method: {other}"),
68 Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
69 ),
70 };
71
72 if is_async_analysis_job_create_path(&args.path)
73 && method == reqwest::Method::POST
74 && !args.allow_async_analysis_job
75 {
76 exit_error(
77 "Direct async analysis-job creation via `kura api POST /v1/analysis/jobs` is blocked by default.",
78 Some(
79 "Use `kura analysis run --objective \"...\"` for user-facing analyses. Use `kura analysis create --request-file payload.json` (or pass `--allow-async-analysis-job`) only for explicit background-job workflows.",
80 ),
81 );
82 }
83
84 let mut query = Vec::new();
86 for q in &args.query {
87 match q.split_once('=') {
88 Some((k, v)) => query.push((k.to_string(), v.to_string())),
89 None => exit_error(
90 &format!("Invalid query parameter: '{q}'"),
91 Some("Format: key=value, e.g. --query event_type=set.logged"),
92 ),
93 }
94 }
95
96 let mut headers = Vec::new();
98 for h in &args.header {
99 match h.split_once(':') {
100 Some((k, v)) => headers.push((k.trim().to_string(), v.trim().to_string())),
101 None => exit_error(
102 &format!("Invalid header: '{h}'"),
103 Some("Format: Key:Value, e.g. --header Content-Type:application/json"),
104 ),
105 }
106 }
107
108 let body = if let Some(ref d) = args.data {
110 match serde_json::from_str(d) {
111 Ok(v) => Some(v),
112 Err(e) => exit_error(
113 &format!("Invalid JSON in --data: {e}"),
114 Some("Provide valid JSON string"),
115 ),
116 }
117 } else if let Some(ref f) = args.data_file {
118 match read_json_from_file(f) {
119 Ok(v) => Some(v),
120 Err(e) => exit_error(&e, Some("Provide a valid JSON file or use '-' for stdin")),
121 }
122 } else {
123 None
124 };
125
126 let token = if args.no_auth {
128 None
129 } else {
130 match resolve_token(api_url).await {
131 Ok(t) => Some(t),
132 Err(e) => exit_error(
133 &e.to_string(),
134 Some("Run `kura login`, set KURA_API_KEY, or use --no-auth for public endpoints"),
135 ),
136 }
137 };
138
139 api_request(
140 api_url,
141 method,
142 &args.path,
143 token.as_deref(),
144 body,
145 &query,
146 &headers,
147 args.raw,
148 args.include,
149 )
150 .await
151}
152
153fn is_async_analysis_job_create_path(path: &str) -> bool {
154 let trimmed = path.trim();
155 if trimmed.is_empty() {
156 return false;
157 }
158
159 let normalized = if trimmed.starts_with('/') {
160 trimmed.to_ascii_lowercase()
161 } else {
162 format!("/{}", trimmed.to_ascii_lowercase())
163 };
164 normalized == "/v1/analysis/jobs" || normalized == "/v1/analysis/jobs/"
165}
166
167#[cfg(test)]
168mod tests {
169 use super::is_async_analysis_job_create_path;
170
171 #[test]
172 fn test_query_parsing() {
173 let input = "event_type=set.logged";
174 let (k, v) = input.split_once('=').unwrap();
175 assert_eq!(k, "event_type");
176 assert_eq!(v, "set.logged");
177 }
178
179 #[test]
180 fn test_header_parsing() {
181 let input = "Content-Type: application/json";
182 let (k, v) = input.split_once(':').unwrap();
183 assert_eq!(k.trim(), "Content-Type");
184 assert_eq!(v.trim(), "application/json");
185 }
186
187 #[test]
188 fn test_method_parsing() {
189 for m in &[
190 "get", "GET", "Get", "post", "POST", "delete", "DELETE", "put", "patch",
191 ] {
192 let parsed = match m.to_uppercase().as_str() {
193 "GET" => Some(reqwest::Method::GET),
194 "POST" => Some(reqwest::Method::POST),
195 "PUT" => Some(reqwest::Method::PUT),
196 "DELETE" => Some(reqwest::Method::DELETE),
197 "PATCH" => Some(reqwest::Method::PATCH),
198 _ => None,
199 };
200 assert!(parsed.is_some(), "Failed to parse method: {m}");
201 }
202 }
203
204 #[test]
205 fn async_analysis_job_create_path_detection_is_exact_to_create_endpoint() {
206 assert!(is_async_analysis_job_create_path("/v1/analysis/jobs"));
207 assert!(is_async_analysis_job_create_path("v1/analysis/jobs"));
208 assert!(is_async_analysis_job_create_path("/v1/analysis/jobs/"));
209 assert!(!is_async_analysis_job_create_path("/v1/analysis/jobs/run"));
210 assert!(!is_async_analysis_job_create_path("/v1/analysis/jobs/123"));
211 assert!(!is_async_analysis_job_create_path("/v1/agent/context"));
212 }
213}