1use crate::auth;
9use crate::error::{GraphQLError, LinearError};
10use crate::pagination::Connection;
11use serde::de::DeserializeOwned;
12
13const LINEAR_API_URL: &str = "https://api.linear.app/graphql";
14
15#[derive(Debug, Clone)]
17pub struct Client {
18 http: reqwest::Client,
19 token: String,
20 base_url: String,
21}
22
23#[derive(serde::Deserialize)]
25struct GraphQLResponse {
26 data: Option<serde_json::Value>,
27 errors: Option<Vec<GraphQLError>>,
28}
29
30impl Client {
31 pub fn from_token(token: impl Into<String>) -> Result<Self, LinearError> {
33 let token = token.into();
34 if token.is_empty() {
35 return Err(LinearError::AuthConfig("Token cannot be empty".to_string()));
36 }
37 Ok(Self {
38 http: reqwest::Client::new(),
39 token,
40 base_url: LINEAR_API_URL.to_string(),
41 })
42 }
43
44 pub fn from_env() -> Result<Self, LinearError> {
46 Self::from_token(auth::token_from_env()?)
47 }
48
49 pub fn from_file() -> Result<Self, LinearError> {
51 Self::from_token(auth::token_from_file()?)
52 }
53
54 pub fn auto() -> Result<Self, LinearError> {
56 Self::from_token(auth::auto_token()?)
57 }
58
59 pub async fn execute<T: DeserializeOwned>(
61 &self,
62 query: &str,
63 variables: serde_json::Value,
64 data_path: &str,
65 ) -> Result<T, LinearError> {
66 let body = serde_json::json!({
67 "query": query,
68 "variables": variables,
69 });
70
71 let response = self
72 .http
73 .post(&self.base_url)
74 .header("Authorization", &self.token)
75 .header("Content-Type", "application/json")
76 .header(
77 "User-Agent",
78 format!("lineark-sdk/{}", env!("CARGO_PKG_VERSION")),
79 )
80 .json(&body)
81 .send()
82 .await?;
83
84 let status = response.status();
85 if status == 401 || status == 403 {
86 let text = response.text().await.unwrap_or_default();
87 if status == 401 {
88 return Err(LinearError::Authentication(text));
89 }
90 return Err(LinearError::Forbidden(text));
91 }
92 if status == 429 {
93 let retry_after = response
94 .headers()
95 .get("retry-after")
96 .and_then(|v| v.to_str().ok())
97 .and_then(|v| v.parse::<f64>().ok());
98 let text = response.text().await.unwrap_or_default();
99 return Err(LinearError::RateLimited {
100 retry_after,
101 message: text,
102 });
103 }
104 if !status.is_success() {
105 let body = response.text().await.unwrap_or_default();
106 return Err(LinearError::HttpError {
107 status: status.as_u16(),
108 body,
109 });
110 }
111
112 let gql_response: GraphQLResponse = response.json().await?;
113
114 if let Some(errors) = gql_response.errors {
116 if !errors.is_empty() {
117 let first_msg = errors[0].message.to_lowercase();
119 if first_msg.contains("authentication") || first_msg.contains("unauthorized") {
120 return Err(LinearError::Authentication(errors[0].message.clone()));
121 }
122 return Err(LinearError::GraphQL(errors));
123 }
124 }
125
126 let data = gql_response
127 .data
128 .ok_or_else(|| LinearError::MissingData("No data in response".to_string()))?;
129
130 let value = data
131 .get(data_path)
132 .ok_or_else(|| {
133 LinearError::MissingData(format!("No '{}' in response data", data_path))
134 })?
135 .clone();
136
137 serde_json::from_value(value).map_err(|e| {
138 LinearError::MissingData(format!("Failed to deserialize '{}': {}", data_path, e))
139 })
140 }
141
142 pub async fn execute_connection<T: DeserializeOwned>(
144 &self,
145 query: &str,
146 variables: serde_json::Value,
147 data_path: &str,
148 ) -> Result<Connection<T>, LinearError> {
149 self.execute::<Connection<T>>(query, variables, data_path)
150 .await
151 }
152
153 pub(crate) fn http(&self) -> &reqwest::Client {
158 &self.http
159 }
160
161 pub(crate) fn token(&self) -> &str {
162 &self.token
163 }
164
165 #[cfg(test)]
167 pub(crate) fn with_base_url(mut self, url: String) -> Self {
168 self.base_url = url;
169 self
170 }
171
172 #[doc(hidden)]
174 pub fn set_base_url(&mut self, url: String) {
175 self.base_url = url;
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use wiremock::matchers::{header, method};
183 use wiremock::{Mock, MockServer, ResponseTemplate};
184
185 #[test]
186 fn from_token_valid() {
187 let client = Client::from_token("lin_api_test123").unwrap();
188 assert_eq!(client.token, "lin_api_test123");
189 assert_eq!(client.base_url, LINEAR_API_URL);
190 }
191
192 #[test]
193 fn from_token_empty_fails() {
194 let err = Client::from_token("").unwrap_err();
195 assert!(matches!(err, LinearError::AuthConfig(_)));
196 assert!(err.to_string().contains("empty"));
197 }
198
199 #[tokio::test]
200 async fn execute_returns_401_as_authentication_error() {
201 let server = MockServer::start().await;
202 Mock::given(method("POST"))
203 .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
204 .mount(&server)
205 .await;
206
207 let client = Client::from_token("bad-token")
208 .unwrap()
209 .with_base_url(server.uri());
210
211 let result = client
212 .execute::<serde_json::Value>(
213 "query { viewer { id } }",
214 serde_json::json!({}),
215 "viewer",
216 )
217 .await;
218
219 assert!(matches!(result, Err(LinearError::Authentication(_))));
220 }
221
222 #[tokio::test]
223 async fn execute_returns_403_as_forbidden_error() {
224 let server = MockServer::start().await;
225 Mock::given(method("POST"))
226 .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
227 .mount(&server)
228 .await;
229
230 let client = Client::from_token("token")
231 .unwrap()
232 .with_base_url(server.uri());
233
234 let result = client
235 .execute::<serde_json::Value>(
236 "query { viewer { id } }",
237 serde_json::json!({}),
238 "viewer",
239 )
240 .await;
241
242 assert!(matches!(result, Err(LinearError::Forbidden(_))));
243 }
244
245 #[tokio::test]
246 async fn execute_returns_429_as_rate_limited_error() {
247 let server = MockServer::start().await;
248 Mock::given(method("POST"))
249 .respond_with(
250 ResponseTemplate::new(429)
251 .append_header("retry-after", "30")
252 .set_body_string("Too Many Requests"),
253 )
254 .mount(&server)
255 .await;
256
257 let client = Client::from_token("token")
258 .unwrap()
259 .with_base_url(server.uri());
260
261 let result = client
262 .execute::<serde_json::Value>(
263 "query { viewer { id } }",
264 serde_json::json!({}),
265 "viewer",
266 )
267 .await;
268
269 match result {
270 Err(LinearError::RateLimited {
271 retry_after,
272 message,
273 }) => {
274 assert_eq!(retry_after, Some(30.0));
275 assert_eq!(message, "Too Many Requests");
276 }
277 other => panic!("Expected RateLimited, got {:?}", other),
278 }
279 }
280
281 #[tokio::test]
282 async fn execute_returns_500_as_http_error() {
283 let server = MockServer::start().await;
284 Mock::given(method("POST"))
285 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
286 .mount(&server)
287 .await;
288
289 let client = Client::from_token("token")
290 .unwrap()
291 .with_base_url(server.uri());
292
293 let result = client
294 .execute::<serde_json::Value>(
295 "query { viewer { id } }",
296 serde_json::json!({}),
297 "viewer",
298 )
299 .await;
300
301 match result {
302 Err(LinearError::HttpError { status, body }) => {
303 assert_eq!(status, 500);
304 assert_eq!(body, "Internal Server Error");
305 }
306 other => panic!("Expected HttpError, got {:?}", other),
307 }
308 }
309
310 #[tokio::test]
311 async fn execute_returns_graphql_errors() {
312 let server = MockServer::start().await;
313 Mock::given(method("POST"))
314 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
315 "data": null,
316 "errors": [{"message": "Field 'foo' not found"}]
317 })))
318 .mount(&server)
319 .await;
320
321 let client = Client::from_token("token")
322 .unwrap()
323 .with_base_url(server.uri());
324
325 let result = client
326 .execute::<serde_json::Value>("query { foo }", serde_json::json!({}), "foo")
327 .await;
328
329 assert!(matches!(result, Err(LinearError::GraphQL(_))));
330 }
331
332 #[tokio::test]
333 async fn execute_graphql_auth_error_detected() {
334 let server = MockServer::start().await;
335 Mock::given(method("POST"))
336 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
337 "data": null,
338 "errors": [{"message": "Authentication required"}]
339 })))
340 .mount(&server)
341 .await;
342
343 let client = Client::from_token("token")
344 .unwrap()
345 .with_base_url(server.uri());
346
347 let result = client
348 .execute::<serde_json::Value>(
349 "query { viewer { id } }",
350 serde_json::json!({}),
351 "viewer",
352 )
353 .await;
354
355 assert!(matches!(result, Err(LinearError::Authentication(_))));
356 }
357
358 #[tokio::test]
359 async fn execute_missing_data_path() {
360 let server = MockServer::start().await;
361 Mock::given(method("POST"))
362 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
363 "data": {"other": {"id": "123"}}
364 })))
365 .mount(&server)
366 .await;
367
368 let client = Client::from_token("token")
369 .unwrap()
370 .with_base_url(server.uri());
371
372 let result = client
373 .execute::<serde_json::Value>(
374 "query { viewer { id } }",
375 serde_json::json!({}),
376 "viewer",
377 )
378 .await;
379
380 match result {
381 Err(LinearError::MissingData(msg)) => {
382 assert!(msg.contains("viewer"));
383 }
384 other => panic!("Expected MissingData, got {:?}", other),
385 }
386 }
387
388 #[tokio::test]
389 async fn execute_no_data_in_response() {
390 let server = MockServer::start().await;
391 Mock::given(method("POST"))
392 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
393 "data": null
394 })))
395 .mount(&server)
396 .await;
397
398 let client = Client::from_token("token")
399 .unwrap()
400 .with_base_url(server.uri());
401
402 let result = client
403 .execute::<serde_json::Value>(
404 "query { viewer { id } }",
405 serde_json::json!({}),
406 "viewer",
407 )
408 .await;
409
410 assert!(matches!(result, Err(LinearError::MissingData(_))));
411 }
412
413 #[tokio::test]
414 async fn execute_success_deserializes() {
415 let server = MockServer::start().await;
416 Mock::given(method("POST"))
417 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
418 "data": {
419 "viewer": {
420 "id": "user-123",
421 "name": "Test User",
422 "email": "test@example.com",
423 "active": true
424 }
425 }
426 })))
427 .mount(&server)
428 .await;
429
430 let client = Client::from_token("token")
431 .unwrap()
432 .with_base_url(server.uri());
433
434 let result: serde_json::Value = client
435 .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
436 .await
437 .unwrap();
438
439 assert_eq!(result["id"], "user-123");
440 assert_eq!(result["name"], "Test User");
441 }
442
443 #[tokio::test]
444 async fn execute_connection_deserializes() {
445 let server = MockServer::start().await;
446 Mock::given(method("POST"))
447 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
448 "data": {
449 "teams": {
450 "nodes": [
451 {"id": "team-1", "name": "Engineering", "key": "ENG"},
452 {"id": "team-2", "name": "Design", "key": "DES"}
453 ],
454 "pageInfo": {
455 "hasNextPage": false,
456 "endCursor": "cursor-abc"
457 }
458 }
459 }
460 })))
461 .mount(&server)
462 .await;
463
464 let client = Client::from_token("token")
465 .unwrap()
466 .with_base_url(server.uri());
467
468 let conn: Connection<serde_json::Value> = client
469 .execute_connection(
470 "query { teams { nodes { id } pageInfo { hasNextPage endCursor } } }",
471 serde_json::json!({}),
472 "teams",
473 )
474 .await
475 .unwrap();
476
477 assert_eq!(conn.nodes.len(), 2);
478 assert_eq!(conn.nodes[0]["id"], "team-1");
479 assert!(!conn.page_info.has_next_page);
480 assert_eq!(conn.page_info.end_cursor, Some("cursor-abc".to_string()));
481 }
482
483 #[tokio::test]
484 async fn execute_sends_authorization_header() {
485 let server = MockServer::start().await;
486 Mock::given(method("POST"))
487 .and(header("Authorization", "my-secret-token"))
488 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
489 "data": {"viewer": {"id": "1"}}
490 })))
491 .mount(&server)
492 .await;
493
494 let client = Client::from_token("my-secret-token")
495 .unwrap()
496 .with_base_url(server.uri());
497
498 let result: serde_json::Value = client
499 .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
500 .await
501 .unwrap();
502
503 assert_eq!(result["id"], "1");
504 }
505}