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::auto`], [`Client::from_env`],
5//! [`Client::from_file`], or [`Client::from_token`], then call generated
6//! query and mutation methods.
7
8use 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/// The Linear API client.
16#[derive(Debug, Clone)]
17pub struct Client {
18    http: reqwest::Client,
19    token: String,
20    base_url: String,
21}
22
23/// Raw GraphQL response shape.
24#[derive(serde::Deserialize)]
25struct GraphQLResponse {
26    data: Option<serde_json::Value>,
27    errors: Option<Vec<GraphQLError>>,
28}
29
30impl Client {
31    /// Create a client with an explicit API token.
32    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    /// Create a client from the `LINEAR_API_TOKEN` environment variable.
45    pub fn from_env() -> Result<Self, LinearError> {
46        Self::from_token(auth::token_from_env()?)
47    }
48
49    /// Create a client from the `~/.linear_api_token` file.
50    pub fn from_file() -> Result<Self, LinearError> {
51        Self::from_token(auth::token_from_file()?)
52    }
53
54    /// Create a client by auto-detecting the token (env -> file).
55    pub fn auto() -> Result<Self, LinearError> {
56        Self::from_token(auth::auto_token()?)
57    }
58
59    /// Execute a GraphQL query and extract a single object from the response.
60    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        // Check for GraphQL-level errors.
115        if let Some(errors) = gql_response.errors {
116            if !errors.is_empty() {
117                // Check for specific error types.
118                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                // Extract operation name from query string (e.g. "query Viewer { ... }" → "Viewer").
123                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    /// Execute a GraphQL query and extract a Connection from the response.
150    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    /// Execute a typed query using the type's [`GraphQLFields`](crate::GraphQLFields) implementation.
161    ///
162    /// Builds the query from `T::selection()` — define a struct with only
163    /// the fields you need for zero-overfetch queries.
164    ///
165    /// ```ignore
166    /// #[derive(Deserialize)]
167    /// struct MyViewer { name: Option<String>, email: Option<String> }
168    ///
169    /// impl GraphQLFields for MyViewer {
170    ///     fn selection() -> String { "name email".into() }
171    /// }
172    ///
173    /// let me: MyViewer = client.query::<MyViewer>("viewer").await?;
174    /// ```
175    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    /// Execute a typed connection query using the node type's
186    /// [`GraphQLFields`](crate::GraphQLFields) implementation.
187    ///
188    /// Builds `{ field { nodes { <T::selection()> } pageInfo { ... } } }`.
189    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    /// Execute a mutation, check `success`, and extract the entity field.
203    ///
204    /// Many Linear mutations return a payload shaped like
205    /// `{ success: Boolean, entityField: { ... } }`. This helper:
206    /// 1. Executes the query and extracts the payload at `data_path`
207    /// 2. Checks the `success` field — returns an error if false
208    /// 3. Extracts and deserializes `payload[entity_field]` as `T`
209    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        // Check success field.
221        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        // Extract and deserialize the entity.
230        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    /// Access the underlying HTTP client.
249    ///
250    /// Used internally by [`helpers`](crate::helpers) for file download/upload
251    /// operations that go outside the GraphQL API.
252    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    /// Override the base URL (for testing against mock servers).
261    #[cfg(test)]
262    pub(crate) fn with_base_url(mut self, url: String) -> Self {
263        self.base_url = url;
264        self
265    }
266
267    /// Allow integration tests (in tests/ directory) to set base URL.
268    #[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}