use std::time::Duration;
use serde_json::Value;
use crate::error::{Error, Result};
pub const ENDPOINT: &str = "https://api.linear.app/graphql";
pub const ENDPOINT_ENV: &str = "CLIBAN_LINEAR_ENDPOINT";
#[allow(async_fn_in_trait)]
pub trait GraphQl {
async fn query(&self, doc: &str, vars: Value) -> Result<Value>;
}
pub struct Client {
http: reqwest::Client,
token: String,
endpoint: String,
}
impl Client {
pub fn new(token: String) -> Result<Self> {
let http = reqwest::Client::builder()
.user_agent(concat!("cliban/", env!("CARGO_PKG_VERSION")))
.timeout(Duration::from_secs(30))
.build()?;
let endpoint = std::env::var(ENDPOINT_ENV)
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| ENDPOINT.to_string());
Ok(Self {
http,
token,
endpoint,
})
}
pub fn from_env() -> Result<Self> {
Self::new(crate::config::token()?)
}
async fn post(&self, body: &Value) -> Result<reqwest::Response> {
Ok(self
.http
.post(&self.endpoint)
.header(reqwest::header::AUTHORIZATION, &self.token)
.json(body)
.send()
.await?)
}
}
impl GraphQl for Client {
async fn query(&self, doc: &str, vars: Value) -> Result<Value> {
let body = serde_json::json!({ "query": doc, "variables": vars });
let mut response = self.post(&body).await?;
if response.status().as_u16() == 429 {
let wait = retry_after(&response).unwrap_or(Duration::from_secs(2));
tokio::time::sleep(wait).await;
response = self.post(&body).await?;
}
let status = response.status().as_u16();
if matches!(status, 401 | 403) {
return Err(Error::Unauthorized(status));
}
let text = response.text().await?;
if !(200..300).contains(&status) {
return Err(Error::Http(format!("HTTP {status}: {}", truncate(&text))));
}
let parsed: Value = serde_json::from_str(&text)
.map_err(|e| Error::Unexpected(format!("{e}: {}", truncate(&text))))?;
extract_data(parsed)
}
}
fn extract_data(mut envelope: Value) -> Result<Value> {
if let Some(errors) = envelope.get("errors").and_then(Value::as_array) {
if !errors.is_empty() {
let messages = errors
.iter()
.map(|e| {
e.get("message")
.and_then(Value::as_str)
.unwrap_or("(no message)")
.to_string()
})
.collect();
return Err(Error::Api(messages));
}
}
match envelope.get_mut("data") {
Some(data) => Ok(data.take()),
None => Err(Error::Unexpected("response had no `data` field".into())),
}
}
fn retry_after(response: &reqwest::Response) -> Option<Duration> {
response
.headers()
.get(reqwest::header::RETRY_AFTER)?
.to_str()
.ok()?
.trim()
.parse::<u64>()
.ok()
.map(|secs| Duration::from_secs(secs.min(30)))
}
fn truncate(s: &str) -> String {
const MAX: usize = 300;
if s.len() <= MAX {
return s.to_string();
}
let cut = s
.char_indices()
.map(|(i, _)| i)
.take_while(|i| *i <= MAX)
.last()
.unwrap_or(0);
format!("{}…", &s[..cut])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_data_unwraps_the_envelope() {
let env = serde_json::json!({"data": {"issue": {"id": "x"}}});
let data = extract_data(env).unwrap();
assert_eq!(data["issue"]["id"], "x");
}
#[test]
fn graphql_errors_beat_partial_data() {
let env = serde_json::json!({
"data": {"issue": null},
"errors": [{"message": "Entity not found"}, {"message": "and another"}]
});
let err = extract_data(env).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Entity not found"), "{msg}");
assert!(msg.contains("and another"), "{msg}");
}
#[test]
fn an_empty_errors_array_is_not_an_error() {
let env = serde_json::json!({"data": {"ok": true}, "errors": []});
assert!(extract_data(env).is_ok());
}
#[test]
fn a_missing_data_field_is_reported_as_unexpected() {
let err = extract_data(serde_json::json!({"nonsense": 1})).unwrap_err();
assert!(err.to_string().contains("no `data` field"));
}
#[test]
fn errors_without_a_message_still_produce_output() {
let env = serde_json::json!({"errors": [{"code": 500}]});
let err = extract_data(env).unwrap_err();
assert!(err.to_string().contains("no message"));
}
#[test]
fn truncate_keeps_long_html_error_pages_readable() {
let long = "x".repeat(1000);
let out = truncate(&long);
assert!(out.len() < 400);
assert!(out.ends_with('…'));
assert_eq!(truncate("short"), "short");
}
#[test]
fn truncate_does_not_split_a_multibyte_character() {
let s = "é".repeat(400);
let out = truncate(&s);
assert!(out.ends_with('…'));
}
}