1use clap::Args;
2use serde::Deserialize;
3use serde_json::Value;
4
5use crate::util::{
6 admin_surface_enabled, api_request, exit_error, is_admin_api_path, read_json_from_file,
7 resolve_token,
8};
9
10#[derive(Args)]
11pub struct ExecArgs {
12 #[arg(long, default_value = "-")]
14 request_file: String,
15}
16
17#[derive(Debug, Deserialize)]
18struct ExecEnvelope {
19 method: String,
20 path: String,
21 #[serde(default)]
22 body: Option<Value>,
23 #[serde(default)]
24 query: Option<Value>,
25 #[serde(default)]
26 headers: Option<Value>,
27 #[serde(default)]
28 include: bool,
29 #[serde(default)]
30 raw: bool,
31 #[serde(default)]
32 no_auth: Option<bool>,
33 #[serde(default)]
34 allow_async_analysis_job: bool,
35}
36
37pub async fn run(api_url: &str, cli_no_auth: bool, args: ExecArgs) -> i32 {
38 let payload = match read_json_from_file(&args.request_file) {
39 Ok(value) => value,
40 Err(err) => exit_error(
41 &err,
42 Some("Provide a valid JSON envelope via --request-file (or '-' for stdin)."),
43 ),
44 };
45
46 let envelope: ExecEnvelope = match serde_json::from_value(payload) {
47 Ok(value) => value,
48 Err(err) => exit_error(
49 &format!("Invalid exec envelope JSON: {err}"),
50 Some(
51 "Envelope fields: method, path, body?, query?, headers?, include?, raw?, no_auth?, allow_async_analysis_job?",
52 ),
53 ),
54 };
55
56 let method = parse_method(&envelope.method);
57 let path = normalize_path(&envelope.path);
58
59 if is_admin_api_path(&path) && !admin_surface_enabled() {
60 exit_error(
61 "Admin API paths are disabled in CLI by default.",
62 Some("Set KURA_ENABLE_ADMIN_SURFACE=1 only in trusted developer/admin sessions."),
63 );
64 }
65
66 if is_async_analysis_job_create_path(&path)
67 && method == reqwest::Method::POST
68 && !envelope.allow_async_analysis_job
69 {
70 exit_error(
71 "Direct async analysis-job creation via POST /v1/analysis/jobs is blocked by default.",
72 Some(
73 "Use `kura analyze --objective \"...\"` for user-facing analyses. Set allow_async_analysis_job=true only for explicit background-job workflows.",
74 ),
75 );
76 }
77
78 let query = parse_key_value_entries(envelope.query, "query").unwrap_or_else(|err| {
79 exit_error(
80 &err,
81 Some(
82 "Use either an object {\"a\":\"b\"} or an array of {\"key\":\"a\",\"value\":\"b\"} entries.",
83 ),
84 )
85 });
86
87 let headers = parse_key_value_entries(envelope.headers, "headers").unwrap_or_else(|err| {
88 exit_error(
89 &err,
90 Some(
91 "Use either an object {\"Header\":\"Value\"} or an array of {\"key\":\"Header\",\"value\":\"Value\"} entries.",
92 ),
93 )
94 });
95
96 let no_auth = envelope.no_auth.unwrap_or(cli_no_auth);
97 let token = if no_auth {
98 None
99 } else {
100 match resolve_token(api_url).await {
101 Ok(token) => Some(token),
102 Err(err) => exit_error(
103 &err.to_string(),
104 Some("Run `kura login`, set KURA_API_KEY, or set no_auth=true in the envelope."),
105 ),
106 }
107 };
108
109 api_request(
110 api_url,
111 method,
112 &path,
113 token.as_deref(),
114 envelope.body,
115 &query,
116 &headers,
117 envelope.raw,
118 envelope.include,
119 )
120 .await
121}
122
123fn parse_method(raw: &str) -> reqwest::Method {
124 match raw.trim().to_ascii_uppercase().as_str() {
125 "GET" => reqwest::Method::GET,
126 "POST" => reqwest::Method::POST,
127 "PUT" => reqwest::Method::PUT,
128 "DELETE" => reqwest::Method::DELETE,
129 "PATCH" => reqwest::Method::PATCH,
130 "HEAD" => reqwest::Method::HEAD,
131 "OPTIONS" => reqwest::Method::OPTIONS,
132 other => exit_error(
133 &format!("Unknown HTTP method in exec envelope: {other}"),
134 Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
135 ),
136 }
137}
138
139fn normalize_path(raw: &str) -> String {
140 let trimmed = raw.trim();
141 if trimmed.is_empty() {
142 exit_error(
143 "Exec envelope field 'path' must not be empty.",
144 Some("Example: \"path\": \"/v1/events\""),
145 );
146 }
147 if trimmed.starts_with('/') {
148 trimmed.to_string()
149 } else {
150 format!("/{trimmed}")
151 }
152}
153
154fn parse_key_value_entries(
155 input: Option<Value>,
156 field_name: &str,
157) -> Result<Vec<(String, String)>, String> {
158 let Some(input) = input else {
159 return Ok(Vec::new());
160 };
161
162 match input {
163 Value::Null => Ok(Vec::new()),
164 Value::Object(map) => {
165 let mut out = Vec::with_capacity(map.len());
166 for (key, value) in map {
167 let value = value.as_str().ok_or_else(|| {
168 format!(
169 "Exec envelope field '{field_name}.{key}' must be a string, got {value}."
170 )
171 })?;
172 out.push((key, value.to_string()));
173 }
174 Ok(out)
175 }
176 Value::Array(items) => {
177 let mut out = Vec::with_capacity(items.len());
178 for (index, item) in items.into_iter().enumerate() {
179 match item {
180 Value::Object(obj) => {
181 let key = obj
182 .get("key")
183 .and_then(|value| value.as_str())
184 .ok_or_else(|| {
185 format!(
186 "Exec envelope field '{field_name}[{index}].key' must be a string."
187 )
188 })?;
189 let value = obj
190 .get("value")
191 .and_then(|value| value.as_str())
192 .ok_or_else(|| {
193 format!(
194 "Exec envelope field '{field_name}[{index}].value' must be a string."
195 )
196 })?;
197 out.push((key.to_string(), value.to_string()));
198 }
199 Value::Array(pair) if pair.len() == 2 => {
200 let key = pair[0].as_str().ok_or_else(|| {
201 format!(
202 "Exec envelope field '{field_name}[{index}][0]' must be a string."
203 )
204 })?;
205 let value = pair[1].as_str().ok_or_else(|| {
206 format!(
207 "Exec envelope field '{field_name}[{index}][1]' must be a string."
208 )
209 })?;
210 out.push((key.to_string(), value.to_string()));
211 }
212 _ => {
213 return Err(format!(
214 "Exec envelope field '{field_name}' supports object entries, {{\"key\":...,\"value\":...}} objects, or [key,value] pairs."
215 ));
216 }
217 }
218 }
219 Ok(out)
220 }
221 other => Err(format!(
222 "Exec envelope field '{field_name}' must be an object or array, got {other}."
223 )),
224 }
225}
226
227fn is_async_analysis_job_create_path(path: &str) -> bool {
228 let trimmed = path.trim();
229 if trimmed.is_empty() {
230 return false;
231 }
232
233 let normalized = if trimmed.starts_with('/') {
234 trimmed.to_ascii_lowercase()
235 } else {
236 format!("/{trimmed}").to_ascii_lowercase()
237 };
238
239 normalized == "/v1/analysis/jobs" || normalized == "/v1/analysis/jobs/"
240}
241
242#[cfg(test)]
243mod tests {
244 use super::{is_async_analysis_job_create_path, parse_key_value_entries};
245 use serde_json::json;
246
247 #[test]
248 fn parse_key_value_entries_accepts_object_shape() {
249 let parsed = parse_key_value_entries(Some(json!({"a": "b", "x": "y"})), "query").unwrap();
250 assert_eq!(
251 parsed,
252 vec![
253 ("a".to_string(), "b".to_string()),
254 ("x".to_string(), "y".to_string())
255 ]
256 );
257 }
258
259 #[test]
260 fn parse_key_value_entries_accepts_object_array_shape() {
261 let parsed = parse_key_value_entries(
262 Some(json!([
263 {"key": "a", "value": "b"},
264 {"key": "x", "value": "y"}
265 ])),
266 "headers",
267 )
268 .unwrap();
269 assert_eq!(
270 parsed,
271 vec![
272 ("a".to_string(), "b".to_string()),
273 ("x".to_string(), "y".to_string())
274 ]
275 );
276 }
277
278 #[test]
279 fn parse_key_value_entries_rejects_non_string_values() {
280 let err = parse_key_value_entries(Some(json!({"a": 1})), "query").unwrap_err();
281 assert!(err.contains("must be a string"));
282 }
283
284 #[test]
285 fn async_analysis_job_create_path_detection_is_exact() {
286 assert!(is_async_analysis_job_create_path("/v1/analysis/jobs"));
287 assert!(is_async_analysis_job_create_path("v1/analysis/jobs"));
288 assert!(!is_async_analysis_job_create_path("/v1/analysis/jobs/run"));
289 assert!(!is_async_analysis_job_create_path("/v1/agent/context"));
290 }
291}