1use crate::auth;
9use crate::error::{GraphQLError, LinearError};
10use crate::pagination::Connection;
11use serde::de::DeserializeOwned;
12use std::path::Path;
13
14const LINEAR_API_URL: &str = "https://api.linear.app/graphql";
15
16#[derive(Debug, Clone)]
18pub struct Client {
19 http: reqwest::Client,
20 token: String,
21 base_url: String,
22}
23
24#[derive(serde::Deserialize)]
26struct GraphQLResponse {
27 data: Option<serde_json::Value>,
28 errors: Option<Vec<GraphQLError>>,
29}
30
31impl Client {
32 pub fn from_token(token: impl Into<String>) -> Result<Self, LinearError> {
34 let token = token.into();
35 if token.is_empty() {
36 return Err(LinearError::AuthConfig("Token cannot be empty".to_string()));
37 }
38 Ok(Self {
39 http: reqwest::Client::new(),
40 token,
41 base_url: LINEAR_API_URL.to_string(),
42 })
43 }
44
45 pub fn from_env() -> Result<Self, LinearError> {
47 Self::from_token(auth::token_from_env()?)
48 }
49
50 pub fn from_token_file(path: &Path) -> Result<Self, LinearError> {
52 Self::from_token(auth::token_from_file(path)?)
53 }
54
55 pub async fn execute<T: DeserializeOwned>(
57 &self,
58 query: &str,
59 variables: serde_json::Value,
60 data_path: &str,
61 ) -> Result<T, LinearError> {
62 let body = serde_json::json!({
63 "query": query,
64 "variables": variables,
65 });
66
67 let response = self
68 .http
69 .post(&self.base_url)
70 .header("Authorization", &self.token)
71 .header("Content-Type", "application/json")
72 .header(
73 "User-Agent",
74 format!("lineark-sdk/{}", env!("CARGO_PKG_VERSION")),
75 )
76 .json(&body)
77 .send()
78 .await?;
79
80 let status = response.status();
81 if status == 401 || status == 403 {
82 let text = response.text().await.unwrap_or_default();
83 if status == 401 {
84 return Err(LinearError::Authentication(text));
85 }
86 return Err(LinearError::Forbidden(text));
87 }
88 if status == 429 {
89 let retry_after = response
90 .headers()
91 .get("retry-after")
92 .and_then(|v| v.to_str().ok())
93 .and_then(|v| v.parse::<f64>().ok());
94 let text = response.text().await.unwrap_or_default();
95 return Err(LinearError::RateLimited {
96 retry_after,
97 message: text,
98 });
99 }
100 if !status.is_success() {
101 let body = response.text().await.unwrap_or_default();
102 return Err(LinearError::HttpError {
103 status: status.as_u16(),
104 body,
105 });
106 }
107
108 let gql_response: GraphQLResponse = response.json().await?;
109
110 if let Some(errors) = gql_response.errors {
112 if !errors.is_empty() {
113 let first_msg = errors[0].message.to_lowercase();
115 if first_msg.contains("authentication") || first_msg.contains("unauthorized") {
116 return Err(LinearError::Authentication(errors[0].message.clone()));
117 }
118 let query_name = query
120 .strip_prefix("query ")
121 .or_else(|| query.strip_prefix("mutation "))
122 .and_then(|rest| rest.split(['(', ' ', '{']).next())
123 .filter(|s| !s.is_empty())
124 .map(|s| s.to_string());
125 return Err(LinearError::GraphQL { errors, query_name });
126 }
127 }
128
129 let data = gql_response
130 .data
131 .ok_or_else(|| LinearError::MissingData("No data in response".to_string()))?;
132
133 let value = data
134 .get(data_path)
135 .ok_or_else(|| {
136 LinearError::MissingData(format!("No '{}' in response data", data_path))
137 })?
138 .clone();
139
140 serde_json::from_value(value).map_err(|e| {
141 LinearError::MissingData(format!("Failed to deserialize '{}': {}", data_path, e))
142 })
143 }
144
145 pub async fn execute_connection<T: DeserializeOwned>(
147 &self,
148 query: &str,
149 variables: serde_json::Value,
150 data_path: &str,
151 ) -> Result<Connection<T>, LinearError> {
152 self.execute::<Connection<T>>(query, variables, data_path)
153 .await
154 }
155
156 pub async fn query<T: DeserializeOwned + crate::GraphQLFields>(
172 &self,
173 field: &str,
174 ) -> Result<T, LinearError> {
175 let selection = T::selection();
176 let query = format!("query {{ {} {{ {} }} }}", field, selection);
177 self.execute::<T>(&query, serde_json::json!({}), field)
178 .await
179 }
180
181 pub async fn query_connection<T: DeserializeOwned + crate::GraphQLFields>(
186 &self,
187 field: &str,
188 ) -> Result<Connection<T>, LinearError> {
189 let selection = T::selection();
190 let query = format!(
191 "query {{ {} {{ nodes {{ {} }} pageInfo {{ hasNextPage endCursor }} }} }}",
192 field, selection
193 );
194 self.execute_connection::<T>(&query, serde_json::json!({}), field)
195 .await
196 }
197
198 pub(crate) async fn execute_mutation<T: DeserializeOwned>(
206 &self,
207 query: &str,
208 variables: serde_json::Value,
209 data_path: &str,
210 entity_field: &str,
211 ) -> Result<T, LinearError> {
212 let payload = self
213 .execute::<serde_json::Value>(query, variables, data_path)
214 .await?;
215
216 if payload.get("success").and_then(|v| v.as_bool()) != Some(true) {
218 return Err(LinearError::Internal(format!(
219 "Mutation '{}' failed: {}",
220 data_path,
221 serde_json::to_string_pretty(&payload).unwrap_or_default()
222 )));
223 }
224
225 let entity = payload
227 .get(entity_field)
228 .ok_or_else(|| {
229 LinearError::MissingData(format!(
230 "No '{}' field in '{}' payload",
231 entity_field, data_path
232 ))
233 })?
234 .clone();
235
236 serde_json::from_value(entity).map_err(|e| {
237 LinearError::MissingData(format!(
238 "Failed to deserialize '{}' from '{}': {}",
239 entity_field, data_path, e
240 ))
241 })
242 }
243
244 pub(crate) fn http(&self) -> &reqwest::Client {
249 &self.http
250 }
251
252 pub(crate) fn token(&self) -> &str {
253 &self.token
254 }
255
256 #[cfg(test)]
258 pub(crate) fn with_base_url(mut self, url: String) -> Self {
259 self.base_url = url;
260 self
261 }
262
263 #[doc(hidden)]
265 pub fn set_base_url(&mut self, url: String) {
266 self.base_url = url;
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use wiremock::matchers::{header, method};
274 use wiremock::{Mock, MockServer, ResponseTemplate};
275
276 #[test]
277 fn from_token_valid() {
278 let client = Client::from_token("lin_api_test123").unwrap();
279 assert_eq!(client.token, "lin_api_test123");
280 assert_eq!(client.base_url, LINEAR_API_URL);
281 }
282
283 #[test]
284 fn from_token_empty_fails() {
285 let err = Client::from_token("").unwrap_err();
286 assert!(matches!(err, LinearError::AuthConfig(_)));
287 assert!(err.to_string().contains("empty"));
288 }
289
290 #[tokio::test]
291 async fn execute_returns_401_as_authentication_error() {
292 let server = MockServer::start().await;
293 Mock::given(method("POST"))
294 .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
295 .mount(&server)
296 .await;
297
298 let client = Client::from_token("bad-token")
299 .unwrap()
300 .with_base_url(server.uri());
301
302 let result = client
303 .execute::<serde_json::Value>(
304 "query { viewer { id } }",
305 serde_json::json!({}),
306 "viewer",
307 )
308 .await;
309
310 assert!(matches!(result, Err(LinearError::Authentication(_))));
311 }
312
313 #[tokio::test]
314 async fn execute_returns_403_as_forbidden_error() {
315 let server = MockServer::start().await;
316 Mock::given(method("POST"))
317 .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
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>(
327 "query { viewer { id } }",
328 serde_json::json!({}),
329 "viewer",
330 )
331 .await;
332
333 assert!(matches!(result, Err(LinearError::Forbidden(_))));
334 }
335
336 #[tokio::test]
337 async fn execute_returns_429_as_rate_limited_error() {
338 let server = MockServer::start().await;
339 Mock::given(method("POST"))
340 .respond_with(
341 ResponseTemplate::new(429)
342 .append_header("retry-after", "30")
343 .set_body_string("Too Many Requests"),
344 )
345 .mount(&server)
346 .await;
347
348 let client = Client::from_token("token")
349 .unwrap()
350 .with_base_url(server.uri());
351
352 let result = client
353 .execute::<serde_json::Value>(
354 "query { viewer { id } }",
355 serde_json::json!({}),
356 "viewer",
357 )
358 .await;
359
360 match result {
361 Err(LinearError::RateLimited {
362 retry_after,
363 message,
364 }) => {
365 assert_eq!(retry_after, Some(30.0));
366 assert_eq!(message, "Too Many Requests");
367 }
368 other => panic!("Expected RateLimited, got {:?}", other),
369 }
370 }
371
372 #[tokio::test]
373 async fn execute_returns_500_as_http_error() {
374 let server = MockServer::start().await;
375 Mock::given(method("POST"))
376 .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
377 .mount(&server)
378 .await;
379
380 let client = Client::from_token("token")
381 .unwrap()
382 .with_base_url(server.uri());
383
384 let result = client
385 .execute::<serde_json::Value>(
386 "query { viewer { id } }",
387 serde_json::json!({}),
388 "viewer",
389 )
390 .await;
391
392 match result {
393 Err(LinearError::HttpError { status, body }) => {
394 assert_eq!(status, 500);
395 assert_eq!(body, "Internal Server Error");
396 }
397 other => panic!("Expected HttpError, got {:?}", other),
398 }
399 }
400
401 #[tokio::test]
402 async fn execute_returns_graphql_errors() {
403 let server = MockServer::start().await;
404 Mock::given(method("POST"))
405 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
406 "data": null,
407 "errors": [{"message": "Field 'foo' not found"}]
408 })))
409 .mount(&server)
410 .await;
411
412 let client = Client::from_token("token")
413 .unwrap()
414 .with_base_url(server.uri());
415
416 let result = client
417 .execute::<serde_json::Value>("query { foo }", serde_json::json!({}), "foo")
418 .await;
419
420 assert!(matches!(result, Err(LinearError::GraphQL { .. })));
421 }
422
423 #[tokio::test]
424 async fn execute_graphql_auth_error_detected() {
425 let server = MockServer::start().await;
426 Mock::given(method("POST"))
427 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
428 "data": null,
429 "errors": [{"message": "Authentication required"}]
430 })))
431 .mount(&server)
432 .await;
433
434 let client = Client::from_token("token")
435 .unwrap()
436 .with_base_url(server.uri());
437
438 let result = client
439 .execute::<serde_json::Value>(
440 "query { viewer { id } }",
441 serde_json::json!({}),
442 "viewer",
443 )
444 .await;
445
446 assert!(matches!(result, Err(LinearError::Authentication(_))));
447 }
448
449 #[tokio::test]
450 async fn execute_missing_data_path() {
451 let server = MockServer::start().await;
452 Mock::given(method("POST"))
453 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
454 "data": {"other": {"id": "123"}}
455 })))
456 .mount(&server)
457 .await;
458
459 let client = Client::from_token("token")
460 .unwrap()
461 .with_base_url(server.uri());
462
463 let result = client
464 .execute::<serde_json::Value>(
465 "query { viewer { id } }",
466 serde_json::json!({}),
467 "viewer",
468 )
469 .await;
470
471 match result {
472 Err(LinearError::MissingData(msg)) => {
473 assert!(msg.contains("viewer"));
474 }
475 other => panic!("Expected MissingData, got {:?}", other),
476 }
477 }
478
479 #[tokio::test]
480 async fn execute_no_data_in_response() {
481 let server = MockServer::start().await;
482 Mock::given(method("POST"))
483 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
484 "data": null
485 })))
486 .mount(&server)
487 .await;
488
489 let client = Client::from_token("token")
490 .unwrap()
491 .with_base_url(server.uri());
492
493 let result = client
494 .execute::<serde_json::Value>(
495 "query { viewer { id } }",
496 serde_json::json!({}),
497 "viewer",
498 )
499 .await;
500
501 assert!(matches!(result, Err(LinearError::MissingData(_))));
502 }
503
504 #[tokio::test]
505 async fn execute_success_deserializes() {
506 let server = MockServer::start().await;
507 Mock::given(method("POST"))
508 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
509 "data": {
510 "viewer": {
511 "id": "user-123",
512 "name": "Test User",
513 "email": "test@example.com",
514 "active": true
515 }
516 }
517 })))
518 .mount(&server)
519 .await;
520
521 let client = Client::from_token("token")
522 .unwrap()
523 .with_base_url(server.uri());
524
525 let result: serde_json::Value = client
526 .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
527 .await
528 .unwrap();
529
530 assert_eq!(result["id"], "user-123");
531 assert_eq!(result["name"], "Test User");
532 }
533
534 #[tokio::test]
535 async fn execute_connection_deserializes() {
536 let server = MockServer::start().await;
537 Mock::given(method("POST"))
538 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
539 "data": {
540 "teams": {
541 "nodes": [
542 {"id": "team-1", "name": "Engineering", "key": "ENG"},
543 {"id": "team-2", "name": "Design", "key": "DES"}
544 ],
545 "pageInfo": {
546 "hasNextPage": false,
547 "endCursor": "cursor-abc"
548 }
549 }
550 }
551 })))
552 .mount(&server)
553 .await;
554
555 let client = Client::from_token("token")
556 .unwrap()
557 .with_base_url(server.uri());
558
559 let conn: Connection<serde_json::Value> = client
560 .execute_connection(
561 "query { teams { nodes { id } pageInfo { hasNextPage endCursor } } }",
562 serde_json::json!({}),
563 "teams",
564 )
565 .await
566 .unwrap();
567
568 assert_eq!(conn.nodes.len(), 2);
569 assert_eq!(conn.nodes[0]["id"], "team-1");
570 assert!(!conn.page_info.has_next_page);
571 assert_eq!(conn.page_info.end_cursor, Some("cursor-abc".to_string()));
572 }
573
574 #[tokio::test]
575 async fn execute_sends_authorization_header() {
576 let server = MockServer::start().await;
577 Mock::given(method("POST"))
578 .and(header("Authorization", "my-secret-token"))
579 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
580 "data": {"viewer": {"id": "1"}}
581 })))
582 .mount(&server)
583 .await;
584
585 let client = Client::from_token("my-secret-token")
586 .unwrap()
587 .with_base_url(server.uri());
588
589 let result: serde_json::Value = client
590 .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
591 .await
592 .unwrap();
593
594 assert_eq!(result["id"], "1");
595 }
596}