Skip to main content

kura_cli/commands/
api.rs

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    /// HTTP method (GET, POST, PUT, DELETE, PATCH)
11    pub method: String,
12
13    /// API path (e.g. /v1/events)
14    pub path: String,
15
16    /// Request body as JSON string
17    #[arg(long, short = 'd')]
18    pub data: Option<String>,
19
20    /// Read request body from file (use '-' for stdin)
21    #[arg(long, short = 'f', conflicts_with = "data")]
22    pub data_file: Option<String>,
23
24    /// Query parameters (repeatable: key=value)
25    #[arg(long, short = 'q')]
26    pub query: Vec<String>,
27
28    /// Extra headers (repeatable: Key:Value)
29    #[arg(long, short = 'H')]
30    pub header: Vec<String>,
31
32    /// Skip pretty-printing (raw JSON for piping)
33    #[arg(long)]
34    pub raw: bool,
35
36    /// Include HTTP status and headers in response wrapper
37    #[arg(long, short = 'i')]
38    pub include: bool,
39
40    /// Skip authentication (for public endpoints like /health)
41    #[arg(long)]
42    pub no_auth: bool,
43
44    /// Allow low-level async analysis job creation via POST /v1/analysis/jobs (advanced/explicit background use only)
45    #[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    // Parse method
58    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    // Parse query parameters
85    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    // Parse extra headers
97    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    // Resolve body
109    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    // Resolve auth
127    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}