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://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}