Skip to main content

gor/
client.rs

1//! HTTP client for the GitHub REST API.
2//!
3//! Provides a [`Client`] that handles authentication, base URL resolution,
4//! and common request patterns. Tokens are resolved from the OS keyring
5//! automatically.
6
7use crate::error::GorError;
8use crate::host::Host;
9use crate::keyring_store;
10
11/// An HTTP client for making authenticated requests to the GitHub API.
12///
13/// Wraps a [`reqwest::blocking::Client`] and automatically attaches
14/// the appropriate `Authorization` header and `Accept` header.
15pub struct Client {
16    /// The underlying reqwest HTTP client.
17    http: reqwest::blocking::Client,
18    /// The GitHub host this client is configured for.
19    host: Host,
20    /// The OAuth token, if available.
21    token: Option<String>,
22}
23
24impl Client {
25    /// Create a new `Client` for the given host.
26    ///
27    /// Attempts to load a stored token from the OS keyring. If no token
28    /// is found, the client is created in an unauthenticated state.
29    ///
30    /// # Examples
31    ///
32    /// ```no_run
33    /// use gor::client::Client;
34    ///
35    /// let client = Client::new("github.com").unwrap();
36    /// ```
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the reqwest client cannot be created.
41    pub fn new(hostname: &str) -> Result<Self, GorError> {
42        let host = Host::new(hostname);
43        let token = keyring_store::get_token(hostname).unwrap_or(None);
44        let http = reqwest::blocking::Client::builder()
45            .user_agent(concat!("gor/", env!("CARGO_PKG_VERSION")))
46            .build()
47            .map_err(GorError::Http)?;
48        Ok(Self { http, host, token })
49    }
50
51    /// Create a new `Client` with an explicit token (bypasses keyring).
52    ///
53    /// # Examples
54    ///
55    /// ```no_run
56    /// use gor::client::Client;
57    ///
58    /// let client = Client::with_token("github.com", "gho_abc123").unwrap();
59    /// ```
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the reqwest client cannot be created.
64    pub fn with_token(hostname: &str, token: &str) -> Result<Self, GorError> {
65        let host = Host::new(hostname);
66        let http = reqwest::blocking::Client::builder()
67            .user_agent(concat!("gor/", env!("CARGO_PKG_VERSION")))
68            .build()
69            .map_err(GorError::Http)?;
70        Ok(Self {
71            http,
72            host,
73            token: Some(token.to_string()),
74        })
75    }
76
77    /// Returns the host this client is configured for.
78    #[must_use]
79    pub const fn host(&self) -> &Host {
80        &self.host
81    }
82
83    /// Returns `true` if the client has an auth token.
84    #[must_use]
85    pub const fn is_authenticated(&self) -> bool {
86        self.token.is_some()
87    }
88
89    /// Returns the auth token, if available.
90    #[must_use]
91    pub fn token(&self) -> Option<&str> {
92        self.token.as_deref()
93    }
94
95    /// Make a `GET` request to the given API path.
96    ///
97    /// The path should start with `/`, e.g. `/user`.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the HTTP request fails.
102    pub fn get(&self, path: &str) -> Result<reqwest::blocking::Response, GorError> {
103        let url = self.host.api_url(path);
104        let mut req = self
105            .http
106            .get(&url)
107            .header("Accept", "application/vnd.github+json");
108        if let Some(token) = &self.token {
109            req = req.header("Authorization", format!("Bearer {token}"));
110        }
111        tracing::debug!("GET {url}");
112        req.send().map_err(GorError::Http)
113    }
114
115    /// Make a `GET` request to an absolute URL (not API-path-based).
116    ///
117    /// Used for downloading release assets from the GitHub CDN.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the HTTP request fails.
122    pub fn get_absolute(&self, url: &str) -> Result<reqwest::blocking::Response, GorError> {
123        let mut req = self
124            .http
125            .get(url)
126            .header("Accept", "application/octet-stream");
127        if let Some(token) = &self.token {
128            req = req.header("Authorization", format!("Bearer {token}"));
129        }
130        tracing::debug!("GET {url}");
131        req.send().map_err(GorError::Http)
132    }
133
134    /// Make a request with an arbitrary HTTP method, headers, and optional body.
135    ///
136    /// The path should start with `/`, e.g. `/repos/owner/repo`.
137    /// `headers` is a slice of "Key: Value" strings.
138    /// `body` is an optional raw byte vector for the request body.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if the HTTP request fails.
143    pub fn request(
144        &self,
145        method: &str,
146        path: &str,
147        headers: &[String],
148        body: Option<Vec<u8>>,
149    ) -> Result<reqwest::blocking::Response, GorError> {
150        let url = self.host.api_url(path);
151        let mut req = self
152            .http
153            .request(method.parse().unwrap_or(reqwest::Method::GET), &url)
154            .header("Accept", "application/vnd.github+json");
155
156        if let Some(token) = &self.token {
157            req = req.header("Authorization", format!("Bearer {token}"));
158        }
159
160        for header in headers {
161            if let Some((key, value)) = header.split_once(':') {
162                req = req.header(key.trim(), value.trim());
163            }
164        }
165
166        if let Some(body_bytes) = body {
167            req = req.body(body_bytes);
168        }
169
170        tracing::debug!("{} {url}", method.to_uppercase());
171        req.send().map_err(GorError::Http)
172    }
173
174    /// Make a `POST` request to the given API path with a JSON body.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the HTTP request fails.
179    pub fn post<T: serde::Serialize>(
180        &self,
181        path: &str,
182        body: &T,
183    ) -> Result<reqwest::blocking::Response, GorError> {
184        let url = self.host.api_url(path);
185        let mut req = self
186            .http
187            .post(&url)
188            .header("Accept", "application/vnd.github+json")
189            .json(body);
190        if let Some(token) = &self.token {
191            req = req.header("Authorization", format!("Bearer {token}"));
192        }
193        tracing::debug!("POST {url}");
194        req.send().map_err(GorError::Http)
195    }
196
197    /// Make a `POST` request to an absolute URL (not API-path-based) with form data.
198    ///
199    /// Used for OAuth device flow endpoints which live on the main host,
200    /// not the API subdomain.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if the HTTP request fails.
205    pub fn post_form_url(
206        &self,
207        url: &str,
208        form: &std::collections::HashMap<&str, &str>,
209    ) -> Result<reqwest::blocking::Response, GorError> {
210        tracing::debug!("POST {url}");
211        self.http
212            .post(url)
213            .header("Accept", "application/json")
214            .form(form)
215            .send()
216            .map_err(GorError::Http)
217    }
218
219    /// Make a GraphQL API request.
220    ///
221    /// Sends a POST request to the GraphQL endpoint with the given query
222    /// and optional variables.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if the HTTP request fails.
227    pub fn graphql(
228        &self,
229        query: &str,
230        variables: Option<serde_json::Value>,
231    ) -> Result<serde_json::Value, GorError> {
232        let url = self.host.api_url("/graphql");
233        let mut body = serde_json::json!({"query": query});
234        if let Some(vars) = variables {
235            body["variables"] = vars;
236        }
237        let mut req = self
238            .http
239            .post(&url)
240            .header("Accept", "application/vnd.github+json")
241            .json(&body);
242        if let Some(token) = &self.token {
243            req = req.header("Authorization", format!("Bearer {token}"));
244        }
245        tracing::debug!("POST {url} (GraphQL)");
246        let response = req.send().map_err(GorError::Http)?;
247        let result: serde_json::Value = response.json().map_err(GorError::Http)?;
248        if let Some(errors) = result.get("errors") {
249            let msg = errors[0]["message"].as_str().unwrap_or("GraphQL error");
250            return Err(GorError::Auth(msg.to_string()));
251        }
252        Ok(result)
253    }
254
255    /// Upload a release asset to the given upload URL.
256    ///
257    /// GitHub release asset uploads use a separate domain (uploads.github.com)
258    /// and require the content type to be set explicitly.
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if the HTTP request fails.
263    pub fn upload_asset(
264        &self,
265        upload_url: &str,
266        data: &[u8],
267        content_type: &str,
268    ) -> Result<reqwest::blocking::Response, GorError> {
269        let mut req = self
270            .http
271            .post(upload_url)
272            .header("Accept", "application/vnd.github+json")
273            .header("Content-Type", content_type)
274            .body(data.to_vec());
275        if let Some(token) = &self.token {
276            req = req.header("Authorization", format!("Bearer {token}"));
277        }
278        tracing::debug!("POST {upload_url}");
279        req.send().map_err(GorError::Http)
280    }
281
282    /// Store the current token in the OS keyring.
283    ///
284    /// # Errors
285    ///
286    /// Returns an error if the keyring operation fails.
287    pub fn save_token(&self) -> Result<(), GorError> {
288        self.token.as_ref().map_or_else(
289            || Ok(()),
290            |token| keyring_store::set_token(self.host.hostname(), token),
291        )
292    }
293
294    /// Update the client's token and persist it.
295    ///
296    /// # Errors
297    ///
298    /// Returns an error if the storage operation fails.
299    pub fn set_token(&mut self, token: String) -> Result<(), GorError> {
300        keyring_store::set_token(self.host.hostname(), &token)?;
301        self.token = Some(token);
302        Ok(())
303    }
304
305    /// Store the authenticated user's login name.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the storage operation fails.
310    pub fn save_user(&self, user: &str) -> Result<(), GorError> {
311        keyring_store::set_user(self.host.hostname(), user)
312    }
313
314    /// Retrieve the stored user login, if available.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the storage read fails.
319    pub fn stored_user(&self) -> Result<Option<String>, GorError> {
320        keyring_store::get_user(self.host.hostname())
321    }
322}