Skip to main content

waka_api/
client.rs

1//! `WakaTime` HTTP client implementation.
2
3use std::time::Duration;
4
5use reqwest::{StatusCode, Url};
6use serde::de::DeserializeOwned;
7use tracing::{debug, warn};
8
9use crate::error::ApiError;
10use crate::params::{StatsRange, SummaryParams};
11use crate::types::{
12    GoalsResponse, LeaderboardResponse, ProjectsResponse, StatsResponse, SummaryResponse,
13    UserResponse,
14};
15
16/// Base URL for the production `WakaTime` API.
17const DEFAULT_BASE_URL: &str = "https://api.wakatime.com/api/v1/";
18
19/// Per-request timeout in seconds.
20const REQUEST_TIMEOUT_SECS: u64 = 10;
21
22/// Maximum number of attempts before giving up (1 initial + 2 retries).
23const MAX_ATTEMPTS: u32 = 3;
24
25// ─────────────────────────────────────────────────────────────────────────────
26
27/// Async HTTP client for the `WakaTime` API v1.
28///
29/// All requests are authenticated via HTTP Basic auth using the supplied API
30/// key. The client performs automatic retry with exponential back-off on
31/// transient errors (network failures and HTTP 5xx responses).
32///
33/// # Example
34///
35/// ```rust,no_run
36/// use waka_api::WakaClient;
37///
38/// # async fn example() -> Result<(), waka_api::ApiError> {
39/// let client = WakaClient::new("waka_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
40/// let me = client.me().await?;
41/// println!("Logged in as {}", me.username);
42/// # Ok(())
43/// # }
44/// ```
45#[derive(Debug, Clone)]
46pub struct WakaClient {
47    /// API key used to authenticate all requests.
48    ///
49    /// Stored as a plain `String` internally; never printed via `Debug`
50    /// (the default derive is acceptable here because the struct is not
51    /// publicly printable in user-visible error paths — the credential store
52    /// wrapper in `waka-config` uses a `Sensitive` newtype).
53    // TODO(spec): consider moving to a Sensitive(String) newtype once
54    // waka-config's CredentialStore wraps requests at the call site.
55    api_key: String,
56    /// Base URL of the API (overridable for testing).
57    base_url: Url,
58    /// Underlying HTTP client (shared connection pool).
59    http: reqwest::Client,
60}
61
62impl WakaClient {
63    /// Creates a new client pointing at the production `WakaTime` API.
64    ///
65    /// # Panics
66    ///
67    /// Panics if the default base URL cannot be parsed (compile-time constant —
68    /// this is unreachable in practice).
69    #[must_use]
70    pub fn new(api_key: &str) -> Self {
71        // SAFETY: DEFAULT_BASE_URL is a compile-time constant that is valid.
72        let base_url =
73            Url::parse(DEFAULT_BASE_URL).expect("DEFAULT_BASE_URL is a valid URL; unreachable");
74        Self {
75            api_key: api_key.to_owned(),
76            base_url,
77            http: build_http_client(),
78        }
79    }
80
81    /// Creates a new client pointing at a custom base URL.
82    ///
83    /// Primarily used in tests to target a `wiremock` mock server.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if `base_url` is not a valid URL.
88    pub fn with_base_url(api_key: &str, base_url: &str) -> Result<Self, ApiError> {
89        let base_url = Url::parse(base_url).map_err(|e| ApiError::ParseError(e.to_string()))?;
90        Ok(Self {
91            api_key: api_key.to_owned(),
92            base_url,
93            http: build_http_client(),
94        })
95    }
96
97    // ── Private helpers ───────────────────────────────────────────────────────
98
99    /// Sends an authenticated GET request to `path` with the given query
100    /// parameters, deserializes the JSON response into `T`, and returns it.
101    ///
102    /// Implements:
103    /// - HTTP Basic auth (`Authorization: Basic <base64(api_key:)>`)
104    /// - 401 → [`ApiError::Unauthorized`]
105    /// - 429 → [`ApiError::RateLimit`] (parses `Retry-After` header)
106    /// - 404 → [`ApiError::NotFound`]
107    /// - 5xx → [`ApiError::ServerError`]
108    /// - Exponential back-off retry (max 3 total attempts)
109    pub(crate) async fn get<T: DeserializeOwned>(
110        &self,
111        path: &str,
112        query: &[(&str, &str)],
113    ) -> Result<T, ApiError> {
114        let url = self
115            .base_url
116            .join(path)
117            .map_err(|e| ApiError::ParseError(format!("invalid path '{path}': {e}")))?;
118
119        let mut last_err: Option<ApiError> = None;
120
121        for attempt in 0..MAX_ATTEMPTS {
122            if attempt > 0 {
123                // Exponential back-off: 500ms, 1000ms
124                let delay = Duration::from_millis(500 * u64::from(attempt));
125                debug!(
126                    "retrying request to {url} after {}ms (attempt {attempt})",
127                    delay.as_millis()
128                );
129                tokio::time::sleep(delay).await;
130            }
131
132            debug!("GET {url} (attempt {})", attempt + 1);
133
134            let result: Result<reqwest::Response, reqwest::Error> = self
135                .http
136                .get(url.clone())
137                // WakaTime uses Basic auth: base64(api_key + ":")
138                // reqwest's basic_auth encodes "user:password" — pass empty password.
139                .basic_auth(&self.api_key, Option::<&str>::None)
140                .query(query)
141                .send()
142                .await;
143
144            let response: reqwest::Response = match result {
145                Ok(r) => r,
146                Err(e) if e.is_timeout() || e.is_connect() => {
147                    warn!("network error on attempt {}: {e}", attempt + 1);
148                    last_err = Some(ApiError::NetworkError(e));
149                    continue; // retry on transient network errors
150                }
151                Err(e) => return Err(ApiError::NetworkError(e)),
152            };
153
154            let status = response.status();
155
156            match status {
157                StatusCode::OK => {
158                    let text: String = response.text().await.map_err(ApiError::NetworkError)?;
159                    return serde_json::from_str::<T>(&text)
160                        .map_err(|e| ApiError::ParseError(e.to_string()));
161                }
162                StatusCode::UNAUTHORIZED => {
163                    return Err(ApiError::Unauthorized);
164                }
165                StatusCode::NOT_FOUND => {
166                    return Err(ApiError::NotFound);
167                }
168                StatusCode::TOO_MANY_REQUESTS => {
169                    let retry_after = response
170                        .headers()
171                        .get("Retry-After")
172                        .and_then(|v: &reqwest::header::HeaderValue| v.to_str().ok())
173                        .and_then(|s: &str| s.parse::<u64>().ok());
174                    return Err(ApiError::RateLimit { retry_after });
175                }
176                s if s.is_server_error() => {
177                    warn!("server error {s} on attempt {}", attempt + 1);
178                    last_err = Some(ApiError::ServerError { status: s.as_u16() });
179                    // fall through to next loop iteration (retry on 5xx)
180                }
181                s => {
182                    return Err(ApiError::ServerError { status: s.as_u16() });
183                }
184            }
185        }
186
187        // All attempts exhausted — return the last recorded error.
188        Err(last_err.unwrap_or_else(|| ApiError::ServerError { status: 500 }))
189    }
190
191    // ── Endpoints ─────────────────────────────────────────────────────────────
192
193    /// Returns the profile of the currently authenticated user.
194    ///
195    /// Calls `GET /users/current`.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`ApiError::Unauthorized`] if the API key is invalid.
200    /// Returns [`ApiError::NetworkError`] on connection or timeout failures.
201    pub async fn me(&self) -> Result<crate::types::User, ApiError> {
202        let resp: UserResponse = self.get("users/current", &[]).await?;
203        Ok(resp.data)
204    }
205
206    /// Returns coding summaries for the date range described by `params`.
207    ///
208    /// Calls `GET /users/current/summaries`.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`ApiError::Unauthorized`] if the API key is invalid.
213    /// Returns [`ApiError::NetworkError`] on connection or timeout failures.
214    /// Returns [`ApiError::ParseError`] if the response cannot be deserialized.
215    pub async fn summaries(&self, params: SummaryParams) -> Result<SummaryResponse, ApiError> {
216        // Convert owned pairs to borrowed slices for the generic get() call.
217        let owned = params.to_query_pairs();
218        let borrowed: Vec<(&str, &str)> = owned
219            .iter()
220            .map(|(k, v)| (k.as_str(), v.as_str()))
221            .collect();
222        self.get("users/current/summaries", &borrowed).await
223    }
224
225    /// Returns all projects the authenticated user has logged time against.
226    ///
227    /// Calls `GET /users/current/projects`.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`ApiError::Unauthorized`] if the API key is invalid.
232    /// Returns [`ApiError::NetworkError`] on connection or timeout failures.
233    /// Returns [`ApiError::ParseError`] if the response cannot be deserialized.
234    pub async fn projects(&self) -> Result<ProjectsResponse, ApiError> {
235        self.get("users/current/projects", &[]).await
236    }
237
238    /// Returns aggregated coding stats for the given predefined range.
239    ///
240    /// Calls `GET /users/current/stats/{range}`.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`ApiError::Unauthorized`] if the API key is invalid.
245    /// Returns [`ApiError::NetworkError`] on connection or timeout failures.
246    /// Returns [`ApiError::ParseError`] if the response cannot be deserialized.
247    pub async fn stats(&self, range: StatsRange) -> Result<StatsResponse, ApiError> {
248        let path = format!("users/current/stats/{}", range.as_str());
249        self.get(&path, &[]).await
250    }
251
252    /// Returns all coding goals for the authenticated user.
253    ///
254    /// Calls `GET /users/current/goals`.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`ApiError::Unauthorized`] if the API key is invalid.
259    /// Returns [`ApiError::NetworkError`] on connection or timeout failures.
260    /// Returns [`ApiError::ParseError`] if the response cannot be deserialized.
261    pub async fn goals(&self) -> Result<GoalsResponse, ApiError> {
262        self.get("users/current/goals", &[]).await
263    }
264
265    /// Returns the public `WakaTime` leaderboard for the given page.
266    ///
267    /// Page numbers are 1-based. Calls `GET /users/current/leaderboards`.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`ApiError::Unauthorized`] if the API key is invalid.
272    /// Returns [`ApiError::NetworkError`] on connection or timeout failures.
273    /// Returns [`ApiError::ParseError`] if the response cannot be deserialized.
274    pub async fn leaderboard(&self, page: u32) -> Result<LeaderboardResponse, ApiError> {
275        let page_str = page.to_string();
276        self.get("users/current/leaderboards", &[("page", &page_str)])
277            .await
278    }
279}
280
281/// Builds the shared `reqwest::Client` with a per-request timeout.
282fn build_http_client() -> reqwest::Client {
283    reqwest::Client::builder()
284        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
285        .build()
286        // reqwest::ClientBuilder::build() only fails if the TLS backend cannot
287        // be initialised — with rustls (the default in reqwest 0.13) this is
288        // unreachable.
289        .expect("failed to build reqwest::Client; TLS backend unavailable")
290}