Skip to main content

raps_cli/commands/
api.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Custom API call commands
5//!
6//! Execute arbitrary HTTP requests to APS API endpoints using the current authentication.
7//! Supports GET, POST, PUT, PATCH, DELETE methods with query parameters, request bodies,
8//! custom headers, and multiple output formats.
9
10use anyhow::{Context, Result, bail};
11use clap::Subcommand;
12use colored::Colorize;
13use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderName, HeaderValue};
14use reqwest::{Client, Method, Response, StatusCode};
15use serde::Serialize;
16use serde_json::Value;
17use std::path::PathBuf;
18use std::str::FromStr;
19
20use crate::output::OutputFormat;
21use raps_kernel::auth::AuthClient;
22use raps_kernel::config::Config;
23use raps_kernel::http::{HttpClientConfig, is_allowed_url};
24
25/// HTTP methods supported by the custom API command
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum HttpMethod {
28    Get,
29    Post,
30    Put,
31    Patch,
32    Delete,
33}
34
35impl HttpMethod {
36    /// Convert to reqwest Method
37    fn as_reqwest_method(self) -> Method {
38        match self {
39            HttpMethod::Get => Method::GET,
40            HttpMethod::Post => Method::POST,
41            HttpMethod::Put => Method::PUT,
42            HttpMethod::Patch => Method::PATCH,
43            HttpMethod::Delete => Method::DELETE,
44        }
45    }
46
47    /// Check if this method supports a request body
48    fn supports_body(self) -> bool {
49        matches!(self, HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch)
50    }
51}
52
53/// Error response structure
54#[derive(Debug, Clone, Serialize)]
55pub struct ApiError {
56    pub status_code: u16,
57    pub error_type: String,
58    pub message: String,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub details: Option<Value>,
61}
62
63/// Custom API call commands
64#[derive(Debug, Subcommand)]
65pub enum ApiCommands {
66    /// Execute HTTP GET request
67    Get {
68        /// API endpoint path (e.g., /oss/v2/buckets)
69        endpoint: String,
70
71        /// Query parameter (KEY=VALUE, repeatable)
72        #[arg(long = "query", value_parser = parse_key_value)]
73        query: Vec<(String, String)>,
74
75        /// Custom header (KEY:VALUE, repeatable)
76        #[arg(short = 'H', long = "header", value_parser = parse_header)]
77        header: Vec<(String, String)>,
78
79        /// Save response to file
80        #[arg(long = "out-file")]
81        out_file: Option<PathBuf>,
82
83        /// Show response headers and status
84        #[arg(short, long)]
85        verbose: bool,
86    },
87
88    /// Execute HTTP POST request
89    Post {
90        /// API endpoint path (e.g., /oss/v2/buckets)
91        endpoint: String,
92
93        /// Inline JSON request body
94        #[arg(short, long, conflicts_with = "data_file")]
95        data: Option<String>,
96
97        /// Read request body from file (use `-` for stdin)
98        #[arg(short = 'f', long = "data-file", conflicts_with = "data")]
99        data_file: Option<PathBuf>,
100
101        /// Query parameter (KEY=VALUE, repeatable)
102        #[arg(long = "query", value_parser = parse_key_value)]
103        query: Vec<(String, String)>,
104
105        /// Custom header (KEY:VALUE, repeatable)
106        #[arg(short = 'H', long = "header", value_parser = parse_header)]
107        header: Vec<(String, String)>,
108
109        /// Save response to file (use `-` for stdout)
110        #[arg(long = "out-file")]
111        out_file: Option<PathBuf>,
112
113        /// Show response headers and status
114        #[arg(short, long)]
115        verbose: bool,
116    },
117
118    /// Execute HTTP PUT request
119    Put {
120        /// API endpoint path
121        endpoint: String,
122
123        /// Inline JSON request body
124        #[arg(short, long, conflicts_with = "data_file")]
125        data: Option<String>,
126
127        /// Read request body from file (use `-` for stdin)
128        #[arg(short = 'f', long = "data-file", conflicts_with = "data")]
129        data_file: Option<PathBuf>,
130
131        /// Query parameter (KEY=VALUE, repeatable)
132        #[arg(long = "query", value_parser = parse_key_value)]
133        query: Vec<(String, String)>,
134
135        /// Custom header (KEY:VALUE, repeatable)
136        #[arg(short = 'H', long = "header", value_parser = parse_header)]
137        header: Vec<(String, String)>,
138
139        /// Save response to file (use `-` for stdout)
140        #[arg(long = "out-file")]
141        out_file: Option<PathBuf>,
142
143        /// Show response headers and status
144        #[arg(short, long)]
145        verbose: bool,
146    },
147
148    /// Execute HTTP PATCH request
149    Patch {
150        /// API endpoint path
151        endpoint: String,
152
153        /// Inline JSON request body
154        #[arg(short, long, conflicts_with = "data_file")]
155        data: Option<String>,
156
157        /// Read request body from file (use `-` for stdin)
158        #[arg(short = 'f', long = "data-file", conflicts_with = "data")]
159        data_file: Option<PathBuf>,
160
161        /// Query parameter (KEY=VALUE, repeatable)
162        #[arg(long = "query", value_parser = parse_key_value)]
163        query: Vec<(String, String)>,
164
165        /// Custom header (KEY:VALUE, repeatable)
166        #[arg(short = 'H', long = "header", value_parser = parse_header)]
167        header: Vec<(String, String)>,
168
169        /// Save response to file (use `-` for stdout)
170        #[arg(long = "out-file")]
171        out_file: Option<PathBuf>,
172
173        /// Show response headers and status
174        #[arg(short, long)]
175        verbose: bool,
176    },
177
178    /// Execute HTTP DELETE request
179    Delete {
180        /// API endpoint path
181        endpoint: String,
182
183        /// Query parameter (KEY=VALUE, repeatable)
184        #[arg(long = "query", value_parser = parse_key_value)]
185        query: Vec<(String, String)>,
186
187        /// Custom header (KEY:VALUE, repeatable)
188        #[arg(short = 'H', long = "header", value_parser = parse_header)]
189        header: Vec<(String, String)>,
190
191        /// Show response headers and status
192        #[arg(short, long)]
193        verbose: bool,
194    },
195}
196
197impl ApiCommands {
198    /// Execute the API command
199    pub async fn execute(
200        self,
201        config: &Config,
202        auth_client: &AuthClient,
203        http_config: &HttpClientConfig,
204        output_format: OutputFormat,
205    ) -> Result<()> {
206        // Extract common parameters based on variant
207        let (method, endpoint, query, headers, data, data_file, output_file, verbose) = match self {
208            ApiCommands::Get {
209                endpoint,
210                query,
211                header,
212                out_file,
213                verbose,
214            } => (
215                HttpMethod::Get,
216                endpoint,
217                query,
218                header,
219                None,
220                None,
221                out_file,
222                verbose,
223            ),
224
225            ApiCommands::Post {
226                endpoint,
227                data,
228                data_file,
229                query,
230                header,
231                out_file,
232                verbose,
233            } => (
234                HttpMethod::Post,
235                endpoint,
236                query,
237                header,
238                data,
239                data_file,
240                out_file,
241                verbose,
242            ),
243
244            ApiCommands::Put {
245                endpoint,
246                data,
247                data_file,
248                query,
249                header,
250                out_file,
251                verbose,
252            } => (
253                HttpMethod::Put,
254                endpoint,
255                query,
256                header,
257                data,
258                data_file,
259                out_file,
260                verbose,
261            ),
262
263            ApiCommands::Patch {
264                endpoint,
265                data,
266                data_file,
267                query,
268                header,
269                out_file,
270                verbose,
271            } => (
272                HttpMethod::Patch,
273                endpoint,
274                query,
275                header,
276                data,
277                data_file,
278                out_file,
279                verbose,
280            ),
281
282            ApiCommands::Delete {
283                endpoint,
284                query,
285                header,
286                verbose,
287            } => (
288                HttpMethod::Delete,
289                endpoint,
290                query,
291                header,
292                None,
293                None,
294                None,
295                verbose,
296            ),
297        };
298
299        // Build full URL from endpoint
300        let full_url = build_url(&config.base_url, &endpoint, &query)?;
301
302        // Validate URL is allowed
303        if !is_allowed_url(&full_url) {
304            bail!(
305                "Only APS API endpoints are allowed. Use a path like /oss/v2/buckets\n\
306                 Hint: External URLs are not permitted for security reasons."
307            );
308        }
309
310        // Parse request body if provided
311        let body = parse_body(method, data, data_file)?;
312
313        // Get auth token
314        let token = get_auth_token(auth_client).await?;
315
316        // Build and execute request
317        let client = http_config.create_client()?;
318        let response =
319            execute_request(&client, method, &full_url, &token, &headers, body.as_ref()).await?;
320
321        // Handle response
322        handle_response(response, output_format, output_file, verbose).await
323    }
324}
325
326/// Parse KEY=VALUE format for query parameters
327fn parse_key_value(s: &str) -> Result<(String, String), String> {
328    let parts: Vec<&str> = s.splitn(2, '=').collect();
329    if parts.len() != 2 {
330        return Err(format!("Invalid format '{}'. Expected KEY=VALUE", s));
331    }
332    Ok((parts[0].to_string(), parts[1].to_string()))
333}
334
335/// Parse KEY:VALUE format for headers
336fn parse_header(s: &str) -> Result<(String, String), String> {
337    let parts: Vec<&str> = s.splitn(2, ':').collect();
338    if parts.len() != 2 {
339        return Err(format!(
340            "Invalid header format '{}'. Expected KEY:VALUE (e.g., Content-Type:application/json)",
341            s
342        ));
343    }
344    Ok((parts[0].trim().to_string(), parts[1].trim().to_string()))
345}
346
347/// Build full URL from base URL, endpoint, and query parameters.
348/// IMPORTANT: Callers must validate the result with `is_allowed_url` before use.
349fn build_url(base_url: &str, endpoint: &str, query_params: &[(String, String)]) -> Result<String> {
350    // Handle relative vs absolute endpoints
351    let mut url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
352        endpoint.to_string()
353    } else {
354        // Ensure endpoint starts with /
355        let endpoint = if endpoint.starts_with('/') {
356            endpoint.to_string()
357        } else {
358            format!("/{}", endpoint)
359        };
360        format!("{}{}", base_url.trim_end_matches('/'), endpoint)
361    };
362
363    // Append query parameters
364    if !query_params.is_empty() {
365        let query_string: String = query_params
366            .iter()
367            .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
368            .collect::<Vec<_>>()
369            .join("&");
370
371        if url.contains('?') {
372            url = format!("{}&{}", url, query_string);
373        } else {
374            url = format!("{}?{}", url, query_string);
375        }
376    }
377
378    Ok(url)
379}
380
381/// Parse and validate request body from --data or --data-file
382fn parse_body(
383    method: HttpMethod,
384    data: Option<String>,
385    data_file: Option<PathBuf>,
386) -> Result<Option<Value>> {
387    // Check if body is allowed for this method
388    if !method.supports_body() {
389        if data.is_some() || data_file.is_some() {
390            bail!(
391                "Request body is not allowed for {} requests",
392                match method {
393                    HttpMethod::Get => "GET",
394                    HttpMethod::Delete => "DELETE",
395                    _ => unreachable!(),
396                }
397            );
398        }
399        return Ok(None);
400    }
401
402    // Read body from data, file, or stdin
403    let body_str =
404        if let Some(data) = data {
405            Some(data)
406        } else if let Some(path) = data_file {
407            if path.as_os_str() == "-" {
408                use std::io::Read;
409                let mut buf = String::new();
410                std::io::stdin()
411                    .lock()
412                    .read_to_string(&mut buf)
413                    .context("Failed to read request body from stdin")?;
414                Some(buf)
415            } else {
416                Some(std::fs::read_to_string(&path).with_context(|| {
417                    format!("Failed to read body from file: {}", path.display())
418                })?)
419            }
420        } else {
421            None
422        };
423
424    // Parse and validate JSON
425    if let Some(body_str) = body_str {
426        let value: Value =
427            serde_json::from_str(&body_str).with_context(|| "Invalid JSON in request body")?;
428        Ok(Some(value))
429    } else {
430        Ok(None)
431    }
432}
433
434/// Get authentication token from auth client
435async fn get_auth_token(auth_client: &AuthClient) -> Result<String> {
436    // Try 3-legged token first, fall back to 2-legged
437    match auth_client.get_3leg_token().await {
438        Ok(token) => Ok(token),
439        Err(_) => {
440            // Try 2-legged token
441            auth_client.get_token().await.with_context(|| {
442                "Not authenticated. Run 'raps auth login' first.\n\
443                 Hint: Use 'raps auth login' for 3-legged auth or configure client credentials for 2-legged auth."
444            })
445        }
446    }
447}
448
449/// Execute HTTP request with authentication
450async fn execute_request(
451    client: &Client,
452    method: HttpMethod,
453    url: &str,
454    token: &str,
455    custom_headers: &[(String, String)],
456    body: Option<&Value>,
457) -> Result<Response> {
458    tracing::info!(
459        method = %method.as_reqwest_method(),
460        url = %raps_kernel::logging::redact_secrets(url),
461        "HTTP request"
462    );
463
464    let mut request = client.request(method.as_reqwest_method(), url);
465
466    // Add authorization header
467    request = request.header(AUTHORIZATION, format!("Bearer {}", token));
468
469    // Add custom headers (but prevent overriding Authorization)
470    for (key, value) in custom_headers {
471        if key.to_lowercase() == "authorization" {
472            tracing::warn!("Ignoring attempt to override Authorization header");
473            continue;
474        }
475        match (HeaderName::from_str(key), HeaderValue::from_str(value)) {
476            (Ok(name), Ok(val)) => {
477                request = request.header(name, val);
478            }
479            (Err(e), _) => tracing::warn!("Skipping invalid header name '{}': {}", key, e),
480            (_, Err(e)) => tracing::warn!("Skipping invalid header value for '{}': {}", key, e),
481        }
482    }
483
484    // Add body if present
485    if let Some(body) = body {
486        request = request.header(CONTENT_TYPE, "application/json").json(body);
487    }
488
489    let response = request.send().await.context("Failed to send request")?;
490
491    Ok(response)
492}
493
494/// Handle HTTP response and format output
495async fn handle_response(
496    response: Response,
497    output_format: OutputFormat,
498    output_file: Option<PathBuf>,
499    verbose: bool,
500) -> Result<()> {
501    let status = response.status();
502    let status_code = status.as_u16();
503
504    // Collect headers for verbose output
505    let headers: Vec<(String, String)> = response
506        .headers()
507        .iter()
508        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
509        .collect();
510
511    let content_type = response
512        .headers()
513        .get(CONTENT_TYPE)
514        .and_then(|v| v.to_str().ok())
515        .unwrap_or("application/octet-stream")
516        .to_string();
517
518    // Print verbose output if requested
519    if verbose {
520        println!(
521            "{}",
522            format!(
523                "HTTP/1.1 {} {}",
524                status_code,
525                status.canonical_reason().unwrap_or("")
526            )
527            .cyan()
528        );
529        for (key, value) in &headers {
530            println!("{}: {}", key.dimmed(), value);
531        }
532        println!();
533    }
534
535    // Handle response based on content type and status
536    if content_type.contains("application/json") {
537        let body_text = response
538            .text()
539            .await
540            .context("Failed to read response body")?;
541
542        // Try to parse as JSON
543        let json: Result<Value, _> = serde_json::from_str(&body_text);
544
545        match json {
546            Ok(value) => {
547                if status.is_success() {
548                    // Save to file if requested
549                    if let Some(ref path) = output_file {
550                        let pretty = serde_json::to_string_pretty(&value)?;
551                        if path.as_os_str() == "-" {
552                            print!("{}", pretty);
553                        } else {
554                            std::fs::write(path, &pretty)?;
555                            eprintln!("{} {}", "Saved to:".green(), path.display());
556                        }
557                    } else {
558                        // Output using configured format
559                        output_format.write(&value)?;
560                    }
561                    Ok(())
562                } else {
563                    // Error response
564                    let error = ApiError {
565                        status_code,
566                        error_type: categorize_error(status_code),
567                        message: extract_error_message(&value, status),
568                        details: Some(value),
569                    };
570                    output_format.write(&error)?;
571                    bail!(
572                        "API error ({}): {}",
573                        status_code,
574                        extract_error_message(
575                            error.details.as_ref().unwrap_or(&Value::Null),
576                            status
577                        )
578                    );
579                }
580            }
581            Err(_) => {
582                // JSON parse failed, treat as text
583                if status.is_success() {
584                    if let Some(ref path) = output_file {
585                        if path.as_os_str() == "-" {
586                            print!("{}", body_text);
587                        } else {
588                            std::fs::write(path, &body_text)?;
589                            eprintln!("{} {}", "Saved to:".green(), path.display());
590                        }
591                    } else {
592                        println!("{}", body_text);
593                    }
594                    Ok(())
595                } else {
596                    bail!("API error ({}): {}", status_code, body_text);
597                }
598            }
599        }
600    } else if content_type.starts_with("text/") || content_type.contains("xml") {
601        // Text response
602        let body_text = response
603            .text()
604            .await
605            .context("Failed to read response body")?;
606
607        if status.is_success() {
608            if let Some(path) = output_file {
609                if path.as_os_str() == "-" {
610                    print!("{}", body_text);
611                } else {
612                    std::fs::write(&path, &body_text)?;
613                    eprintln!("{} {}", "Saved to:".green(), path.display());
614                }
615            } else {
616                println!("{}", body_text);
617            }
618            Ok(())
619        } else {
620            bail!("API error ({}): {}", status_code, body_text);
621        }
622    } else {
623        // Binary response
624        let bytes = response
625            .bytes()
626            .await
627            .context("Failed to read response body")?;
628
629        if status.is_success() {
630            if let Some(path) = output_file {
631                if path.as_os_str() == "-" {
632                    use std::io::Write;
633                    std::io::stdout()
634                        .lock()
635                        .write_all(&bytes)
636                        .context("Failed to write to stdout")?;
637                } else {
638                    std::fs::write(&path, &bytes)?;
639                    eprintln!(
640                        "{} {} ({} bytes)",
641                        "Saved to:".green(),
642                        path.display(),
643                        bytes.len()
644                    );
645                }
646                Ok(())
647            } else if !std::io::IsTerminal::is_terminal(&std::io::stdout()) {
648                // stdout is piped — write binary directly
649                use std::io::Write;
650                std::io::stdout()
651                    .lock()
652                    .write_all(&bytes)
653                    .context("Failed to write to stdout")?;
654                Ok(())
655            } else {
656                bail!(
657                    "Binary response received. Use --out-file to save to a file (or pipe stdout).\n\
658                     Content-Type: {}, Size: {} bytes",
659                    content_type,
660                    bytes.len()
661                );
662            }
663        } else {
664            bail!(
665                "API error ({}): binary error response ({} bytes)",
666                status_code,
667                bytes.len()
668            );
669        }
670    }
671}
672
673/// Categorize error based on status code
674fn categorize_error(status_code: u16) -> String {
675    match status_code {
676        401 | 403 => "authentication".to_string(),
677        400 | 422 => "validation".to_string(),
678        404 => "not_found".to_string(),
679        429 => "rate_limited".to_string(),
680        500..=599 => "server_error".to_string(),
681        _ => "error".to_string(),
682    }
683}
684
685/// Extract error message from JSON response
686fn extract_error_message(value: &Value, status: StatusCode) -> String {
687    // Try common error message fields
688    if let Some(msg) = value.get("message").and_then(|v| v.as_str()) {
689        return msg.to_string();
690    }
691    if let Some(msg) = value.get("error").and_then(|v| v.as_str()) {
692        return msg.to_string();
693    }
694    if let Some(msg) = value.get("reason").and_then(|v| v.as_str()) {
695        return msg.to_string();
696    }
697    if let Some(msg) = value.get("developerMessage").and_then(|v| v.as_str()) {
698        return msg.to_string();
699    }
700
701    // Fallback to status text
702    status
703        .canonical_reason()
704        .unwrap_or("Request failed")
705        .to_string()
706}