use std::time::Duration;
use reqwest::{StatusCode, Url};
use serde::de::DeserializeOwned;
use tracing::{debug, warn};
use crate::error::ApiError;
use crate::params::{StatsRange, SummaryParams};
use crate::types::{
GoalsResponse, LeaderboardResponse, ProjectsResponse, StatsResponse, SummaryResponse,
UserResponse,
};
const DEFAULT_BASE_URL: &str = "https://api.wakatime.com/api/v1/";
const REQUEST_TIMEOUT_SECS: u64 = 10;
const MAX_ATTEMPTS: u32 = 3;
#[derive(Debug, Clone)]
pub struct WakaClient {
api_key: String,
base_url: Url,
http: reqwest::Client,
}
impl WakaClient {
#[must_use]
pub fn new(api_key: &str) -> Self {
let base_url =
Url::parse(DEFAULT_BASE_URL).expect("DEFAULT_BASE_URL is a valid URL; unreachable");
Self {
api_key: api_key.to_owned(),
base_url,
http: build_http_client(),
}
}
pub fn with_base_url(api_key: &str, base_url: &str) -> Result<Self, ApiError> {
let base_url = Url::parse(base_url).map_err(|e| ApiError::ParseError(e.to_string()))?;
Ok(Self {
api_key: api_key.to_owned(),
base_url,
http: build_http_client(),
})
}
pub(crate) async fn get<T: DeserializeOwned>(
&self,
path: &str,
query: &[(&str, &str)],
) -> Result<T, ApiError> {
let url = self
.base_url
.join(path)
.map_err(|e| ApiError::ParseError(format!("invalid path '{path}': {e}")))?;
let mut last_err: Option<ApiError> = None;
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
let delay = Duration::from_millis(500 * u64::from(attempt));
debug!(
"retrying request to {url} after {}ms (attempt {attempt})",
delay.as_millis()
);
tokio::time::sleep(delay).await;
}
debug!("GET {url} (attempt {})", attempt + 1);
let result: Result<reqwest::Response, reqwest::Error> = self
.http
.get(url.clone())
.basic_auth(&self.api_key, Option::<&str>::None)
.query(query)
.send()
.await;
let response: reqwest::Response = match result {
Ok(r) => r,
Err(e) if e.is_timeout() || e.is_connect() => {
warn!("network error on attempt {}: {e}", attempt + 1);
last_err = Some(ApiError::NetworkError(e));
continue; }
Err(e) => return Err(ApiError::NetworkError(e)),
};
let status = response.status();
match status {
StatusCode::OK => {
let text: String = response.text().await.map_err(ApiError::NetworkError)?;
return serde_json::from_str::<T>(&text)
.map_err(|e| ApiError::ParseError(e.to_string()));
}
StatusCode::UNAUTHORIZED => {
return Err(ApiError::Unauthorized);
}
StatusCode::NOT_FOUND => {
return Err(ApiError::NotFound);
}
StatusCode::TOO_MANY_REQUESTS => {
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v: &reqwest::header::HeaderValue| v.to_str().ok())
.and_then(|s: &str| s.parse::<u64>().ok());
return Err(ApiError::RateLimit { retry_after });
}
s if s.is_server_error() => {
warn!("server error {s} on attempt {}", attempt + 1);
last_err = Some(ApiError::ServerError { status: s.as_u16() });
}
s => {
return Err(ApiError::ServerError { status: s.as_u16() });
}
}
}
Err(last_err.unwrap_or_else(|| ApiError::ServerError { status: 500 }))
}
pub async fn me(&self) -> Result<crate::types::User, ApiError> {
let resp: UserResponse = self.get("users/current", &[]).await?;
Ok(resp.data)
}
pub async fn summaries(&self, params: SummaryParams) -> Result<SummaryResponse, ApiError> {
let owned = params.to_query_pairs();
let borrowed: Vec<(&str, &str)> = owned
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
self.get("users/current/summaries", &borrowed).await
}
pub async fn projects(&self) -> Result<ProjectsResponse, ApiError> {
self.get("users/current/projects", &[]).await
}
pub async fn stats(&self, range: StatsRange) -> Result<StatsResponse, ApiError> {
let path = format!("users/current/stats/{}", range.as_str());
self.get(&path, &[]).await
}
pub async fn goals(&self) -> Result<GoalsResponse, ApiError> {
self.get("users/current/goals", &[]).await
}
pub async fn leaderboard(&self, page: u32) -> Result<LeaderboardResponse, ApiError> {
let page_str = page.to_string();
self.get("users/current/leaderboards", &[("page", &page_str)])
.await
}
}
fn build_http_client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build()
.expect("failed to build reqwest::Client; TLS backend unavailable")
}