cliban_sync/linear/
client.rs1use std::time::Duration;
10
11use serde_json::Value;
12
13use crate::error::{Error, Result};
14
15pub const ENDPOINT: &str = "https://api.linear.app/graphql";
17
18pub const ENDPOINT_ENV: &str = "CLIBAN_LINEAR_ENDPOINT";
21
22#[allow(async_fn_in_trait)]
25pub trait GraphQl {
26 async fn query(&self, doc: &str, vars: Value) -> Result<Value>;
28}
29
30pub struct Client {
31 http: reqwest::Client,
32 token: String,
33 endpoint: String,
34}
35
36impl Client {
37 pub fn new(token: String) -> Result<Self> {
39 let http = reqwest::Client::builder()
40 .user_agent(concat!("cliban/", env!("CARGO_PKG_VERSION")))
41 .timeout(Duration::from_secs(30))
42 .build()?;
43 let endpoint = std::env::var(ENDPOINT_ENV)
44 .ok()
45 .filter(|v| !v.is_empty())
46 .unwrap_or_else(|| ENDPOINT.to_string());
47 Ok(Self {
48 http,
49 token,
50 endpoint,
51 })
52 }
53
54 pub fn from_env() -> Result<Self> {
56 Self::new(crate::config::token()?)
57 }
58
59 async fn post(&self, body: &Value) -> Result<reqwest::Response> {
60 Ok(self
61 .http
62 .post(&self.endpoint)
63 .header(reqwest::header::AUTHORIZATION, &self.token)
67 .json(body)
68 .send()
69 .await?)
70 }
71}
72
73impl GraphQl for Client {
74 async fn query(&self, doc: &str, vars: Value) -> Result<Value> {
75 let body = serde_json::json!({ "query": doc, "variables": vars });
76
77 let mut response = self.post(&body).await?;
78
79 if response.status().as_u16() == 429 {
83 let wait = retry_after(&response).unwrap_or(Duration::from_secs(2));
84 tokio::time::sleep(wait).await;
85 response = self.post(&body).await?;
86 }
87
88 let status = response.status().as_u16();
89 if matches!(status, 401 | 403) {
90 return Err(Error::Unauthorized(status));
91 }
92
93 let text = response.text().await?;
94 if !(200..300).contains(&status) {
95 return Err(Error::Http(format!("HTTP {status}: {}", truncate(&text))));
96 }
97
98 let parsed: Value = serde_json::from_str(&text)
99 .map_err(|e| Error::Unexpected(format!("{e}: {}", truncate(&text))))?;
100 extract_data(parsed)
101 }
102}
103
104fn extract_data(mut envelope: Value) -> Result<Value> {
110 if let Some(errors) = envelope.get("errors").and_then(Value::as_array) {
111 if !errors.is_empty() {
112 let messages = errors
113 .iter()
114 .map(|e| {
115 e.get("message")
116 .and_then(Value::as_str)
117 .unwrap_or("(no message)")
118 .to_string()
119 })
120 .collect();
121 return Err(Error::Api(messages));
122 }
123 }
124 match envelope.get_mut("data") {
125 Some(data) => Ok(data.take()),
126 None => Err(Error::Unexpected("response had no `data` field".into())),
127 }
128}
129
130fn retry_after(response: &reqwest::Response) -> Option<Duration> {
131 response
132 .headers()
133 .get(reqwest::header::RETRY_AFTER)?
134 .to_str()
135 .ok()?
136 .trim()
137 .parse::<u64>()
138 .ok()
139 .map(|secs| Duration::from_secs(secs.min(30)))
142}
143
144fn truncate(s: &str) -> String {
146 const MAX: usize = 300;
147 if s.len() <= MAX {
148 return s.to_string();
149 }
150 let cut = s
151 .char_indices()
152 .map(|(i, _)| i)
153 .take_while(|i| *i <= MAX)
154 .last()
155 .unwrap_or(0);
156 format!("{}…", &s[..cut])
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn extract_data_unwraps_the_envelope() {
165 let env = serde_json::json!({"data": {"issue": {"id": "x"}}});
166 let data = extract_data(env).unwrap();
167 assert_eq!(data["issue"]["id"], "x");
168 }
169
170 #[test]
171 fn graphql_errors_beat_partial_data() {
172 let env = serde_json::json!({
175 "data": {"issue": null},
176 "errors": [{"message": "Entity not found"}, {"message": "and another"}]
177 });
178 let err = extract_data(env).unwrap_err();
179 let msg = err.to_string();
180 assert!(msg.contains("Entity not found"), "{msg}");
181 assert!(msg.contains("and another"), "{msg}");
182 }
183
184 #[test]
185 fn an_empty_errors_array_is_not_an_error() {
186 let env = serde_json::json!({"data": {"ok": true}, "errors": []});
187 assert!(extract_data(env).is_ok());
188 }
189
190 #[test]
191 fn a_missing_data_field_is_reported_as_unexpected() {
192 let err = extract_data(serde_json::json!({"nonsense": 1})).unwrap_err();
193 assert!(err.to_string().contains("no `data` field"));
194 }
195
196 #[test]
197 fn errors_without_a_message_still_produce_output() {
198 let env = serde_json::json!({"errors": [{"code": 500}]});
199 let err = extract_data(env).unwrap_err();
200 assert!(err.to_string().contains("no message"));
201 }
202
203 #[test]
204 fn truncate_keeps_long_html_error_pages_readable() {
205 let long = "x".repeat(1000);
206 let out = truncate(&long);
207 assert!(out.len() < 400);
208 assert!(out.ends_with('…'));
209 assert_eq!(truncate("short"), "short");
210 }
211
212 #[test]
213 fn truncate_does_not_split_a_multibyte_character() {
214 let s = "é".repeat(400);
215 let out = truncate(&s);
216 assert!(out.ends_with('…'));
217 }
218}