github_rust/github/
client.rs

1use crate::{config::*, error::*};
2use reqwest::{Client, header::HeaderMap};
3use secrecy::{ExposeSecret, SecretString};
4use std::env;
5
6/// Low-level GitHub API client with connection pooling.
7///
8/// Handles HTTP requests, authentication, and rate limiting.
9/// For most use cases, prefer using [`GitHubService`](crate::GitHubService) instead.
10#[derive(Clone)]
11pub struct GitHubClient {
12    client: Client,
13    /// Token stored securely - automatically zeroized on drop
14    token: Option<SecretString>,
15}
16
17impl GitHubClient {
18    /// Creates a new GitHub API client with connection pooling and optional token authentication.
19    ///
20    /// Automatically detects `GITHUB_TOKEN` from environment variables.
21    /// The token is stored securely using [`SecretString`] and is automatically
22    /// zeroized when the client is dropped.
23    #[must_use = "Creating a client without using it is wasteful"]
24    pub fn new() -> Result<Self> {
25        let token: Option<SecretString> = env::var("GITHUB_TOKEN").ok().map(SecretString::from);
26        let mut headers = HeaderMap::new();
27
28        headers.insert(
29            "User-Agent",
30            USER_AGENT
31                .parse()
32                .map_err(|_| GitHubError::ConfigError("Invalid User-Agent header".to_string()))?,
33        );
34        headers.insert(
35            "Accept",
36            "application/vnd.github+json"
37                .parse()
38                .map_err(|_| GitHubError::ConfigError("Invalid Accept header".to_string()))?,
39        );
40        headers.insert(
41            "X-GitHub-Api-Version",
42            "2022-11-28"
43                .parse()
44                .map_err(|_| GitHubError::ConfigError("Invalid API version header".to_string()))?,
45        );
46
47        if let Some(ref token) = token {
48            let auth_value = format!("Bearer {}", token.expose_secret());
49            headers.insert(
50                "Authorization",
51                auth_value.parse().map_err(|_| {
52                    GitHubError::ConfigError("Invalid Authorization header".to_string())
53                })?,
54            );
55        }
56
57        let client = Client::builder()
58            .timeout(DEFAULT_TIMEOUT)
59            .default_headers(headers)
60            .build()
61            .map_err(|e| GitHubError::NetworkError(e.to_string()))?;
62
63        Ok(Self { client, token })
64    }
65
66    #[must_use]
67    pub fn has_token(&self) -> bool {
68        self.token.is_some()
69    }
70
71    #[must_use]
72    pub fn client(&self) -> &Client {
73        &self.client
74    }
75
76    pub async fn check_rate_limit(&self) -> Result<RateLimit> {
77        let response = self
78            .client
79            .get(format!("{}/rate_limit", GITHUB_API_URL))
80            .send()
81            .await?;
82
83        if response.status() == 403 {
84            // For rate_limit endpoint, 403 should always be actual rate limiting
85            // But let's be defensive and check the response content
86            let error_text = response.text().await.unwrap_or_default();
87            let error_lower = error_text.to_lowercase();
88
89            if error_lower.contains("rate limit") || error_lower.is_empty() {
90                // Empty response or explicit rate limit message
91                return Err(GitHubError::RateLimitError(
92                    "API rate limit exceeded".to_string(),
93                ));
94            } else if error_lower.contains("repository access blocked")
95                || error_lower.contains("access blocked")
96            {
97                return Err(GitHubError::AccessBlockedError(
98                    "Rate limit check blocked".to_string(),
99                ));
100            } else {
101                return Err(GitHubError::AuthenticationError(format!(
102                    "Access denied for rate limit check: {}",
103                    error_text
104                )));
105            }
106        }
107
108        let rate_limit_response: serde_json::Value = response.json().await?;
109        let rate = &rate_limit_response["rate"];
110
111        Ok(RateLimit {
112            limit: rate["limit"].as_u64().unwrap_or(0),
113            remaining: rate["remaining"].as_u64().unwrap_or(0),
114            reset: rate["reset"].as_u64().unwrap_or(0),
115        })
116    }
117}
118
119/// GitHub API rate limit information.
120///
121/// Provides information about API usage limits and reset times.
122/// Authenticated requests have a limit of 5000/hour, unauthenticated 60/hour.
123#[derive(Debug, Clone)]
124pub struct RateLimit {
125    /// Maximum number of requests allowed per hour
126    pub limit: u64,
127    /// Number of requests remaining in the current window
128    pub remaining: u64,
129    /// Unix timestamp when the rate limit resets
130    pub reset: u64,
131}
132
133impl RateLimit {
134    /// Returns the datetime when the rate limit resets.
135    #[must_use]
136    pub fn reset_datetime(&self) -> chrono::DateTime<chrono::Utc> {
137        chrono::DateTime::from_timestamp(self.reset as i64, 0).unwrap_or_else(chrono::Utc::now)
138    }
139
140    /// Returns the duration until the rate limit resets.
141    #[must_use]
142    pub fn time_until_reset(&self) -> std::time::Duration {
143        let now = chrono::Utc::now().timestamp() as u64;
144        if self.reset > now {
145            std::time::Duration::from_secs(self.reset - now)
146        } else {
147            std::time::Duration::ZERO
148        }
149    }
150
151    /// Returns true if the rate limit has been exceeded (no requests remaining).
152    #[must_use]
153    pub fn is_exceeded(&self) -> bool {
154        self.remaining == 0
155    }
156
157    /// Returns the number of requests used in the current window.
158    #[must_use]
159    pub fn used(&self) -> u64 {
160        self.limit.saturating_sub(self.remaining)
161    }
162}