use base64::engine::general_purpose::STANDARD; use base64::Engine; use chrono::{DateTime, SecondsFormat, Utc}; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::Response; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_retry::policies::ExponentialBackoff; use reqwest_retry::{Jitter, RetryTransientMiddleware}; use serde::de::DeserializeOwned; use std::time::Duration; use support::ReqwestRateLimiter; mod support; pub mod types; #[derive(Debug, Clone)] pub struct TogglApi { client: ClientWithMiddleware, pub workspace_id: u64, headers: HeaderMap, } const BASE_URL: &str = "https://api.track.toggl.com/api/v9"; const REPORTS_BASE_URL: &str = "https://api.track.toggl.com/reports/api/v3"; impl TogglApi { pub fn new(api_key: &str, workspace_id: u64) -> Self { let rate_limiter = ReqwestRateLimiter::new(); let backoff = ExponentialBackoff::builder() .retry_bounds(Duration::from_secs(1), Duration::from_secs(60)) .jitter(Jitter::Bounded) .base(2) .build_with_total_retry_duration(Duration::from_secs(24 * 60 * 60)); let client = ClientBuilder::new(reqwest::Client::new()) .with(reqwest_ratelimit::all(rate_limiter)) .with(RetryTransientMiddleware::new_with_policy(backoff)) .build(); let toggl_auth = &STANDARD.encode(format!("{}:api_token", api_key)); let headers = Self::authorisation_headers(toggl_auth); Self { client, workspace_id, headers, } } fn authorisation_headers(toggl_auth: &str) -> HeaderMap { let mut headers = HeaderMap::new(); let mut value = HeaderValue::from_str(&format!("Basic {}", toggl_auth)).unwrap(); value.set_sensitive(true); headers.insert("Authorization", value); headers.insert("Content-Type", HeaderValue::from_static("application/json")); headers } /// Get the workspaces that a user is a part of #[tracing::instrument(skip(self))] async fn get_users_workspaces(&self) -> Result, TogglError> { let url = format!("{base_url}/me/workspaces", base_url = BASE_URL); let response = self .client .get(&url) .headers(self.headers.clone()) .send() .await?; let data = response.text().await?; let workspaces: Vec = serde_json::from_str(&data)?; Ok(workspaces) } /// Get a specific workspace by its ID #[tracing::instrument(skip(self))] pub async fn get_workspace(&self, id: u64) -> Result { let url = format!("{base_url}/workspaces/{id}", base_url = BASE_URL, id = id); Self::parse( self.client .get(&url) .headers(self.headers.clone()) .send() .await?, ) .await } /// Fetches all time entries for this user from Toggl that have been modified since the given /// date. #[tracing::instrument(skip(self))] pub async fn get_time_entries_for_user_modified_since( &self, since: DateTime, ) -> Result, TogglError> { let url = format!( "{base_url}/me/time_entries?since={since}", base_url = BASE_URL, since = since.timestamp() ); Self::parse( self.client .get(&url) .headers(self.headers.clone()) .send() .await?, ) .await } /// Fetches all time entries for this user from Toggl that have a start time between the given /// start and end times. #[tracing::instrument(skip(self))] pub async fn get_time_user_entries_between( &self, start: DateTime, until: DateTime, ) -> Result, TogglError> { let url = format!("{base_url}/me/time_entries", base_url = BASE_URL,); Self::parse( self.client .get(url) .headers(self.headers.clone()) .query(&[ ( "start_date", start.to_rfc3339_opts(SecondsFormat::Secs, true), ), ("end_date", until.to_rfc3339_opts(SecondsFormat::Secs, true)), ]) .send() .await?, ) .await } #[tracing::instrument(skip(self))] pub async fn get_current_time_entry(&self) -> Result, TogglError> { let url = format!("{base_url}/me/time_entries/current", base_url = BASE_URL); Ok(self .client .get(&url) .headers(self.headers.clone()) .send() .await? .json() .await?) } #[tracing::instrument(skip(self))] pub async fn get_projects(&self) -> Result, TogglError> { let url = format!( "{base_url}/workspaces/{workspace_id}/projects", base_url = BASE_URL, workspace_id = self.workspace_id ); Self::parse( self.client .get(&url) .headers(self.headers.clone()) .send() .await?, ) .await } #[tracing::instrument(skip(self))] pub async fn get_clients(&self) -> Result, TogglError> { let url = format!( "{base_url}/workspaces/{workspace_id}/clients", base_url = BASE_URL, workspace_id = self.workspace_id ); Self::parse( self.client .get(&url) .headers(self.headers.clone()) .send() .await?, ) .await } async fn parse(response: Response) -> Result { let data = response.text().await?; let result = serde_json_path_to_error::from_str(&data); let result = result.map_err(|error| TogglError::JsonWithDataError { data, error })?; Ok(result) } #[tracing::instrument(skip(self))] pub async fn get_tags(&self) -> Result, TogglError> { let url = format!( "{base_url}/workspaces/{workspace_id}/tags", base_url = BASE_URL, workspace_id = self.workspace_id ); Self::parse( self.client .get(&url) .headers(self.headers.clone()) .send() .await?, ) .await } fn paginate_filters( original_filters: &types::TogglReportFilters, last_row_id: u64, ) -> types::TogglReportFilters { let mut filters = original_filters.clone(); filters.first_row_number = Some(last_row_id + 1); filters } #[tracing::instrument(skip(self))] pub async fn search( &self, workspace_id: u64, filters: types::TogglReportFilters, ) -> Result, TogglError> { let url = format!( "{base_url}/workspace/{workspace_id}/search/time_entries", base_url = &REPORTS_BASE_URL, workspace_id = workspace_id, ); let mut last_row_number = Some(0); let mut results = vec![]; while let Some(last_row_number_n) = last_row_number { // If we are not on the first page, wait a bit to avoid rate limiting if last_row_number_n != 0 { tokio::time::sleep(Duration::from_secs(1)).await; } let data: Vec = Self::parse( self.client .post(&url) .headers(self.headers.clone()) .json(&Self::paginate_filters(&filters, last_row_number_n)) .send() .await?, ) .await?; last_row_number = data.last().map(|e| e.row_number as u64); data.into_iter().for_each(|e| results.push(e)); } Ok(results) } } #[derive(Debug, thiserror::Error)] pub enum TogglError { #[error("Toggl returned error: {0}")] TogglError(serde_json::Value), #[error("Reqwest error: {0}")] ReqwestError(#[from] reqwest_middleware::Error), #[error("Json error: {0}")] JsonError(#[from] serde_json::Error), #[error("Failed to parse JSON data: {data}, error: {error}")] JsonWithDataError { data: String, error: serde_json_path_to_error::Error, }, } impl From for TogglError { fn from(value: reqwest::Error) -> Self { TogglError::ReqwestError(reqwest_middleware::Error::Reqwest(value)) } }