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#[derive(Debug, Clone)]
10pub struct Client {
11 http: reqwest::Client,
12 token: String,
13 base_url: String,
14}
15
16#[derive(serde::Deserialize)]
18struct GraphQLResponse {
19 data: Option<serde_json::Value>,
20 errors: Option<Vec<GraphQLError>>,
21}
22
23impl Client {
24 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 pub fn from_env() -> Result<Self, LinearError> {
39 Self::from_token(auth::token_from_env()?)
40 }
41
42 pub fn from_file() -> Result<Self, LinearError> {
44 Self::from_token(auth::token_from_file()?)
45 }
46
47 pub fn auto() -> Result<Self, LinearError> {
49 Self::from_token(auth::auto_token()?)
50 }
51
52 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 if let Some(errors) = gql_response.errors {
109 if !errors.is_empty() {
110 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 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 #[cfg(test)]
148 pub(crate) fn with_base_url(mut self, url: String) -> Self {
149 self.base_url = url;
150 self
151 }
152}
153
154impl 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}