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            .header(
68                "User-Agent",
69                format!("lineark-sdk/{}", env!("CARGO_PKG_VERSION")),
70            )
71            .json(&body)
72            .send()
73            .await?;
74
75        let status = response.status();
76        if status == 401 || status == 403 {
77            let text = response.text().await.unwrap_or_default();
78            if status == 401 {
79                return Err(LinearError::Authentication(text));
80            }
81            return Err(LinearError::Forbidden(text));
82        }
83        if status == 429 {
84            let retry_after = response
85                .headers()
86                .get("retry-after")
87                .and_then(|v| v.to_str().ok())
88                .and_then(|v| v.parse::<f64>().ok());
89            let text = response.text().await.unwrap_or_default();
90            return Err(LinearError::RateLimited {
91                retry_after,
92                message: text,
93            });
94        }
95        if !status.is_success() {
96            let body = response.text().await.unwrap_or_default();
97            return Err(LinearError::HttpError {
98                status: status.as_u16(),
99                body,
100            });
101        }
102
103        let gql_response: GraphQLResponse = response.json().await?;
104
105        // Check for GraphQL-level errors.
106        if let Some(errors) = gql_response.errors {
107            if !errors.is_empty() {
108                // Check for specific error types.
109                let first_msg = errors[0].message.to_lowercase();
110                if first_msg.contains("authentication") || first_msg.contains("unauthorized") {
111                    return Err(LinearError::Authentication(errors[0].message.clone()));
112                }
113                return Err(LinearError::GraphQL(errors));
114            }
115        }
116
117        let data = gql_response
118            .data
119            .ok_or_else(|| LinearError::MissingData("No data in response".to_string()))?;
120
121        let value = data
122            .get(data_path)
123            .ok_or_else(|| {
124                LinearError::MissingData(format!("No '{}' in response data", data_path))
125            })?
126            .clone();
127
128        serde_json::from_value(value).map_err(|e| {
129            LinearError::MissingData(format!("Failed to deserialize '{}': {}", data_path, e))
130        })
131    }
132
133    /// Execute a GraphQL query and extract a Connection from the response.
134    pub async fn execute_connection<T: DeserializeOwned>(
135        &self,
136        query: &str,
137        variables: serde_json::Value,
138        data_path: &str,
139    ) -> Result<Connection<T>, LinearError> {
140        self.execute::<Connection<T>>(query, variables, data_path)
141            .await
142    }
143}