Skip to main content

gor/cmd/
api.rs

1//! Implementation of the `gor api` subcommand.
2//!
3//! Provides arbitrary REST API calls to GitHub endpoints
4//! and GraphQL API queries.
5//! Supports GET, POST, PUT, PATCH, DELETE methods with custom headers,
6//! body input, pagination, and JSON output.
7
8#![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
16/// Run the `gor api` subcommand.
17///
18/// # Errors
19///
20/// Returns an error if the command execution fails.
21pub 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/// Execute a REST API request.
69///
70/// # Errors
71///
72/// Returns an error if the HTTP request fails or the response cannot be read.
73#[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    // Build the endpoint path
100    let endpoint = if endpoint.starts_with('/') {
101        endpoint.to_string()
102    } else {
103        format!("/{endpoint}")
104    };
105
106    // Build the request body
107    let body = build_body(fields, raw_fields, input).context("failed to build request body")?;
108
109    // Make the request
110    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    // Read the response body
118    let response_body = response.text().context("failed to read response body")?;
119
120    // Handle pagination
121    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
157/// Execute a GraphQL API request.
158///
159/// # Errors
160///
161/// Returns an error if the HTTP request fails or the response indicates
162/// GraphQL-level errors.
163fn 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    // Read query from --query flag or stdin
175    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    // Build variables JSON
189    let variables = build_graphql_variables(fields)?;
190
191    // Build the GraphQL request body
192    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    // Parse and check for GraphQL errors
207    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        // Exit non-zero on GraphQL errors
225        std::process::exit(1);
226    }
227
228    // Handle --jq
229    if jq.is_some() {
230        tracing::warn!("jq support requires the `jaq` feature; falling back to raw JSON output");
231    }
232
233    // Handle --template
234    if template.is_some() {
235        tracing::warn!("template support is not yet implemented; falling back to raw JSON output");
236    }
237
238    // Pretty-print the response
239    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
248/// Build a JSON object from key=value field pairs for GraphQL variables.
249///
250/// Each field is parsed as JSON if possible, otherwise treated as a string.
251///
252/// # Errors
253///
254/// Returns an error if a field is not in `key=value` format.
255fn 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            // Try to parse as JSON first, fall back to string
260            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
270/// Build the request body from the command arguments.
271///
272/// Priority: --input > --field > --raw-field
273fn build_body(
274    fields: &[String],
275    raw_fields: &[String],
276    input: Option<&str>,
277) -> anyhow::Result<Option<Vec<u8>>> {
278    // --input takes precedence
279    if let Some(input) = input {
280        return read_input(input).map(Some);
281    }
282
283    // --field / -F: URL-encoded form fields
284    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    // --raw-field / -f: raw (non-URL-encoded) form fields
300    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
315/// Read input from a file or stdin.
316///
317/// If `input` is `@-`, read from stdin.
318/// Otherwise, read from the specified file path.
319fn 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
336/// Simple URL-encoding for form field values.
337///
338/// Only encodes characters that are not allowed in form data:
339/// spaces, special characters, and non-ASCII.
340fn 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
358/// Collect paginated results by following `Link` headers.
359///
360/// Concatenates all page responses into a single JSON array.
361fn 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    // Parse the first page
372    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            // If it's not an array, just return the first body as-is
377            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        // Extract the path from the full URL
387        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
410/// Parse the `Link` header to find the next page URL.
411fn parse_next_link(headers: &reqwest::header::HeaderMap) -> Option<String> {
412    let link_header = headers.get("Link")?.to_str().ok()?;
413
414    // Link header format: `<url>; rel="next", <url>; rel="last"`
415    for part in link_header.split(',') {
416        let part = part.trim();
417        if part.contains("rel=\"next\"") || part.contains("rel=next") {
418            // Extract the URL from <...>
419            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
430/// Extract the API path from a full URL.
431///
432/// Given `https://api.github.com/repos/owner/repo/issues?page=2`,
433/// returns `/repos/owner/repo/issues?page=2`.
434fn extract_path_from_url(url: &str) -> String {
435    // Find the path after the host
436    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            // Remove API prefix if present (/api/v3 or /api)
441            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/// Print the output based on command flags.
454///
455/// Handles `--include`, `--jq`, `--template`, `--silent`, and default output.
456#[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    // --include / -i: print response headers
468    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    // --silent: suppress status output
483    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    // --jq: filter through jq expression
496    if jq.is_some() {
497        // jaq is not yet a dependency; print message and fall back to raw JSON
498        tracing::warn!("jq support requires the `jaq` feature; falling back to raw JSON output");
499        print_body(body);
500        return;
501    }
502
503    // --template: format via template
504    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    // Default: print the body
511    print_body(body);
512}
513
514/// Print the response body, attempting to pretty-print JSON.
515fn print_body(body: &str) {
516    // Try to pretty-print JSON
517    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    // Fall back to raw output
524    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        // input takes precedence; verify by checking logic path
559        let input = Some("@-");
560        // This would try to read from stdin, which is not available in tests.
561        // Just verify that input takes precedence by checking the logic path.
562        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        // Should not panic
656        print_body(r#"{"name": "test"}"#);
657    }
658
659    #[test]
660    fn print_body_invalid_json() {
661        // Should not panic with non-JSON
662        print_body("plain text response");
663    }
664
665    #[test]
666    fn print_body_empty() {
667        // Should not panic with empty string
668        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        // 42 should be parsed as JSON number
678        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}