1#![allow(clippy::print_stdout, clippy::print_stderr)]
9
10use crate::cli::ApiCommand;
11use crate::client::Client;
12use anyhow::Context;
13use std::fmt::Write as FmtWrite;
14use std::io::Read;
15
16pub fn run(cmd: &ApiCommand, global_hostname: Option<&str>) -> anyhow::Result<()> {
22 match cmd {
23 ApiCommand::Rest {
24 endpoint,
25 method,
26 fields,
27 raw_fields,
28 headers,
29 input,
30 paginate,
31 hostname,
32 jq,
33 template,
34 silent,
35 include,
36 } => run_rest(
37 endpoint,
38 method,
39 fields,
40 raw_fields,
41 headers,
42 input.as_deref(),
43 *paginate,
44 hostname.as_deref(),
45 jq.as_deref(),
46 template.as_deref(),
47 *silent,
48 *include,
49 global_hostname,
50 ),
51 ApiCommand::Graphql {
52 query,
53 fields,
54 hostname,
55 jq,
56 template,
57 } => run_graphql(
58 query.as_deref(),
59 fields,
60 hostname.as_deref(),
61 jq.as_deref(),
62 template.as_deref(),
63 global_hostname,
64 ),
65 }
66}
67
68#[allow(clippy::too_many_arguments)]
74fn run_rest(
75 endpoint: &str,
76 method: &str,
77 fields: &[String],
78 raw_fields: &[String],
79 headers: &[String],
80 input: Option<&str>,
81 paginate: bool,
82 hostname: Option<&str>,
83 jq: Option<&str>,
84 template: Option<&str>,
85 silent: bool,
86 include: bool,
87 global_hostname: Option<&str>,
88) -> anyhow::Result<()> {
89 let host = hostname.or(global_hostname).unwrap_or("github.com");
90
91 let client = Client::new(host).context("failed to create HTTP client")?;
92
93 let method = if method.is_empty() {
94 "GET".to_string()
95 } else {
96 method.to_uppercase()
97 };
98
99 let endpoint = if endpoint.starts_with('/') {
101 endpoint.to_string()
102 } else {
103 format!("/{endpoint}")
104 };
105
106 let body = build_body(fields, raw_fields, input).context("failed to build request body")?;
108
109 let response = client
111 .request(&method, &endpoint, headers, body)
112 .with_context(|| format!("failed to make {method} request to {endpoint}"))?;
113
114 let status = response.status();
115 let response_headers = response.headers().clone();
116
117 let response_body = response.text().context("failed to read response body")?;
119
120 if paginate && status.is_success() {
122 let all_bodies = collect_paginated(
123 &client,
124 &method,
125 &endpoint,
126 headers,
127 &response_body,
128 &response_headers,
129 )
130 .context("failed to collect paginated results")?;
131 print_output(
132 include,
133 silent,
134 jq,
135 template,
136 status,
137 &response_headers,
138 &all_bodies,
139 true,
140 );
141 } else {
142 print_output(
143 include,
144 silent,
145 jq,
146 template,
147 status,
148 &response_headers,
149 &response_body,
150 false,
151 );
152 }
153
154 Ok(())
155}
156
157fn run_graphql(
164 query: Option<&str>,
165 fields: &[String],
166 hostname: Option<&str>,
167 jq: Option<&str>,
168 template: Option<&str>,
169 global_hostname: Option<&str>,
170) -> anyhow::Result<()> {
171 let host = hostname.or(global_hostname).unwrap_or("github.com");
172 let client = Client::new(host).context("failed to create HTTP client")?;
173
174 let query_str = if let Some(q) = query {
176 q.to_string()
177 } else {
178 let mut buf = String::new();
179 std::io::stdin()
180 .read_to_string(&mut buf)
181 .context("failed to read GraphQL query from stdin")?;
182 if buf.trim().is_empty() {
183 anyhow::bail!("no GraphQL query provided (use --query or pipe via stdin)");
184 }
185 buf
186 };
187
188 let variables = build_graphql_variables(fields)?;
190
191 let body = serde_json::json!({
193 "query": query_str,
194 "variables": variables,
195 });
196
197 let body_bytes = serde_json::to_vec(&body).context("failed to serialize GraphQL body")?;
198
199 let response = client
200 .request("POST", "/graphql", &[], Some(body_bytes))
201 .context("failed to make GraphQL request")?;
202
203 let _status = response.status();
204 let response_body = response.text().context("failed to read response body")?;
205
206 let parsed: serde_json::Value =
208 serde_json::from_str(&response_body).context("failed to parse GraphQL response")?;
209
210 if let Some(errors) = parsed.get("errors") {
211 if let Some(err_array) = errors.as_array() {
212 for err in err_array {
213 let msg = err
214 .get("message")
215 .and_then(|m| m.as_str())
216 .unwrap_or("unknown error");
217 let path = err
218 .get("path")
219 .and_then(|p| serde_json::to_string(p).ok())
220 .unwrap_or_default();
221 eprintln!("GraphQL error: {msg} (path: {path})");
222 }
223 }
224 std::process::exit(1);
226 }
227
228 if jq.is_some() {
230 tracing::warn!("jq support requires the `jaq` feature; falling back to raw JSON output");
231 }
232
233 if template.is_some() {
235 tracing::warn!("template support is not yet implemented; falling back to raw JSON output");
236 }
237
238 if let Ok(pretty) = serde_json::to_string_pretty(&parsed) {
240 println!("{pretty}");
241 } else {
242 println!("{response_body}");
243 }
244
245 Ok(())
246}
247
248fn build_graphql_variables(fields: &[String]) -> anyhow::Result<serde_json::Value> {
256 let mut map = serde_json::Map::new();
257 for field in fields {
258 if let Some((key, value)) = field.split_once('=') {
259 let json_value = serde_json::from_str::<serde_json::Value>(value)
261 .unwrap_or_else(|_| serde_json::Value::String(value.to_string()));
262 map.insert(key.to_string(), json_value);
263 } else {
264 anyhow::bail!("invalid field format: '{field}' (expected key=value)");
265 }
266 }
267 Ok(serde_json::Value::Object(map))
268}
269
270fn build_body(
274 fields: &[String],
275 raw_fields: &[String],
276 input: Option<&str>,
277) -> anyhow::Result<Option<Vec<u8>>> {
278 if let Some(input) = input {
280 return read_input(input).map(Some);
281 }
282
283 if !fields.is_empty() {
285 let mut parts: Vec<String> = Vec::new();
286 for field in fields {
287 if let Some((key, value)) = field.split_once('=') {
288 let encoded_key = urlencode(key);
289 let encoded_value = urlencode(value);
290 parts.push(format!("{encoded_key}={encoded_value}"));
291 } else {
292 let encoded_key = urlencode(field);
293 parts.push(format!("{encoded_key}="));
294 }
295 }
296 return Ok(Some(parts.join("&").into_bytes()));
297 }
298
299 if !raw_fields.is_empty() {
301 let mut parts: Vec<String> = Vec::new();
302 for field in raw_fields {
303 if let Some((key, value)) = field.split_once('=') {
304 parts.push(format!("{key}={value}"));
305 } else {
306 parts.push(format!("{field}="));
307 }
308 }
309 return Ok(Some(parts.join("&").into_bytes()));
310 }
311
312 Ok(None)
313}
314
315fn read_input(input: &str) -> anyhow::Result<Vec<u8>> {
320 if input == "@-" {
321 let mut buf = Vec::new();
322 std::io::stdin()
323 .read_to_end(&mut buf)
324 .context("failed to read from stdin")?;
325 Ok(buf)
326 } else {
327 let mut file = std::fs::File::open(input)
328 .with_context(|| format!("failed to open input file '{input}'"))?;
329 let mut buf = Vec::new();
330 file.read_to_end(&mut buf)
331 .with_context(|| format!("failed to read input file '{input}'"))?;
332 Ok(buf)
333 }
334}
335
336fn urlencode(s: &str) -> String {
341 let mut result = String::with_capacity(s.len());
342 for byte in s.bytes() {
343 match byte {
344 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
345 result.push(byte as char);
346 }
347 b' ' => {
348 result.push('+');
349 }
350 _ => {
351 let _ = std::write!(result, "%{byte:02X}");
352 }
353 }
354 }
355 result
356}
357
358fn collect_paginated(
362 client: &Client,
363 method: &str,
364 _endpoint: &str,
365 headers: &[String],
366 first_body: &str,
367 first_headers: &reqwest::header::HeaderMap,
368) -> anyhow::Result<String> {
369 let mut all_items: Vec<serde_json::Value> = Vec::new();
370
371 if let Ok(first_json) = serde_json::from_str::<serde_json::Value>(first_body) {
373 if let Some(arr) = first_json.as_array() {
374 all_items.extend(arr.iter().cloned());
375 } else {
376 return Ok(first_body.to_string());
378 }
379 } else {
380 return Ok(first_body.to_string());
381 }
382
383 let mut next_url = parse_next_link(first_headers);
384
385 while let Some(url) = next_url {
386 let path = extract_path_from_url(&url);
388
389 let response = client
390 .request(method, &path, headers, None)
391 .context("failed to fetch paginated page")?;
392
393 let response_headers = response.headers().clone();
394 let body_text = response
395 .text()
396 .context("failed to read paginated response body")?;
397
398 if let Ok(page_json) = serde_json::from_str::<serde_json::Value>(&body_text) {
399 if let Some(arr) = page_json.as_array() {
400 all_items.extend(arr.iter().cloned());
401 }
402 }
403
404 next_url = parse_next_link(&response_headers);
405 }
406
407 serde_json::to_string_pretty(&all_items).context("failed to serialize paginated results")
408}
409
410fn parse_next_link(headers: &reqwest::header::HeaderMap) -> Option<String> {
412 let link_header = headers.get("Link")?.to_str().ok()?;
413
414 for part in link_header.split(',') {
416 let part = part.trim();
417 if part.contains("rel=\"next\"") || part.contains("rel=next") {
418 if let Some(start) = part.find('<') {
420 if let Some(end) = part.find('>') {
421 return Some(part[start + 1..end].to_string());
422 }
423 }
424 }
425 }
426
427 None
428}
429
430fn extract_path_from_url(url: &str) -> String {
435 if let Some(path_start) = url.find("://") {
437 let after_scheme = &url[path_start + 3..];
438 if let Some(slash_pos) = after_scheme.find('/') {
439 let after_host = &after_scheme[slash_pos..];
440 if let Some(rest) = after_host.strip_prefix("/api/v3") {
442 return rest.to_string();
443 }
444 if let Some(rest) = after_host.strip_prefix("/api") {
445 return rest.to_string();
446 }
447 return after_host.to_string();
448 }
449 }
450 url.to_string()
451}
452
453#[allow(clippy::too_many_arguments)]
457fn print_output(
458 include: bool,
459 silent: bool,
460 jq: Option<&str>,
461 template: Option<&str>,
462 status: reqwest::StatusCode,
463 headers: &reqwest::header::HeaderMap,
464 body: &str,
465 is_paginated: bool,
466) {
467 if include {
469 println!(
470 "HTTP/1.1 {} {}",
471 status.as_u16(),
472 status.canonical_reason().unwrap_or("Unknown")
473 );
474 for (name, value) in headers {
475 if let Ok(v) = value.to_str() {
476 println!("{name}: {v}");
477 }
478 }
479 println!();
480 }
481
482 if !silent && !include {
484 if status.is_success() {
485 if is_paginated {
486 println!("HTTP {} (paginated)", status.as_u16());
487 } else {
488 println!("HTTP {}", status.as_u16());
489 }
490 } else {
491 tracing::warn!("HTTP {}", status.as_u16());
492 }
493 }
494
495 if jq.is_some() {
497 tracing::warn!("jq support requires the `jaq` feature; falling back to raw JSON output");
499 print_body(body);
500 return;
501 }
502
503 if template.is_some() {
505 tracing::warn!("template support is not yet implemented; falling back to raw JSON output");
506 print_body(body);
507 return;
508 }
509
510 print_body(body);
512}
513
514fn print_body(body: &str) {
516 if let Ok(json) = serde_json::from_str::<serde_json::Value>(body) {
518 if let Ok(pretty) = serde_json::to_string_pretty(&json) {
519 println!("{pretty}");
520 return;
521 }
522 }
523 println!("{body}");
525}
526
527#[cfg(test)]
528#[allow(clippy::expect_used, clippy::unwrap_used)]
529mod tests {
530 use super::*;
531
532 #[test]
533 fn build_body_no_input() {
534 let body = build_body(&[], &[], None).expect("build_body should succeed");
535 assert!(body.is_none());
536 }
537
538 #[test]
539 fn build_body_with_fields() {
540 let fields = vec!["name=hello".to_string(), "count=42".to_string()];
541 let body = build_body(&fields, &[], None).expect("build_body should succeed");
542 let body_str = String::from_utf8(body.expect("body should be Some")).expect("valid utf8");
543 assert!(body_str.contains("name=hello") || body_str.contains("name=hello"));
544 assert!(body_str.contains("count=42"));
545 }
546
547 #[test]
548 fn build_body_with_raw_fields() {
549 let raw_fields = vec!["key=value".to_string(), "json=[1,2,3]".to_string()];
550 let body = build_body(&[], &raw_fields, None).expect("build_body should succeed");
551 let body_str = String::from_utf8(body.expect("body should be Some")).expect("valid utf8");
552 assert!(body_str.contains("key=value"));
553 assert!(body_str.contains("json=[1,2,3]"));
554 }
555
556 #[test]
557 fn build_body_input_takes_precedence() {
558 let input = Some("@-");
560 assert!(input.is_some());
563 }
564
565 #[test]
566 fn build_body_field_without_value() {
567 let fields = vec!["flag".to_string()];
568 let body = build_body(&fields, &[], None).expect("build_body should succeed");
569 let body_str = String::from_utf8(body.expect("body should be Some")).expect("valid utf8");
570 assert_eq!(body_str, "flag=");
571 }
572
573 #[test]
574 fn read_input_from_file() {
575 let dir = std::env::temp_dir();
576 let path = dir.join("gor_test_api_input.txt");
577 std::fs::write(&path, b"test body content").expect("write temp file");
578 let path_str = path.to_str().expect("path to str").to_string();
579 let content = read_input(&path_str).expect("read_input should succeed");
580 assert_eq!(
581 String::from_utf8(content).expect("valid utf8"),
582 "test body content"
583 );
584 let _ = std::fs::remove_file(&path);
585 }
586
587 #[test]
588 fn urlencode_basic() {
589 assert_eq!(urlencode("hello"), "hello");
590 assert_eq!(urlencode("hello world"), "hello+world");
591 assert_eq!(urlencode("a=b&c"), "a%3Db%26c");
592 assert_eq!(urlencode(""), "");
593 }
594
595 #[test]
596 fn urlencode_special_chars() {
597 assert_eq!(urlencode("foo/bar"), "foo%2Fbar");
598 assert_eq!(urlencode("a b c"), "a+b+c");
599 assert_eq!(urlencode("~test_123"), "~test_123");
600 }
601
602 #[test]
603 fn parse_next_link_no_header() {
604 let headers = reqwest::header::HeaderMap::new();
605 assert!(parse_next_link(&headers).is_none());
606 }
607
608 #[test]
609 fn parse_next_link_with_next() {
610 let mut headers = reqwest::header::HeaderMap::new();
611 headers.insert(
612 "Link",
613 "<https://api.github.com/repos/owner/repo/issues?page=2>; rel=\"next\", <https://api.github.com/repos/owner/repo/issues?page=5>; rel=\"last\"".parse().unwrap(),
614 );
615 let next = parse_next_link(&headers);
616 assert_eq!(
617 next,
618 Some("https://api.github.com/repos/owner/repo/issues?page=2".to_string())
619 );
620 }
621
622 #[test]
623 fn parse_next_link_no_next() {
624 let mut headers = reqwest::header::HeaderMap::new();
625 headers.insert(
626 "Link",
627 "<https://api.github.com/repos/owner/repo/issues?page=1>; rel=\"first\", <https://api.github.com/repos/owner/repo/issues?page=5>; rel=\"last\"".parse().unwrap(),
628 );
629 assert!(parse_next_link(&headers).is_none());
630 }
631
632 #[test]
633 fn extract_path_from_url_github() {
634 let url = "https://api.github.com/repos/owner/repo/issues?page=2";
635 assert_eq!(
636 extract_path_from_url(url),
637 "/repos/owner/repo/issues?page=2"
638 );
639 }
640
641 #[test]
642 fn extract_path_from_url_ghes() {
643 let url = "https://github.example.com/api/v3/repos/owner/repo/issues";
644 assert_eq!(extract_path_from_url(url), "/repos/owner/repo/issues");
645 }
646
647 #[test]
648 fn extract_path_from_url_relative() {
649 let url = "/repos/owner/repo";
650 assert_eq!(extract_path_from_url(url), "/repos/owner/repo");
651 }
652
653 #[test]
654 fn print_body_valid_json() {
655 print_body(r#"{"name": "test"}"#);
657 }
658
659 #[test]
660 fn print_body_invalid_json() {
661 print_body("plain text response");
663 }
664
665 #[test]
666 fn print_body_empty() {
667 print_body("");
669 }
670
671 #[test]
672 fn build_graphql_variables_basic() {
673 let fields = vec!["name=hello".to_string(), "count=42".to_string()];
674 let vars = build_graphql_variables(&fields).expect("should succeed");
675 let obj = vars.as_object().expect("should be an object");
676 assert_eq!(obj.get("name").and_then(|v| v.as_str()), Some("hello"));
677 assert_eq!(
679 obj.get("count").and_then(serde_json::Value::as_i64),
680 Some(42)
681 );
682 }
683
684 #[test]
685 fn build_graphql_variables_json_value() {
686 let fields = vec!["data={\"n\":5}".to_string()];
687 let vars = build_graphql_variables(&fields).expect("should succeed");
688 let obj = vars.as_object().expect("should be an object");
689 let data = obj.get("data").expect("data field");
690 assert_eq!(data.get("n").and_then(serde_json::Value::as_i64), Some(5));
691 }
692
693 #[test]
694 fn build_graphql_variables_invalid_format() {
695 let fields = vec!["noequal".to_string()];
696 let result = build_graphql_variables(&fields);
697 assert!(result.is_err());
698 let err = result.unwrap_err().to_string();
699 assert!(err.contains("invalid field format"));
700 }
701}