Skip to main content

lineark_sdk/
client.rs

1use crate::auth;
2use crate::error::{GraphQLError, LinearError};
3use crate::pagination::Connection;
4use serde::de::DeserializeOwned;
5
6const LINEAR_API_URL: &str = "https://api.linear.app/graphql";
7
8/// The Linear API client.
9#[derive(Debug, Clone)]
10pub struct Client {
11    http: reqwest::Client,
12    token: String,
13    base_url: String,
14}
15
16/// Raw GraphQL response shape.
17#[derive(serde::Deserialize)]
18struct GraphQLResponse {
19    data: Option<serde_json::Value>,
20    errors: Option<Vec<GraphQLError>>,
21}
22
23impl Client {
24    /// Create a client with an explicit API token.
25    pub fn from_token(token: impl Into<String>) -> Result<Self, LinearError> {
26        let token = token.into();
27        if token.is_empty() {
28            return Err(LinearError::AuthConfig("Token cannot be empty".to_string()));
29        }
30        Ok(Self {
31            http: reqwest::Client::new(),
32            token,
33            base_url: LINEAR_API_URL.to_string(),
34        })
35    }
36
37    /// Create a client from the `LINEAR_API_TOKEN` environment variable.
38    pub fn from_env() -> Result<Self, LinearError> {
39        Self::from_token(auth::token_from_env()?)
40    }
41
42    /// Create a client from the `~/.linear_api_token` file.
43    pub fn from_file() -> Result<Self, LinearError> {
44        Self::from_token(auth::token_from_file()?)
45    }
46
47    /// Create a client by auto-detecting the token (env -> file).
48    pub fn auto() -> Result<Self, LinearError> {
49        Self::from_token(auth::auto_token()?)
50    }
51
52    /// Execute a GraphQL query and extract a single object from the response.
53    pub async fn execute<T: DeserializeOwned>(
54        &self,
55        query: &str,
56        variables: serde_json::Value,
57        data_path: &str,
58    ) -> Result<T, LinearError> {
59        let body = serde_json::json!({
60            "query": query,
61            "variables": variables,
62        });
63
64        let response = self
65            .http
66            .post(&self.base_url)
67            .header("Authorization", &self.token)
68            .header("Content-Type", "application/json")
69            .header(
70                "User-Agent",
71                format!("lineark-sdk/{}", env!("CARGO_PKG_VERSION")),
72            )
73            .json(&body)
74            .send()
75            .await?;
76
77        let status = response.status();
78        if status == 401 || status == 403 {
79            let text = response.text().await.unwrap_or_default();
80            if status == 401 {
81                return Err(LinearError::Authentication(text));
82            }
83            return Err(LinearError::Forbidden(text));
84        }
85        if status == 429 {
86            let retry_after = response
87                .headers()
88                .get("retry-after")
89                .and_then(|v| v.to_str().ok())
90                .and_then(|v| v.parse::<f64>().ok());
91            let text = response.text().await.unwrap_or_default();
92            return Err(LinearError::RateLimited {
93                retry_after,
94                message: text,
95            });
96        }
97        if !status.is_success() {
98            let body = response.text().await.unwrap_or_default();
99            return Err(LinearError::HttpError {
100                status: status.as_u16(),
101                body,
102            });
103        }
104
105        let gql_response: GraphQLResponse = response.json().await?;
106
107        // Check for GraphQL-level errors.
108        if let Some(errors) = gql_response.errors {
109            if !errors.is_empty() {
110                // Check for specific error types.
111                let first_msg = errors[0].message.to_lowercase();
112                if first_msg.contains("authentication") || first_msg.contains("unauthorized") {
113                    return Err(LinearError::Authentication(errors[0].message.clone()));
114                }
115                return Err(LinearError::GraphQL(errors));
116            }
117        }
118
119        let data = gql_response
120            .data
121            .ok_or_else(|| LinearError::MissingData("No data in response".to_string()))?;
122
123        let value = data
124            .get(data_path)
125            .ok_or_else(|| {
126                LinearError::MissingData(format!("No '{}' in response data", data_path))
127            })?
128            .clone();
129
130        serde_json::from_value(value).map_err(|e| {
131            LinearError::MissingData(format!("Failed to deserialize '{}': {}", data_path, e))
132        })
133    }
134
135    /// Execute a GraphQL query and extract a Connection from the response.
136    pub async fn execute_connection<T: DeserializeOwned>(
137        &self,
138        query: &str,
139        variables: serde_json::Value,
140        data_path: &str,
141    ) -> Result<Connection<T>, LinearError> {
142        self.execute::<Connection<T>>(query, variables, data_path)
143            .await
144    }
145
146    /// Access the underlying HTTP client.
147    ///
148    /// Used internally by [`helpers`](crate::helpers) for file download/upload
149    /// operations that go outside the GraphQL API.
150    pub(crate) fn http(&self) -> &reqwest::Client {
151        &self.http
152    }
153
154    pub(crate) fn token(&self) -> &str {
155        &self.token
156    }
157
158    /// Override the base URL (for testing against mock servers).
159    #[cfg(test)]
160    pub(crate) fn with_base_url(mut self, url: String) -> Self {
161        self.base_url = url;
162        self
163    }
164}
165
166/// Allow integration tests (in tests/ directory) to set base URL.
167impl Client {
168    #[doc(hidden)]
169    pub fn set_base_url(&mut self, url: String) {
170        self.base_url = url;
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use wiremock::matchers::{header, method};
178    use wiremock::{Mock, MockServer, ResponseTemplate};
179
180    #[test]
181    fn from_token_valid() {
182        let client = Client::from_token("lin_api_test123").unwrap();
183        assert_eq!(client.token, "lin_api_test123");
184        assert_eq!(client.base_url, LINEAR_API_URL);
185    }
186
187    #[test]
188    fn from_token_empty_fails() {
189        let err = Client::from_token("").unwrap_err();
190        assert!(matches!(err, LinearError::AuthConfig(_)));
191        assert!(err.to_string().contains("empty"));
192    }
193
194    #[tokio::test]
195    async fn execute_returns_401_as_authentication_error() {
196        let server = MockServer::start().await;
197        Mock::given(method("POST"))
198            .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
199            .mount(&server)
200            .await;
201
202        let client = Client::from_token("bad-token")
203            .unwrap()
204            .with_base_url(server.uri());
205
206        let result = client
207            .execute::<serde_json::Value>(
208                "query { viewer { id } }",
209                serde_json::json!({}),
210                "viewer",
211            )
212            .await;
213
214        assert!(matches!(result, Err(LinearError::Authentication(_))));
215    }
216
217    #[tokio::test]
218    async fn execute_returns_403_as_forbidden_error() {
219        let server = MockServer::start().await;
220        Mock::given(method("POST"))
221            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
222            .mount(&server)
223            .await;
224
225        let client = Client::from_token("token")
226            .unwrap()
227            .with_base_url(server.uri());
228
229        let result = client
230            .execute::<serde_json::Value>(
231                "query { viewer { id } }",
232                serde_json::json!({}),
233                "viewer",
234            )
235            .await;
236
237        assert!(matches!(result, Err(LinearError::Forbidden(_))));
238    }
239
240    #[tokio::test]
241    async fn execute_returns_429_as_rate_limited_error() {
242        let server = MockServer::start().await;
243        Mock::given(method("POST"))
244            .respond_with(
245                ResponseTemplate::new(429)
246                    .append_header("retry-after", "30")
247                    .set_body_string("Too Many Requests"),
248            )
249            .mount(&server)
250            .await;
251
252        let client = Client::from_token("token")
253            .unwrap()
254            .with_base_url(server.uri());
255
256        let result = client
257            .execute::<serde_json::Value>(
258                "query { viewer { id } }",
259                serde_json::json!({}),
260                "viewer",
261            )
262            .await;
263
264        match result {
265            Err(LinearError::RateLimited {
266                retry_after,
267                message,
268            }) => {
269                assert_eq!(retry_after, Some(30.0));
270                assert_eq!(message, "Too Many Requests");
271            }
272            other => panic!("Expected RateLimited, got {:?}", other),
273        }
274    }
275
276    #[tokio::test]
277    async fn execute_returns_500_as_http_error() {
278        let server = MockServer::start().await;
279        Mock::given(method("POST"))
280            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
281            .mount(&server)
282            .await;
283
284        let client = Client::from_token("token")
285            .unwrap()
286            .with_base_url(server.uri());
287
288        let result = client
289            .execute::<serde_json::Value>(
290                "query { viewer { id } }",
291                serde_json::json!({}),
292                "viewer",
293            )
294            .await;
295
296        match result {
297            Err(LinearError::HttpError { status, body }) => {
298                assert_eq!(status, 500);
299                assert_eq!(body, "Internal Server Error");
300            }
301            other => panic!("Expected HttpError, got {:?}", other),
302        }
303    }
304
305    #[tokio::test]
306    async fn execute_returns_graphql_errors() {
307        let server = MockServer::start().await;
308        Mock::given(method("POST"))
309            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
310                "data": null,
311                "errors": [{"message": "Field 'foo' not found"}]
312            })))
313            .mount(&server)
314            .await;
315
316        let client = Client::from_token("token")
317            .unwrap()
318            .with_base_url(server.uri());
319
320        let result = client
321            .execute::<serde_json::Value>("query { foo }", serde_json::json!({}), "foo")
322            .await;
323
324        assert!(matches!(result, Err(LinearError::GraphQL(_))));
325    }
326
327    #[tokio::test]
328    async fn execute_graphql_auth_error_detected() {
329        let server = MockServer::start().await;
330        Mock::given(method("POST"))
331            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
332                "data": null,
333                "errors": [{"message": "Authentication required"}]
334            })))
335            .mount(&server)
336            .await;
337
338        let client = Client::from_token("token")
339            .unwrap()
340            .with_base_url(server.uri());
341
342        let result = client
343            .execute::<serde_json::Value>(
344                "query { viewer { id } }",
345                serde_json::json!({}),
346                "viewer",
347            )
348            .await;
349
350        assert!(matches!(result, Err(LinearError::Authentication(_))));
351    }
352
353    #[tokio::test]
354    async fn execute_missing_data_path() {
355        let server = MockServer::start().await;
356        Mock::given(method("POST"))
357            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
358                "data": {"other": {"id": "123"}}
359            })))
360            .mount(&server)
361            .await;
362
363        let client = Client::from_token("token")
364            .unwrap()
365            .with_base_url(server.uri());
366
367        let result = client
368            .execute::<serde_json::Value>(
369                "query { viewer { id } }",
370                serde_json::json!({}),
371                "viewer",
372            )
373            .await;
374
375        match result {
376            Err(LinearError::MissingData(msg)) => {
377                assert!(msg.contains("viewer"));
378            }
379            other => panic!("Expected MissingData, got {:?}", other),
380        }
381    }
382
383    #[tokio::test]
384    async fn execute_no_data_in_response() {
385        let server = MockServer::start().await;
386        Mock::given(method("POST"))
387            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
388                "data": null
389            })))
390            .mount(&server)
391            .await;
392
393        let client = Client::from_token("token")
394            .unwrap()
395            .with_base_url(server.uri());
396
397        let result = client
398            .execute::<serde_json::Value>(
399                "query { viewer { id } }",
400                serde_json::json!({}),
401                "viewer",
402            )
403            .await;
404
405        assert!(matches!(result, Err(LinearError::MissingData(_))));
406    }
407
408    #[tokio::test]
409    async fn execute_success_deserializes() {
410        let server = MockServer::start().await;
411        Mock::given(method("POST"))
412            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
413                "data": {
414                    "viewer": {
415                        "id": "user-123",
416                        "name": "Test User",
417                        "email": "test@example.com",
418                        "active": true
419                    }
420                }
421            })))
422            .mount(&server)
423            .await;
424
425        let client = Client::from_token("token")
426            .unwrap()
427            .with_base_url(server.uri());
428
429        let result: serde_json::Value = client
430            .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
431            .await
432            .unwrap();
433
434        assert_eq!(result["id"], "user-123");
435        assert_eq!(result["name"], "Test User");
436    }
437
438    #[tokio::test]
439    async fn execute_connection_deserializes() {
440        let server = MockServer::start().await;
441        Mock::given(method("POST"))
442            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
443                "data": {
444                    "teams": {
445                        "nodes": [
446                            {"id": "team-1", "name": "Engineering", "key": "ENG"},
447                            {"id": "team-2", "name": "Design", "key": "DES"}
448                        ],
449                        "pageInfo": {
450                            "hasNextPage": false,
451                            "endCursor": "cursor-abc"
452                        }
453                    }
454                }
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 conn: Connection<serde_json::Value> = client
464            .execute_connection(
465                "query { teams { nodes { id } pageInfo { hasNextPage endCursor } } }",
466                serde_json::json!({}),
467                "teams",
468            )
469            .await
470            .unwrap();
471
472        assert_eq!(conn.nodes.len(), 2);
473        assert_eq!(conn.nodes[0]["id"], "team-1");
474        assert!(!conn.page_info.has_next_page);
475        assert_eq!(conn.page_info.end_cursor, Some("cursor-abc".to_string()));
476    }
477
478    #[tokio::test]
479    async fn execute_sends_authorization_header() {
480        let server = MockServer::start().await;
481        Mock::given(method("POST"))
482            .and(header("Authorization", "my-secret-token"))
483            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
484                "data": {"viewer": {"id": "1"}}
485            })))
486            .mount(&server)
487            .await;
488
489        let client = Client::from_token("my-secret-token")
490            .unwrap()
491            .with_base_url(server.uri());
492
493        let result: serde_json::Value = client
494            .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
495            .await
496            .unwrap();
497
498        assert_eq!(result["id"], "1");
499    }
500}