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                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    /// Execute a GraphQL query and extract a Connection from the response.
143    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    /// Access the underlying HTTP client.
154    ///
155    /// Used internally by [`helpers`](crate::helpers) for file download/upload
156    /// operations that go outside the GraphQL API.
157    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    /// Override the base URL (for testing against mock servers).
166    #[cfg(test)]
167    pub(crate) fn with_base_url(mut self, url: String) -> Self {
168        self.base_url = url;
169        self
170    }
171
172    /// Allow integration tests (in tests/ directory) to set base URL.
173    #[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}