1use 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 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 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 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 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
92fn 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
132pub 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}