Skip to main content

runtime_cli/
client.rs

1//! Thin GraphQL client. Uses `reqwest::blocking` so the CLI stays
2//! synchronous end-to-end — no tokio runtime, no async noise in the
3//! subcommand modules.
4//!
5//! `Client::execute` posts to `<host>/graphql` with optional bearer
6//! auth. Errors in the response's `errors` array surface as a single
7//! flat error string; the caller doesn't have to know GraphQL's
8//! structured-error shape.
9
10use anyhow::{anyhow, bail, Result};
11use serde_json::{json, Value};
12
13use crate::config::Config;
14
15pub struct Client {
16    inner: reqwest::blocking::Client,
17    host: String,
18    token: Option<String>,
19}
20
21impl Client {
22    /// Construct an authenticated client from a saved config.
23    pub fn from_config(cfg: &Config) -> Result<Self> {
24        Self::new(cfg.host.clone(), Some(cfg.token.clone()))
25    }
26
27    pub fn new(host: String, token: Option<String>) -> Result<Self> {
28        let inner = reqwest::blocking::Client::builder()
29            .user_agent(concat!("runtime-cli/", env!("CARGO_PKG_VERSION")))
30            .build()?;
31        Ok(Self {
32            inner,
33            host: host.trim_end_matches('/').to_string(),
34            token,
35        })
36    }
37
38    pub fn host(&self) -> &str {
39        &self.host
40    }
41
42    /// Run `query` with optional `variables` and return the parsed
43    /// `data` slice. GraphQL errors are flattened into one
44    /// `anyhow::Error`.
45    pub fn execute(&self, query: &str, variables: Value) -> Result<Value> {
46        let body = json!({ "query": query, "variables": variables });
47        let mut req = self
48            .inner
49            .post(format!("{}/graphql", self.host))
50            .json(&body);
51        if let Some(t) = &self.token {
52            req = req.bearer_auth(t);
53        }
54        let resp = req.send()?;
55        let status = resp.status();
56        let text = resp.text()?;
57        let parsed: Value = serde_json::from_str(&text)
58            .map_err(|e| anyhow!("non-JSON response (HTTP {status}): {e}: {text}"))?;
59        if let Some(errs) = parsed.get("errors").and_then(|v| v.as_array()) {
60            if !errs.is_empty() {
61                // Issue #31.2 — render each GraphQL error with its
62                // `extensions.code` when present so the user sees
63                // \"validation failed\" / \"conflict\" / \"not found\"
64                // rather than a flat \"GraphQL error\" wrapper. The
65                // codes were introduced by #11's `extend_domain`.
66                bail!("{}", render_graphql_errors(errs));
67            }
68        }
69        if !status.is_success() {
70            bail!("HTTP {status}: {text}");
71        }
72        Ok(parsed.get("data").cloned().unwrap_or(Value::Null))
73    }
74
75    /// Run a query without flattening errors — used by `runtime api`,
76    /// which pretty-prints the entire response.
77    pub fn execute_raw(&self, query: &str, variables: Value) -> Result<Value> {
78        let body = json!({ "query": query, "variables": variables });
79        let mut req = self
80            .inner
81            .post(format!("{}/graphql", self.host))
82            .json(&body);
83        if let Some(t) = &self.token {
84            req = req.bearer_auth(t);
85        }
86        let resp = req.send()?;
87        let text = resp.text()?;
88        Ok(serde_json::from_str(&text)?)
89    }
90}
91
92/// Issue #31.2 — render GraphQL errors with their `extensions.code`
93/// when present. Falls back to the legacy flat-join shape when the
94/// server doesn't surface codes.
95///
96/// Examples:
97///   Validation: title: must not be empty
98///   Conflict: webhook with that URL already exists; Not found: webhook 42
99fn render_graphql_errors(errs: &[Value]) -> String {
100    let lines: Vec<String> = errs
101        .iter()
102        .filter_map(|e| {
103            let msg = e.get("message").and_then(|m| m.as_str())?;
104            let code = e
105                .get("extensions")
106                .and_then(|x| x.get("code"))
107                .and_then(|c| c.as_str());
108            Some(match code {
109                Some(c) => format!("{}: {msg}", humanize_code(c)),
110                None => msg.to_string(),
111            })
112        })
113        .collect();
114    if lines.is_empty() {
115        return "GraphQL error: (no message)".to_string();
116    }
117    lines.join("; ")
118}
119
120fn humanize_code(code: &str) -> &str {
121    match code {
122        "NOT_FOUND" => "Not found",
123        "CONFLICT" => "Conflict",
124        "FORBIDDEN" => "Forbidden",
125        "VALIDATION" => "Validation",
126        "UNIMPLEMENTED" => "Unimplemented",
127        "STORAGE" => "Storage error",
128        other => other,
129    }
130}
131
132/// Load the config and build an authenticated client. Common helper
133/// for every authenticated subcommand.
134pub fn authenticated() -> Result<(Client, Config)> {
135    let cfg = Config::load()?;
136    let client = Client::from_config(&cfg)?;
137    Ok((client, cfg))
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn render_error_with_validation_code() {
146        let errs = vec![json!({
147            "message": "title: must not be empty",
148            "extensions": { "code": "VALIDATION" }
149        })];
150        assert_eq!(
151            render_graphql_errors(&errs),
152            "Validation: title: must not be empty"
153        );
154    }
155
156    #[test]
157    fn render_error_without_code_falls_back_to_message() {
158        let errs = vec![json!({ "message": "PERMISSION_DENIED" })];
159        assert_eq!(render_graphql_errors(&errs), "PERMISSION_DENIED");
160    }
161
162    #[test]
163    fn render_multiple_errors_semicolon_joined() {
164        let errs = vec![
165            json!({
166                "message": "a",
167                "extensions": { "code": "CONFLICT" }
168            }),
169            json!({
170                "message": "b",
171                "extensions": { "code": "NOT_FOUND" }
172            }),
173        ];
174        assert_eq!(render_graphql_errors(&errs), "Conflict: a; Not found: b");
175    }
176}