1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum HttpMethod {
28 Get,
29 Post,
30 Put,
31 Patch,
32 Delete,
33}
34
35impl HttpMethod {
36 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 fn supports_body(self) -> bool {
49 matches!(self, HttpMethod::Post | HttpMethod::Put | HttpMethod::Patch)
50 }
51}
52
53#[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#[derive(Debug, Subcommand)]
65pub enum ApiCommands {
66 Get {
68 endpoint: String,
70
71 #[arg(long = "query", value_parser = parse_key_value)]
73 query: Vec<(String, String)>,
74
75 #[arg(short = 'H', long = "header", value_parser = parse_header)]
77 header: Vec<(String, String)>,
78
79 #[arg(long = "out-file")]
81 out_file: Option<PathBuf>,
82
83 #[arg(short, long)]
85 verbose: bool,
86 },
87
88 Post {
90 endpoint: String,
92
93 #[arg(short, long, conflicts_with = "data_file")]
95 data: Option<String>,
96
97 #[arg(short = 'f', long = "data-file", conflicts_with = "data")]
99 data_file: Option<PathBuf>,
100
101 #[arg(long = "query", value_parser = parse_key_value)]
103 query: Vec<(String, String)>,
104
105 #[arg(short = 'H', long = "header", value_parser = parse_header)]
107 header: Vec<(String, String)>,
108
109 #[arg(long = "out-file")]
111 out_file: Option<PathBuf>,
112
113 #[arg(short, long)]
115 verbose: bool,
116 },
117
118 Put {
120 endpoint: String,
122
123 #[arg(short, long, conflicts_with = "data_file")]
125 data: Option<String>,
126
127 #[arg(short = 'f', long = "data-file", conflicts_with = "data")]
129 data_file: Option<PathBuf>,
130
131 #[arg(long = "query", value_parser = parse_key_value)]
133 query: Vec<(String, String)>,
134
135 #[arg(short = 'H', long = "header", value_parser = parse_header)]
137 header: Vec<(String, String)>,
138
139 #[arg(long = "out-file")]
141 out_file: Option<PathBuf>,
142
143 #[arg(short, long)]
145 verbose: bool,
146 },
147
148 Patch {
150 endpoint: String,
152
153 #[arg(short, long, conflicts_with = "data_file")]
155 data: Option<String>,
156
157 #[arg(short = 'f', long = "data-file", conflicts_with = "data")]
159 data_file: Option<PathBuf>,
160
161 #[arg(long = "query", value_parser = parse_key_value)]
163 query: Vec<(String, String)>,
164
165 #[arg(short = 'H', long = "header", value_parser = parse_header)]
167 header: Vec<(String, String)>,
168
169 #[arg(long = "out-file")]
171 out_file: Option<PathBuf>,
172
173 #[arg(short, long)]
175 verbose: bool,
176 },
177
178 Delete {
180 endpoint: String,
182
183 #[arg(long = "query", value_parser = parse_key_value)]
185 query: Vec<(String, String)>,
186
187 #[arg(short = 'H', long = "header", value_parser = parse_header)]
189 header: Vec<(String, String)>,
190
191 #[arg(short, long)]
193 verbose: bool,
194 },
195}
196
197impl ApiCommands {
198 pub async fn execute(
200 self,
201 config: &Config,
202 auth_client: &AuthClient,
203 http_config: &HttpClientConfig,
204 output_format: OutputFormat,
205 ) -> Result<()> {
206 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 let full_url = build_url(&config.base_url, &endpoint, &query)?;
301
302 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 let body = parse_body(method, data, data_file)?;
312
313 let token = get_auth_token(auth_client).await?;
315
316 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(response, output_format, output_file, verbose).await
323 }
324}
325
326fn 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
335fn 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
347fn build_url(base_url: &str, endpoint: &str, query_params: &[(String, String)]) -> Result<String> {
350 let mut url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
352 endpoint.to_string()
353 } else {
354 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 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
381fn parse_body(
383 method: HttpMethod,
384 data: Option<String>,
385 data_file: Option<PathBuf>,
386) -> Result<Option<Value>> {
387 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 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 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
434async fn get_auth_token(auth_client: &AuthClient) -> Result<String> {
436 match auth_client.get_3leg_token().await {
438 Ok(token) => Ok(token),
439 Err(_) => {
440 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
449async 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 request = request.header(AUTHORIZATION, format!("Bearer {}", token));
468
469 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 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
494async 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 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 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 if content_type.contains("application/json") {
537 let body_text = response
538 .text()
539 .await
540 .context("Failed to read response body")?;
541
542 let json: Result<Value, _> = serde_json::from_str(&body_text);
544
545 match json {
546 Ok(value) => {
547 if status.is_success() {
548 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_format.write(&value)?;
560 }
561 Ok(())
562 } else {
563 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 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 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 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 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
673fn 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
685fn extract_error_message(value: &Value, status: StatusCode) -> String {
687 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 status
703 .canonical_reason()
704 .unwrap_or("Request failed")
705 .to_string()
706}