use clap::Args;
use crate::util::{
admin_surface_enabled, api_request, exit_error, is_admin_api_path, read_json_from_file,
resolve_token,
};
#[derive(Args)]
pub struct ApiArgs {
pub method: String,
pub path: String,
#[arg(long, short = 'd')]
pub data: Option<String>,
#[arg(long, short = 'f', conflicts_with = "data")]
pub data_file: Option<String>,
#[arg(long, short = 'q')]
pub query: Vec<String>,
#[arg(long, short = 'H')]
pub header: Vec<String>,
#[arg(long)]
pub raw: bool,
#[arg(long, short = 'i')]
pub include: bool,
#[arg(long)]
pub no_auth: bool,
#[arg(long)]
pub allow_async_analysis_job: bool,
}
pub async fn run(api_url: &str, args: ApiArgs) -> i32 {
if is_admin_api_path(&args.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."),
);
}
let method = match args.method.to_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: {other}"),
Some("Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS"),
),
};
if is_async_analysis_job_create_path(&args.path)
&& method == reqwest::Method::POST
&& !args.allow_async_analysis_job
{
exit_error(
"Direct async analysis-job creation via `kura api POST /v1/analysis/jobs` is blocked by default.",
Some(
"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.",
),
);
}
let mut query = Vec::new();
for q in &args.query {
match q.split_once('=') {
Some((k, v)) => query.push((k.to_string(), v.to_string())),
None => exit_error(
&format!("Invalid query parameter: '{q}'"),
Some("Format: key=value, e.g. --query event_type=set.logged"),
),
}
}
let mut headers = Vec::new();
for h in &args.header {
match h.split_once(':') {
Some((k, v)) => headers.push((k.trim().to_string(), v.trim().to_string())),
None => exit_error(
&format!("Invalid header: '{h}'"),
Some("Format: Key:Value, e.g. --header Content-Type:application/json"),
),
}
}
let body = if let Some(ref d) = args.data {
match serde_json::from_str(d) {
Ok(v) => Some(v),
Err(e) => exit_error(
&format!("Invalid JSON in --data: {e}"),
Some("Provide valid JSON string"),
),
}
} else if let Some(ref f) = args.data_file {
match read_json_from_file(f) {
Ok(v) => Some(v),
Err(e) => exit_error(&e, Some("Provide a valid JSON file or use '-' for stdin")),
}
} else {
None
};
let token = if args.no_auth {
None
} else {
match resolve_token(api_url).await {
Ok(t) => Some(t),
Err(e) => exit_error(
&e.to_string(),
Some("Run `kura login`, set KURA_API_KEY, or use --no-auth for public endpoints"),
),
}
};
api_request(
api_url,
method,
&args.path,
token.as_deref(),
body,
&query,
&headers,
args.raw,
args.include,
)
.await
}
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;
#[test]
fn test_query_parsing() {
let input = "event_type=set.logged";
let (k, v) = input.split_once('=').unwrap();
assert_eq!(k, "event_type");
assert_eq!(v, "set.logged");
}
#[test]
fn test_header_parsing() {
let input = "Content-Type: application/json";
let (k, v) = input.split_once(':').unwrap();
assert_eq!(k.trim(), "Content-Type");
assert_eq!(v.trim(), "application/json");
}
#[test]
fn test_method_parsing() {
for m in &[
"get", "GET", "Get", "post", "POST", "delete", "DELETE", "put", "patch",
] {
let parsed = match m.to_uppercase().as_str() {
"GET" => Some(reqwest::Method::GET),
"POST" => Some(reqwest::Method::POST),
"PUT" => Some(reqwest::Method::PUT),
"DELETE" => Some(reqwest::Method::DELETE),
"PATCH" => Some(reqwest::Method::PATCH),
_ => None,
};
assert!(parsed.is_some(), "Failed to parse method: {m}");
}
}
#[test]
fn async_analysis_job_create_path_detection_is_exact_to_create_endpoint() {
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/"));
assert!(!is_async_analysis_job_create_path("/v1/analysis/jobs/run"));
assert!(!is_async_analysis_job_create_path("/v1/analysis/jobs/123"));
assert!(!is_async_analysis_job_create_path("/v1/agent/context"));
}
}