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}
14
15/// Raw GraphQL response shape.
16#[derive(serde::Deserialize)]
17struct GraphQLResponse {
18    data: Option<serde_json::Value>,
19    errors: Option<Vec<GraphQLError>>,
20}
21
22impl Client {
23    /// Create a client with an explicit API token.
24    pub fn from_token(token: impl Into<String>) -> Result<Self, LinearError> {
25        let token = token.into();
26        if token.is_empty() {
27            return Err(LinearError::AuthConfig("Token cannot be empty".to_string()));
28        }
29        Ok(Self {
30            http: reqwest::Client::new(),
31            token,
32        })
33    }
34
35    /// Create a client from the `LINEAR_API_TOKEN` environment variable.
36    pub fn from_env() -> Result<Self, LinearError> {
37        Self::from_token(auth::token_from_env()?)
38    }
39
40    /// Create a client from the `~/.linear_api_token` file.
41    pub fn from_file() -> Result<Self, LinearError> {
42        Self::from_token(auth::token_from_file()?)
43    }
44
45    /// Create a client by auto-detecting the token (env -> file).
46    pub fn auto() -> Result<Self, LinearError> {
47        Self::from_token(auth::auto_token()?)
48    }
49
50    /// Execute a GraphQL query and extract a single object from the response.
51    pub async fn execute<T: DeserializeOwned>(
52        &self,
53        query: &str,
54        variables: serde_json::Value,
55        data_path: &str,
56    ) -> Result<T, LinearError> {
57        let body = serde_json::json!({
58            "query": query,
59            "variables": variables,
60        });
61
62        let response = self
63            .http
64            .post(LINEAR_API_URL)
65            .header("Authorization", &self.token)
66            .header("Content-Type", "application/json")
67            .json(&body)
68            .send()
69            .await?;
70
71        let status = response.status();
72        if status == 401 || status == 403 {
73            let text = response.text().await.unwrap_or_default();
74            if status == 401 {
75                return Err(LinearError::Authentication(text));
76            }
77            return Err(LinearError::Forbidden(text));
78        }
79        if status == 429 {
80            let retry_after = response
81                .headers()
82                .get("retry-after")
83                .and_then(|v| v.to_str().ok())
84                .and_then(|v| v.parse::<f64>().ok());
85            let text = response.text().await.unwrap_or_default();
86            return Err(LinearError::RateLimited {
87                retry_after,
88                message: text,
89            });
90        }
91
92        let gql_response: GraphQLResponse = response.json().await?;
93
94        // Check for GraphQL-level errors.
95        if let Some(errors) = gql_response.errors {
96            if !errors.is_empty() {
97                // Check for specific error types.
98                let first_msg = errors[0].message.to_lowercase();
99                if first_msg.contains("authentication") || first_msg.contains("unauthorized") {
100                    return Err(LinearError::Authentication(errors[0].message.clone()));
101                }
102                return Err(LinearError::GraphQL(errors));
103            }
104        }
105
106        let data = gql_response
107            .data
108            .ok_or_else(|| LinearError::MissingData("No data in response".to_string()))?;
109
110        let value = data
111            .get(data_path)
112            .ok_or_else(|| {
113                LinearError::MissingData(format!("No '{}' in response data", data_path))
114            })?
115            .clone();
116
117        serde_json::from_value(value).map_err(|e| {
118            LinearError::MissingData(format!("Failed to deserialize '{}': {}", data_path, e))
119        })
120    }
121
122    /// Execute a GraphQL query and extract a Connection from the response.
123    pub async fn execute_connection<T: DeserializeOwned + Default>(
124        &self,
125        query: &str,
126        variables: serde_json::Value,
127        data_path: &str,
128    ) -> Result<Connection<T>, LinearError> {
129        self.execute::<Connection<T>>(query, variables, data_path)
130            .await
131    }
132}