use anyhow::{anyhow, bail, Result};
use serde_json::{json, Value};
use crate::config::Config;
pub struct Client {
inner: reqwest::blocking::Client,
host: String,
token: Option<String>,
}
impl Client {
pub fn from_config(cfg: &Config) -> Result<Self> {
Self::new(cfg.host.clone(), Some(cfg.token.clone()))
}
pub fn new(host: String, token: Option<String>) -> Result<Self> {
let inner = reqwest::blocking::Client::builder()
.user_agent(concat!("runtime-cli/", env!("CARGO_PKG_VERSION")))
.build()?;
Ok(Self {
inner,
host: host.trim_end_matches('/').to_string(),
token,
})
}
pub fn host(&self) -> &str {
&self.host
}
pub fn execute(&self, query: &str, variables: Value) -> Result<Value> {
let body = json!({ "query": query, "variables": variables });
let mut req = self
.inner
.post(format!("{}/graphql", self.host))
.json(&body);
if let Some(t) = &self.token {
req = req.bearer_auth(t);
}
let resp = req.send()?;
let status = resp.status();
let text = resp.text()?;
let parsed: Value = serde_json::from_str(&text)
.map_err(|e| anyhow!("non-JSON response (HTTP {status}): {e}: {text}"))?;
if let Some(errs) = parsed.get("errors").and_then(|v| v.as_array()) {
if !errs.is_empty() {
bail!("{}", render_graphql_errors(errs));
}
}
if !status.is_success() {
bail!("HTTP {status}: {text}");
}
Ok(parsed.get("data").cloned().unwrap_or(Value::Null))
}
pub fn execute_raw(&self, query: &str, variables: Value) -> Result<Value> {
let body = json!({ "query": query, "variables": variables });
let mut req = self
.inner
.post(format!("{}/graphql", self.host))
.json(&body);
if let Some(t) = &self.token {
req = req.bearer_auth(t);
}
let resp = req.send()?;
let text = resp.text()?;
Ok(serde_json::from_str(&text)?)
}
}
fn render_graphql_errors(errs: &[Value]) -> String {
let lines: Vec<String> = errs
.iter()
.filter_map(|e| {
let msg = e.get("message").and_then(|m| m.as_str())?;
let code = e
.get("extensions")
.and_then(|x| x.get("code"))
.and_then(|c| c.as_str());
Some(match code {
Some(c) => format!("{}: {msg}", humanize_code(c)),
None => msg.to_string(),
})
})
.collect();
if lines.is_empty() {
return "GraphQL error: (no message)".to_string();
}
lines.join("; ")
}
fn humanize_code(code: &str) -> &str {
match code {
"NOT_FOUND" => "Not found",
"CONFLICT" => "Conflict",
"FORBIDDEN" => "Forbidden",
"VALIDATION" => "Validation",
"UNIMPLEMENTED" => "Unimplemented",
"STORAGE" => "Storage error",
other => other,
}
}
pub fn authenticated() -> Result<(Client, Config)> {
let cfg = Config::load()?;
let client = Client::from_config(&cfg)?;
Ok((client, cfg))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_error_with_validation_code() {
let errs = vec![json!({
"message": "title: must not be empty",
"extensions": { "code": "VALIDATION" }
})];
assert_eq!(
render_graphql_errors(&errs),
"Validation: title: must not be empty"
);
}
#[test]
fn render_error_without_code_falls_back_to_message() {
let errs = vec![json!({ "message": "PERMISSION_DENIED" })];
assert_eq!(render_graphql_errors(&errs), "PERMISSION_DENIED");
}
#[test]
fn render_multiple_errors_semicolon_joined() {
let errs = vec![
json!({
"message": "a",
"extensions": { "code": "CONFLICT" }
}),
json!({
"message": "b",
"extensions": { "code": "NOT_FOUND" }
}),
];
assert_eq!(render_graphql_errors(&errs), "Conflict: a; Not found: b");
}
}