use reqwest_retry::policies::ExponentialBackoff; use std::time::Duration; use reqwest_retry::{Jitter, RetryTransientMiddleware}; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use std::num::NonZero; use axum::async_trait; use governor::state::{InMemoryState, NotKeyed}; use governor::clock::DefaultClock; use reqwest::header::{HeaderMap, HeaderValue}; use base64::engine::general_purpose::STANDARD; use base64::Engine; use chrono::{DateTime, SecondsFormat, Utc}; use reqwest::Response; use serde::de::DeserializeOwned; struct ReqwestRateLimiter { rate_limiter: governor::RateLimiter, } impl ReqwestRateLimiter { fn new() -> Self { Self { rate_limiter: governor::RateLimiter::direct( governor::Quota::per_second(NonZero::new(1u32).unwrap()) ), } } } #[async_trait] impl reqwest_ratelimit::RateLimiter for ReqwestRateLimiter { async fn acquire_permit(&self) { // We don't need to introduce jitter here as that is handled by the retry_request // middleware. self.rate_limiter.until_ready().await; } } #[derive(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) } } pub mod types { use std::collections::HashMap; use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; #[derive(Serialize, Deserialize, Debug, Clone, Soars)] pub struct TimeEntry { pub id: u64, pub workspace_id: u64, pub user_id: u64, pub project_id: Option, pub task_id: Option, pub start: DateTime, pub stop: Option>, #[serde(with = "duration_field")] pub duration: Option, #[serde(rename = "at")] pub updated_at: DateTime, pub description: Option, #[serde(default)] pub tags: Vec, #[serde(default)] pub tag_ids: Vec, pub billable: bool, pub server_deleted_at: Option>, pub permissions: Option, } mod duration_field { use serde::{Deserialize, Serialize}; pub fn serialize(duration: &Option, serializer: S) -> Result where S: serde::Serializer, { match duration { None => i32::serialize(&-1, serializer), Some(duration) => i32::serialize(&(*duration as i32), serializer), } } pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { let duration = i32::deserialize(deserializer)?; if duration < 0 { Ok(None) } else { Ok(Some(duration as u32)) } } } #[derive(Serialize, Deserialize, Debug, Clone, Soars)] pub struct Project { pub id: i64, pub workspace_id: i64, pub client_id: Option, pub name: String, pub color: String, pub status: ProjectStatus, pub active: bool, #[serde(rename = "at")] pub updated_at: DateTime, pub start_date: NaiveDate, pub created_at: DateTime, pub server_deleted_at: Option>, pub actual_hours: Option, pub actual_seconds: Option, pub can_track_time: bool, pub permissions: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "lowercase")] pub enum ProjectStatus { Upcoming, Active, Archived, Ended, Deleted, } impl fmt::Display for ProjectStatus { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", match self { ProjectStatus::Upcoming => "upcoming", ProjectStatus::Active => "active", ProjectStatus::Archived => "archived", ProjectStatus::Ended => "ended", ProjectStatus::Deleted => "deleted", }) } } #[derive(Serialize, Deserialize, Debug, Clone, Soars)] pub struct TrackingClient { /// The unique identifier for the client. pub id: i64, /// Represents the timestamp of the last update made to the client. #[serde(rename = "at")] pub updated_at: DateTime, /// Indicates whether the client is archived or not. pub archived: bool, pub creator_id: i64, pub integration_provider: Option, pub notes: Option, /// The name of the client. pub name: String, /// Indicates the timestamp when the client was deleted. If the client is not deleted, this property will be null. pub server_deleted_at: Option>, /// The Workspace ID associated with the client. #[serde(rename = "wid")] pub workspace_id: i64, pub permissions: Option, } #[derive(Soars, Serialize, Deserialize, Debug, Clone)] pub struct Tag { pub id: u64, pub name: String, pub workspace_id: u64, pub creator_id: u64, #[serde(rename = "at")] pub updated_at: DateTime, pub deleted_at: Option>, pub permissions: Option, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ReportEntry { pub user_id: u64, pub username: String, pub project_id: Option, pub task_id: Option, pub billable: bool, pub description: Option, pub tag_ids: Vec, pub billable_amount_in_cents: Option, pub hourly_rate_in_cents: Option, pub currency: String, pub time_entries: Vec, pub row_number: u32, #[serde(flatten)] pub enriched_information: Option, #[serde(flatten)] pub rest: HashMap, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ReportEntryEnrichedInfo { pub project_id: Option, pub project_name: Option, pub project_hex: Option, pub tag_names: Vec, } #[derive(Clone, Serialize, Deserialize, Debug)] pub struct ReportEntryTimeDetails { pub id: u64, pub seconds: u32, pub start: DateTime, pub stop: DateTime, #[serde(rename = "at")] pub updated_at: DateTime, } impl ReportEntry { pub fn into_time_entry(self, workspace_id: u64) -> TimeEntry { TimeEntry { id: self.time_entries[0].id, workspace_id, user_id: self.user_id, project_id: self.project_id, task_id: self.task_id, start: self.time_entries[0].start, stop: Some(self.time_entries[0].stop), duration: Some(self.time_entries[0].seconds), updated_at: self.time_entries[0].updated_at, description: self.description, tags: self.enriched_information .map(|e| e.tag_names.clone()) .unwrap_or_default(), tag_ids: self.tag_ids, billable: self.billable, server_deleted_at: None, permissions: None, } } } #[skip_serializing_none] #[derive(Serialize, Deserialize, Clone, Default)] pub struct TogglReportFilters { pub billable: Option, pub client_ids: Option>, pub description: Option, pub end_date: Option, pub enrich_response: Option, pub first_id: Option, pub first_row_number: Option, pub first_timestamp: Option, pub group_ids: Option>, pub grouped: Option, pub hide_amounts: Option, pub max_duration_seconds: Option, pub min_duration_seconds: Option, pub order_by: Option, pub order_dir: Option, pub page_size: Option, #[serde(rename = "postedFields")] pub posted_fields: Option>, pub project_ids: Option>, pub rounding: Option, pub rounding_minutes: Option, #[serde(rename = "startTime")] pub start_time: Option, pub start_date: Option, pub tag_ids: Option>, pub task_ids: Option>, pub time_entry_ids: Option>, pub user_ids: Option>, #[serde(flatten)] pub rest: HashMap, } use std::fmt; use soa_rs::Soars; impl fmt::Debug for TogglReportFilters { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut ds = f.debug_struct("TogglQuery"); if let Some(billable) = &self.billable { ds.field("billable", billable); } if let Some(client_ids) = &self.client_ids { ds.field("client_ids", client_ids); } if let Some(description) = &self.description { ds.field("description", description); } if let Some(end_date) = &self.end_date { ds.field("end_date", end_date); } if let Some(first_id) = &self.first_id { ds.field("first_id", first_id); } if let Some(first_row_number) = &self.first_row_number { ds.field("first_row_number", first_row_number); } if let Some(first_timestamp) = &self.first_timestamp { ds.field("first_timestamp", first_timestamp); } if let Some(group_ids) = &self.group_ids { ds.field("group_ids", group_ids); } if let Some(grouped) = &self.grouped { ds.field("grouped", grouped); } if let Some(hide_amounts) = &self.hide_amounts { ds.field("hide_amounts", hide_amounts); } if let Some(max_duration_seconds) = &self.max_duration_seconds { ds.field("max_duration_seconds", max_duration_seconds); } if let Some(min_duration_seconds) = &self.min_duration_seconds { ds.field("min_duration_seconds", min_duration_seconds); } if let Some(order_by) = &self.order_by { ds.field("order_by", order_by); } if let Some(order_dir) = &self.order_dir { ds.field("order_dir", order_dir); } if let Some(posted_fields) = &self.posted_fields { ds.field("postedFields", posted_fields); } if let Some(project_ids) = &self.project_ids { ds.field("project_ids", project_ids); } if let Some(rounding) = &self.rounding { ds.field("rounding", rounding); } if let Some(rounding_minutes) = &self.rounding_minutes { ds.field("rounding_minutes", rounding_minutes); } if let Some(start_time) = &self.start_time { ds.field("startTime", start_time); } if let Some(start_date) = &self.start_date { ds.field("start_date", start_date); } if let Some(tag_ids) = &self.tag_ids { ds.field("tag_ids", tag_ids); } if let Some(task_ids) = &self.task_ids { ds.field("task_ids", task_ids); } if let Some(time_entry_ids) = &self.time_entry_ids { ds.field("time_entry_ids", time_entry_ids); } if let Some(user_ids) = &self.user_ids { ds.field("user_ids", user_ids); } if !self.rest.is_empty() { ds.field("rest", &self.rest); } ds.finish() } } #[derive(Serialize, Deserialize, Debug, Clone, Soars)] pub struct Workspace { pub id: u64, pub organization_id: u64, pub name: String, } } #[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)) } }