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 let query_name = query
124 .strip_prefix("query ")
125 .or_else(|| query.strip_prefix("mutation "))
126 .and_then(|rest| rest.split(['(', ' ', '{']).next())
127 .filter(|s| !s.is_empty())
128 .map(|s| s.to_string());
129 return Err(LinearError::GraphQL { errors, query_name });
130 }
131 }
132
133 let data = gql_response
134 .data
135 .ok_or_else(|| LinearError::MissingData("No data in response".to_string()))?;
136
137 let value = data
138 .get(data_path)
139 .ok_or_else(|| {
140 LinearError::MissingData(format!("No '{}' in response data", data_path))
141 })?
142 .clone();
143
144 serde_json::from_value(value).map_err(|e| {
145 LinearError::MissingData(format!("Failed to deserialize '{}': {}", data_path, e))
146 })
147 }
148
149 pub async fn execute_connection<T: DeserializeOwned>(
151 &self,
152 query: &str,
153 variables: serde_json::Value,
154 data_path: &str,
155 ) -> Result<Connection<T>, LinearError> {
156 self.execute::<Connection<T>>(query, variables, data_path)
157 .await
158 }
159
160 pub async fn query<T: DeserializeOwned + crate::GraphQLFields>(
176 &self,
177 field: &str,
178 ) -> Result<T, LinearError> {
179 let selection = T::selection();
180 let query = format!("query {{ {} {{ {} }} }}", field, selection);
181 self.execute::<T>(&query, serde_json::json!({}), field)
182 .await
183 }
184
185 pub async fn query_connection<T: DeserializeOwned + crate::GraphQLFields>(
190 &self,
191 field: &str,
192 ) -> Result<Connection<T>, LinearError> {
193 let selection = T::selection();
194 let query = format!(
195 "query {{ {} {{ nodes {{ {} }} pageInfo {{ hasNextPage endCursor }} }} }}",
196 field, selection
197 );
198 self.execute_connection::<T>(&query, serde_json::json!({}), field)
199 .await
200 }
201
202 pub(crate) async fn execute_mutation<T: DeserializeOwned>(
210 &self,
211 query: &str,
212 variables: serde_json::Value,
213 data_path: &str,
214 entity_field: &str,
215 ) -> Result<T, LinearError> {
216 let payload = self
217 .execute::<serde_json::Value>(query, variables, data_path)
218 .await?;
219
220 if payload.get("success").and_then(|v| v.as_bool()) != Some(true) {
222 return Err(LinearError::Internal(format!(
223 "Mutation '{}' failed: {}",
224 data_path,
225 serde_json::to_string_pretty(&payload).unwrap_or_default()
226 )));
227 }
228
229 let entity = payload
231 .get(entity_field)
232 .ok_or_else(|| {
233 LinearError::MissingData(format!(
234 "No '{}' field in '{}' payload",
235 entity_field, data_path
236 ))
237 })?
238 .clone();
239
240 serde_json::from_value(entity).map_err(|e| {
241 LinearError::MissingData(format!(
242 "Failed to deserialize '{}' from '{}': {}",
243 entity_field, data_path, e
244 ))
245 })
246 }
247
248 pub(crate) fn http(&self) -> &reqwest::Client {
253 &self.http
254 }
255
256 pub(crate) fn token(&self) -> &str {
257 &self.token
258 }
259
260 #[cfg(test)]
262 pub(crate) fn with_base_url(mut self, url: String) -> Self {
263 self.base_url = url;
264 self
265 }
266
267 #[doc(hidden)]
269 pub fn set_base_url(&mut self, url: String) {
270 self.base_url = url;
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use wiremock::matchers::{header, method};
278 use wiremock::{Mock, MockServer, ResponseTemplate};
279
280 #[test]
281 fn from_token_valid() {
282 let client = Client::from_token("lin_api_test123").unwrap();
283 assert_eq!(client.token, "lin_api_test123");
284 assert_eq!(client.base_url, LINEAR_API_URL);
285 }
286
287 #[test]
288 fn from_token_empty_fails() {
289 let err = Client::from_token("").unwrap_err();
290 assert!(matches!(err, LinearError::AuthConfig(_)));
291 assert!(err.to_string().contains("empty"));
292 }
293
294 #[tokio::test]
295 async fn execute_returns_401_as_authentication_error() {
296 let server = MockServer::start().await;
297 Mock::given(method("POST"))
298 .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
299 .mount(&server)
300 .await;
301
302 let client = Client::from_token("bad-token")
303 .unwrap()
304 .with_base_url(server.uri());
305
306 let result = client
307 .execute::<serde_json::Value>(
308 "query { viewer { id } }",
309 serde_json::json!({}),
310 "viewer",
311 )
312 .await;
313
314 assert!(matches!(result, Err(LinearError::Authentication(_))));
315 }
316
317 #[tokio::test]
318 async fn execute_returns_403_as_forbidden_error() {
319 let server = MockServer::start().await;
320 Mock::given(method("POST"))
321 .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
322 .mount(&server)
323 .await;
324
325 let client = Client::from_token("token")
326 .unwrap()
327 .with_base_url(server.uri());
328
329 let result = client
330 .execute::<serde_json::Value>(
331 "query { viewer { id } }",
332 serde_json::json!({}),
333 "viewer",
334 )
335 .await;
336
337 assert!(matches!(result, Err(LinearError::Forbidden(_))));
338 }
339
340 #[tokio::test]
341 async fn execute_returns_429_as_rate_limited_error() {
342 let server = MockServer::start().await;
343 Mock::given(method("POST"))
344 .respond_with(
345 ResponseTemplate::new(429)
346 .append_header("retry-after", "30")
347 .set_body_string("Too Many Requests"),
348 )
349 .mount(&server)
350 .await;
351
352 let client = Client::from_token("token")
353 .unwrap()
354 .with_base_url(server.uri());
355
356 let result = client
357 .execute::<serde_json::Value>(
358 "query { viewer { id } }",
359 serde_json::json!({}),
360 "viewer",
361 )
362 .await;
363
364 match result {
365 Err(LinearError::RateLimited {
366 retry_after,
367 message,
368 }) => {
369 assert_eq!(retry_after, Some(30.0));
370 assert_eq!(message, "Too Many Requests");
371 }
372 other => panic!("Expected RateLimited, got {:?}", other),
373 }
374 }
375
376 #[tokio::test]
377 async fn execute_returns_500_as_http_error() {
378 let server = MockServer::start().await;
379 Mock::given(method("POST"))
380 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
381 .mount(&server)
382 .await;
383
384 let client = Client::from_token("token")
385 .unwrap()
386 .with_base_url(server.uri());
387
388 let result = client
389 .execute::<serde_json::Value>(
390 "query { viewer { id } }",
391 serde_json::json!({}),
392 "viewer",
393 )
394 .await;
395
396 match result {
397 Err(LinearError::HttpError { status, body }) => {
398 assert_eq!(status, 500);
399 assert_eq!(body, "Internal Server Error");
400 }
401 other => panic!("Expected HttpError, got {:?}", other),
402 }
403 }
404
405 #[tokio::test]
406 async fn execute_returns_graphql_errors() {
407 let server = MockServer::start().await;
408 Mock::given(method("POST"))
409 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
410 "data": null,
411 "errors": [{"message": "Field 'foo' not found"}]
412 })))
413 .mount(&server)
414 .await;
415
416 let client = Client::from_token("token")
417 .unwrap()
418 .with_base_url(server.uri());
419
420 let result = client
421 .execute::<serde_json::Value>("query { foo }", serde_json::json!({}), "foo")
422 .await;
423
424 assert!(matches!(result, Err(LinearError::GraphQL { .. })));
425 }
426
427 #[tokio::test]
428 async fn execute_graphql_auth_error_detected() {
429 let server = MockServer::start().await;
430 Mock::given(method("POST"))
431 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
432 "data": null,
433 "errors": [{"message": "Authentication required"}]
434 })))
435 .mount(&server)
436 .await;
437
438 let client = Client::from_token("token")
439 .unwrap()
440 .with_base_url(server.uri());
441
442 let result = client
443 .execute::<serde_json::Value>(
444 "query { viewer { id } }",
445 serde_json::json!({}),
446 "viewer",
447 )
448 .await;
449
450 assert!(matches!(result, Err(LinearError::Authentication(_))));
451 }
452
453 #[tokio::test]
454 async fn execute_missing_data_path() {
455 let server = MockServer::start().await;
456 Mock::given(method("POST"))
457 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
458 "data": {"other": {"id": "123"}}
459 })))
460 .mount(&server)
461 .await;
462
463 let client = Client::from_token("token")
464 .unwrap()
465 .with_base_url(server.uri());
466
467 let result = client
468 .execute::<serde_json::Value>(
469 "query { viewer { id } }",
470 serde_json::json!({}),
471 "viewer",
472 )
473 .await;
474
475 match result {
476 Err(LinearError::MissingData(msg)) => {
477 assert!(msg.contains("viewer"));
478 }
479 other => panic!("Expected MissingData, got {:?}", other),
480 }
481 }
482
483 #[tokio::test]
484 async fn execute_no_data_in_response() {
485 let server = MockServer::start().await;
486 Mock::given(method("POST"))
487 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
488 "data": null
489 })))
490 .mount(&server)
491 .await;
492
493 let client = Client::from_token("token")
494 .unwrap()
495 .with_base_url(server.uri());
496
497 let result = client
498 .execute::<serde_json::Value>(
499 "query { viewer { id } }",
500 serde_json::json!({}),
501 "viewer",
502 )
503 .await;
504
505 assert!(matches!(result, Err(LinearError::MissingData(_))));
506 }
507
508 #[tokio::test]
509 async fn execute_success_deserializes() {
510 let server = MockServer::start().await;
511 Mock::given(method("POST"))
512 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
513 "data": {
514 "viewer": {
515 "id": "user-123",
516 "name": "Test User",
517 "email": "test@example.com",
518 "active": true
519 }
520 }
521 })))
522 .mount(&server)
523 .await;
524
525 let client = Client::from_token("token")
526 .unwrap()
527 .with_base_url(server.uri());
528
529 let result: serde_json::Value = client
530 .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
531 .await
532 .unwrap();
533
534 assert_eq!(result["id"], "user-123");
535 assert_eq!(result["name"], "Test User");
536 }
537
538 #[tokio::test]
539 async fn execute_connection_deserializes() {
540 let server = MockServer::start().await;
541 Mock::given(method("POST"))
542 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
543 "data": {
544 "teams": {
545 "nodes": [
546 {"id": "team-1", "name": "Engineering", "key": "ENG"},
547 {"id": "team-2", "name": "Design", "key": "DES"}
548 ],
549 "pageInfo": {
550 "hasNextPage": false,
551 "endCursor": "cursor-abc"
552 }
553 }
554 }
555 })))
556 .mount(&server)
557 .await;
558
559 let client = Client::from_token("token")
560 .unwrap()
561 .with_base_url(server.uri());
562
563 let conn: Connection<serde_json::Value> = client
564 .execute_connection(
565 "query { teams { nodes { id } pageInfo { hasNextPage endCursor } } }",
566 serde_json::json!({}),
567 "teams",
568 )
569 .await
570 .unwrap();
571
572 assert_eq!(conn.nodes.len(), 2);
573 assert_eq!(conn.nodes[0]["id"], "team-1");
574 assert!(!conn.page_info.has_next_page);
575 assert_eq!(conn.page_info.end_cursor, Some("cursor-abc".to_string()));
576 }
577
578 #[tokio::test]
579 async fn execute_sends_authorization_header() {
580 let server = MockServer::start().await;
581 Mock::given(method("POST"))
582 .and(header("Authorization", "my-secret-token"))
583 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
584 "data": {"viewer": {"id": "1"}}
585 })))
586 .mount(&server)
587 .await;
588
589 let client = Client::from_token("my-secret-token")
590 .unwrap()
591 .with_base_url(server.uri());
592
593 let result: serde_json::Value = client
594 .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
595 .await
596 .unwrap();
597
598 assert_eq!(result["id"], "1");
599 }
600}