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    /// Override the base URL (for testing against mock servers).
147    #[cfg(test)]
148    pub(crate) fn with_base_url(mut self, url: String) -> Self {
149        self.base_url = url;
150        self
151    }
152}
153
154/// Allow integration tests (in tests/ directory) to set base URL.
155impl Client {
156    #[doc(hidden)]
157    pub fn set_base_url(&mut self, url: String) {
158        self.base_url = url;
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use wiremock::matchers::{header, method};
166    use wiremock::{Mock, MockServer, ResponseTemplate};
167
168    #[test]
169    fn from_token_valid() {
170        let client = Client::from_token("lin_api_test123").unwrap();
171        assert_eq!(client.token, "lin_api_test123");
172        assert_eq!(client.base_url, LINEAR_API_URL);
173    }
174
175    #[test]
176    fn from_token_empty_fails() {
177        let err = Client::from_token("").unwrap_err();
178        assert!(matches!(err, LinearError::AuthConfig(_)));
179        assert!(err.to_string().contains("empty"));
180    }
181
182    #[tokio::test]
183    async fn execute_returns_401_as_authentication_error() {
184        let server = MockServer::start().await;
185        Mock::given(method("POST"))
186            .respond_with(ResponseTemplate::new(401).set_body_string("Unauthorized"))
187            .mount(&server)
188            .await;
189
190        let client = Client::from_token("bad-token")
191            .unwrap()
192            .with_base_url(server.uri());
193
194        let result = client
195            .execute::<serde_json::Value>(
196                "query { viewer { id } }",
197                serde_json::json!({}),
198                "viewer",
199            )
200            .await;
201
202        assert!(matches!(result, Err(LinearError::Authentication(_))));
203    }
204
205    #[tokio::test]
206    async fn execute_returns_403_as_forbidden_error() {
207        let server = MockServer::start().await;
208        Mock::given(method("POST"))
209            .respond_with(ResponseTemplate::new(403).set_body_string("Forbidden"))
210            .mount(&server)
211            .await;
212
213        let client = Client::from_token("token")
214            .unwrap()
215            .with_base_url(server.uri());
216
217        let result = client
218            .execute::<serde_json::Value>(
219                "query { viewer { id } }",
220                serde_json::json!({}),
221                "viewer",
222            )
223            .await;
224
225        assert!(matches!(result, Err(LinearError::Forbidden(_))));
226    }
227
228    #[tokio::test]
229    async fn execute_returns_429_as_rate_limited_error() {
230        let server = MockServer::start().await;
231        Mock::given(method("POST"))
232            .respond_with(
233                ResponseTemplate::new(429)
234                    .append_header("retry-after", "30")
235                    .set_body_string("Too Many Requests"),
236            )
237            .mount(&server)
238            .await;
239
240        let client = Client::from_token("token")
241            .unwrap()
242            .with_base_url(server.uri());
243
244        let result = client
245            .execute::<serde_json::Value>(
246                "query { viewer { id } }",
247                serde_json::json!({}),
248                "viewer",
249            )
250            .await;
251
252        match result {
253            Err(LinearError::RateLimited {
254                retry_after,
255                message,
256            }) => {
257                assert_eq!(retry_after, Some(30.0));
258                assert_eq!(message, "Too Many Requests");
259            }
260            other => panic!("Expected RateLimited, got {:?}", other),
261        }
262    }
263
264    #[tokio::test]
265    async fn execute_returns_500_as_http_error() {
266        let server = MockServer::start().await;
267        Mock::given(method("POST"))
268            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
269            .mount(&server)
270            .await;
271
272        let client = Client::from_token("token")
273            .unwrap()
274            .with_base_url(server.uri());
275
276        let result = client
277            .execute::<serde_json::Value>(
278                "query { viewer { id } }",
279                serde_json::json!({}),
280                "viewer",
281            )
282            .await;
283
284        match result {
285            Err(LinearError::HttpError { status, body }) => {
286                assert_eq!(status, 500);
287                assert_eq!(body, "Internal Server Error");
288            }
289            other => panic!("Expected HttpError, got {:?}", other),
290        }
291    }
292
293    #[tokio::test]
294    async fn execute_returns_graphql_errors() {
295        let server = MockServer::start().await;
296        Mock::given(method("POST"))
297            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
298                "data": null,
299                "errors": [{"message": "Field 'foo' not found"}]
300            })))
301            .mount(&server)
302            .await;
303
304        let client = Client::from_token("token")
305            .unwrap()
306            .with_base_url(server.uri());
307
308        let result = client
309            .execute::<serde_json::Value>("query { foo }", serde_json::json!({}), "foo")
310            .await;
311
312        assert!(matches!(result, Err(LinearError::GraphQL(_))));
313    }
314
315    #[tokio::test]
316    async fn execute_graphql_auth_error_detected() {
317        let server = MockServer::start().await;
318        Mock::given(method("POST"))
319            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
320                "data": null,
321                "errors": [{"message": "Authentication required"}]
322            })))
323            .mount(&server)
324            .await;
325
326        let client = Client::from_token("token")
327            .unwrap()
328            .with_base_url(server.uri());
329
330        let result = client
331            .execute::<serde_json::Value>(
332                "query { viewer { id } }",
333                serde_json::json!({}),
334                "viewer",
335            )
336            .await;
337
338        assert!(matches!(result, Err(LinearError::Authentication(_))));
339    }
340
341    #[tokio::test]
342    async fn execute_missing_data_path() {
343        let server = MockServer::start().await;
344        Mock::given(method("POST"))
345            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
346                "data": {"other": {"id": "123"}}
347            })))
348            .mount(&server)
349            .await;
350
351        let client = Client::from_token("token")
352            .unwrap()
353            .with_base_url(server.uri());
354
355        let result = client
356            .execute::<serde_json::Value>(
357                "query { viewer { id } }",
358                serde_json::json!({}),
359                "viewer",
360            )
361            .await;
362
363        match result {
364            Err(LinearError::MissingData(msg)) => {
365                assert!(msg.contains("viewer"));
366            }
367            other => panic!("Expected MissingData, got {:?}", other),
368        }
369    }
370
371    #[tokio::test]
372    async fn execute_no_data_in_response() {
373        let server = MockServer::start().await;
374        Mock::given(method("POST"))
375            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
376                "data": null
377            })))
378            .mount(&server)
379            .await;
380
381        let client = Client::from_token("token")
382            .unwrap()
383            .with_base_url(server.uri());
384
385        let result = client
386            .execute::<serde_json::Value>(
387                "query { viewer { id } }",
388                serde_json::json!({}),
389                "viewer",
390            )
391            .await;
392
393        assert!(matches!(result, Err(LinearError::MissingData(_))));
394    }
395
396    #[tokio::test]
397    async fn execute_success_deserializes() {
398        let server = MockServer::start().await;
399        Mock::given(method("POST"))
400            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
401                "data": {
402                    "viewer": {
403                        "id": "user-123",
404                        "name": "Test User",
405                        "email": "test@example.com",
406                        "active": true
407                    }
408                }
409            })))
410            .mount(&server)
411            .await;
412
413        let client = Client::from_token("token")
414            .unwrap()
415            .with_base_url(server.uri());
416
417        let result: serde_json::Value = client
418            .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
419            .await
420            .unwrap();
421
422        assert_eq!(result["id"], "user-123");
423        assert_eq!(result["name"], "Test User");
424    }
425
426    #[tokio::test]
427    async fn execute_connection_deserializes() {
428        let server = MockServer::start().await;
429        Mock::given(method("POST"))
430            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
431                "data": {
432                    "teams": {
433                        "nodes": [
434                            {"id": "team-1", "name": "Engineering", "key": "ENG"},
435                            {"id": "team-2", "name": "Design", "key": "DES"}
436                        ],
437                        "pageInfo": {
438                            "hasNextPage": false,
439                            "endCursor": "cursor-abc"
440                        }
441                    }
442                }
443            })))
444            .mount(&server)
445            .await;
446
447        let client = Client::from_token("token")
448            .unwrap()
449            .with_base_url(server.uri());
450
451        let conn: Connection<serde_json::Value> = client
452            .execute_connection(
453                "query { teams { nodes { id } pageInfo { hasNextPage endCursor } } }",
454                serde_json::json!({}),
455                "teams",
456            )
457            .await
458            .unwrap();
459
460        assert_eq!(conn.nodes.len(), 2);
461        assert_eq!(conn.nodes[0]["id"], "team-1");
462        assert!(!conn.page_info.has_next_page);
463        assert_eq!(conn.page_info.end_cursor, Some("cursor-abc".to_string()));
464    }
465
466    #[tokio::test]
467    async fn execute_sends_authorization_header() {
468        let server = MockServer::start().await;
469        Mock::given(method("POST"))
470            .and(header("Authorization", "my-secret-token"))
471            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
472                "data": {"viewer": {"id": "1"}}
473            })))
474            .mount(&server)
475            .await;
476
477        let client = Client::from_token("my-secret-token")
478            .unwrap()
479            .with_base_url(server.uri());
480
481        let result: serde_json::Value = client
482            .execute("query { viewer { id } }", serde_json::json!({}), "viewer")
483            .await
484            .unwrap();
485
486        assert_eq!(result["id"], "1");
487    }
488}