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 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 #[cfg(test)]
160 pub(crate) fn with_base_url(mut self, url: String) -> Self {
161 self.base_url = url;
162 self
163 }
164}
165
166impl 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}