Skip to main content

lineark_sdk/
client.rs

1//! Async Linear API client.
2//!
3//! The primary entry point for interacting with Linear's GraphQL API.
4//! Construct a [`Client`] via [`Client::from_token`], [`Client::from_env`],
5//! or [`Client::from_token_file`], then call generated query and mutation
6//! methods.
7
8use 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/// The Linear API client.
17#[derive(Debug, Clone)]
18pub struct Client {
19    http: reqwest::Client,
20    token: String,
21    base_url: String,
22}
23
24/// Raw GraphQL response shape.
25#[derive(serde::Deserialize)]
26struct GraphQLResponse {
27    data: Option<serde_json::Value>,
28    errors: Option<Vec<GraphQLError>>,
29}
30
31impl Client {
32    /// Create a client with an explicit API token.
33    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    /// Create a client from the `LINEAR_API_TOKEN` environment variable.
46    pub fn from_env() -> Result<Self, LinearError> {
47        Self::from_token(auth::token_from_env()?)
48    }
49
50    /// Create a client from a token file at the given path.
51    pub fn from_token_file(path: &Path) -> Result<Self, LinearError> {
52        Self::from_token(auth::token_from_file(path)?)
53    }
54
55    /// Execute a GraphQL query and extract a single object from the response.
56    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        // Check for GraphQL-level errors.
111        if let Some(errors) = gql_response.errors {
112            if !errors.is_empty() {
113                // Check for specific error types.
114                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                // Extract operation name from query string (e.g. "query Viewer { ... }" → "Viewer").
119                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    /// Execute a GraphQL query and extract a Connection from the response.
146    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    /// Execute a typed query using the type's [`GraphQLFields`](crate::GraphQLFields) implementation.
157    ///
158    /// Builds the query from `T::selection()` — define a struct with only
159    /// the fields you need for zero-overfetch queries.
160    ///
161    /// ```ignore
162    /// #[derive(Deserialize)]
163    /// struct MyViewer { name: Option<String>, email: Option<String> }
164    ///
165    /// impl GraphQLFields for MyViewer {
166    ///     fn selection() -> String { "name email".into() }
167    /// }
168    ///
169    /// let me: MyViewer = client.query::<MyViewer>("viewer").await?;
170    /// ```
171    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    /// Execute a typed connection query using the node type's
182    /// [`GraphQLFields`](crate::GraphQLFields) implementation.
183    ///
184    /// Builds `{ field { nodes { <T::selection()> } pageInfo { ... } } }`.
185    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    /// Execute a mutation, check `success`, and extract the entity field.
199    ///
200    /// Many Linear mutations return a payload shaped like
201    /// `{ success: Boolean, entityField: { ... } }`. This helper:
202    /// 1. Executes the query and extracts the payload at `data_path`
203    /// 2. Checks the `success` field — returns an error if false
204    /// 3. Extracts and deserializes `payload[entity_field]` as `T`
205    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        // Check success field.
217        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        // Extract and deserialize the entity.
226        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    /// Access the underlying HTTP client.
245    ///
246    /// Used internally by [`helpers`](crate::helpers) for file download/upload
247    /// operations that go outside the GraphQL API.
248    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    /// Override the base URL (for testing against mock servers).
257    #[cfg(test)]
258    pub(crate) fn with_base_url(mut self, url: String) -> Self {
259        self.base_url = url;
260        self
261    }
262
263    /// Allow integration tests (in tests/ directory) to set base URL.
264    #[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}