use clap::Args;
use serde::Deserialize;
use serde_json::Value;
use crate::util::{
admin_surface_enabled, api_request, exit_error, is_admin_api_path, read_json_from_file,
resolve_token,
};
#[derive(Args)]
pub struct ExecArgs {
#[arg(long, default_value = "-")]
request_file: String,
}
#[derive(Debug, Deserialize)]
struct ExecEnvelope {
method: String,
path: String,
#[serde(default)]
body: Option<Value>,
#[serde(default)]
query: Option<Value>,
#[serde(default)]
headers: Option<Value>,
#[serde(default)]
include: bool,
#[serde(default)]
raw: bool,
#[serde(default)]
no_auth: Option<bool>,
#[serde(default)]
allow_async_analysis_job: bool,
}
pub async fn run(api_url: &str, cli_no_auth: bool, args: ExecArgs) -> i32 {
let payload = match read_json_from_file(&args.request_file) {
Ok(value) => value,
Err(err) => exit_error(
&err,
Some("Provide a valid JSON envelope via --request-file (or '-' for stdin)."),
),
};
let envelope: ExecEnvelope = match serde_json::from_value(payload) {
Ok(value) => value,
Err(err) => exit_error(
&format!("Invalid exec envelope JSON: {err}"),
Some(
"Envelope fields: method, path, body?, query?, headers?, include?, raw?, no_auth?, allow_async_analysis_job?",
),
),
};
let method = parse_method(&envelope.method);
let path = normalize_path(&envelope.path);
if is_admin_api_path(&path) && !admin_surface_enabled() {
exit_error(
"Admin API paths are disabled in CLI by default.",
Some("Set KURA_ENABLE_ADMIN_SURFACE=1 only in trusted developer/admin sessions."),
);
}
if is_async_analysis_job_create_path(&path)
&& method == reqwest::Method::POST
&& !envelope.allow_async_analysis_job
{
exit_error(
"Direct async analysis-job creation via POST /v1/analysis/jobs is blocked by default.",
Some(
"Use `kura analysis run --objective \"...\"` for user-facing analyses. Set allow_async_analysis_job=true only for explicit background-job workflows.",
),
);
}
let query = parse_key_value_entries(envelope.query, "query").unwrap_or_else(|err| {
exit_error(
&err,
Some(
"Use either an object {\"a\":\"b\"} or an array of {\"key\":\"a\",\"value\":\"b\"} entries.",
),
)
});
let headers = parse_key_value_entries(envelope.headers, "headers").unwrap_or_else(|err| {
exit_error(
&err,
Some(
"Use either an object {\"Header\":\"Value\"} or an array of {\"key\":\"Header\",\"value\":\"Value\"} entries.",
),
)
});
let no_auth = envelope.no_auth.unwrap_or(cli_no_auth);
let token = if no_auth {
None
} else {
match resolve_token(api_url).await {
Ok(token) => Some(token),
Err(err) => exit_error(
&err.to_string(),
Some("Run `kura login`, set KURA_API_KEY, or set no_auth=true in the envelope."),
),
}
};
api_request(
api_url,
method,
&path,
token.as_deref(),
envelope.body,
&query,
&headers,
envelope.raw,
envelope.include,
)
.await
}
fn parse_method(raw: &str) -> reqwest::Method {
match raw.trim().to_ascii_uppercase().as_str() {
"GET" => reqwest::Method::GET,
"POST" => reqwest::Method::POST,
"PUT" => reqwest::Method::PUT,
"DELETE" => reqwest::Method::DELETE,
"PATCH" => reqwest::Method::PATCH,
"HEAD" => reqwest::Method::HEAD,
"OPTIONS" => reqwest::Method::OPTIONS,
other => exit_error(
&format!("Unknown HTTP method in exec envelope: {other}"),
Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
),
}
}
fn normalize_path(raw: &str) -> String {
let trimmed = raw.trim();
if trimmed.is_empty() {
exit_error(
"Exec envelope field 'path' must not be empty.",
Some("Example: \"path\": \"/v1/events\""),
);
}
if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
fn parse_key_value_entries(
input: Option<Value>,
field_name: &str,
) -> Result<Vec<(String, String)>, String> {
let Some(input) = input else {
return Ok(Vec::new());
};
match input {
Value::Null => Ok(Vec::new()),
Value::Object(map) => {
let mut out = Vec::with_capacity(map.len());
for (key, value) in map {
let value = value.as_str().ok_or_else(|| {
format!(
"Exec envelope field '{field_name}.{key}' must be a string, got {value}."
)
})?;
out.push((key, value.to_string()));
}
Ok(out)
}
Value::Array(items) => {
let mut out = Vec::with_capacity(items.len());
for (index, item) in items.into_iter().enumerate() {
match item {
Value::Object(obj) => {
let key = obj
.get("key")
.and_then(|value| value.as_str())
.ok_or_else(|| {
format!(
"Exec envelope field '{field_name}[{index}].key' must be a string."
)
})?;
let value = obj
.get("value")
.and_then(|value| value.as_str())
.ok_or_else(|| {
format!(
"Exec envelope field '{field_name}[{index}].value' must be a string."
)
})?;
out.push((key.to_string(), value.to_string()));
}
Value::Array(pair) if pair.len() == 2 => {
let key = pair[0].as_str().ok_or_else(|| {
format!(
"Exec envelope field '{field_name}[{index}][0]' must be a string."
)
})?;
let value = pair[1].as_str().ok_or_else(|| {
format!(
"Exec envelope field '{field_name}[{index}][1]' must be a string."
)
})?;
out.push((key.to_string(), value.to_string()));
}
_ => {
return Err(format!(
"Exec envelope field '{field_name}' supports object entries, {{\"key\":...,\"value\":...}} objects, or [key,value] pairs."
));
}
}
}
Ok(out)
}
other => Err(format!(
"Exec envelope field '{field_name}' must be an object or array, got {other}."
)),
}
}
fn is_async_analysis_job_create_path(path: &str) -> bool {
let trimmed = path.trim();
if trimmed.is_empty() {
return false;
}
let normalized = if trimmed.starts_with('/') {
trimmed.to_ascii_lowercase()
} else {
format!("/{trimmed}").to_ascii_lowercase()
};
normalized == "/v1/analysis/jobs" || normalized == "/v1/analysis/jobs/"
}
#[cfg(test)]
mod tests {
use super::{is_async_analysis_job_create_path, parse_key_value_entries};
use serde_json::json;
#[test]
fn parse_key_value_entries_accepts_object_shape() {
let parsed = parse_key_value_entries(Some(json!({"a": "b", "x": "y"})), "query").unwrap();
assert_eq!(
parsed,
vec![
("a".to_string(), "b".to_string()),
("x".to_string(), "y".to_string())
]
);
}
#[test]
fn parse_key_value_entries_accepts_object_array_shape() {
let parsed = parse_key_value_entries(
Some(json!([
{"key": "a", "value": "b"},
{"key": "x", "value": "y"}
])),
"headers",
)
.unwrap();
assert_eq!(
parsed,
vec![
("a".to_string(), "b".to_string()),
("x".to_string(), "y".to_string())
]
);
}
#[test]
fn parse_key_value_entries_rejects_non_string_values() {
let err = parse_key_value_entries(Some(json!({"a": 1})), "query").unwrap_err();
assert!(err.contains("must be a string"));
}
#[test]
fn async_analysis_job_create_path_detection_is_exact() {
assert!(is_async_analysis_job_create_path("/v1/analysis/jobs"));
assert!(is_async_analysis_job_create_path("v1/analysis/jobs"));
assert!(!is_async_analysis_job_create_path("/v1/analysis/jobs/run"));
assert!(!is_async_analysis_job_create_path("/v1/agent/context"));
}
}