Move toggl-portal to its own package

This commit is contained in:
Joshua Coles 2023-10-29 10:33:07 +00:00
commit 31b322d643
4 changed files with 354 additions and 0 deletions

20
Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "toggl-portal"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.0", features = ["full"] }
axum = "0.6.20"
clap = { version = "4.4.3", features = ["derive", "env"] }
serde = { version = "1.0.188", features = ["derive"] }
serde_json = "1.0.106"
tower-http = { version = "0.4.3", features = ["trace", "cors", "compression-gzip"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] }
anyhow = { version = "^1.0", features = ["backtrace"] }
beachhead = { path = "../" }
hyper = "0.14.27"
reqwest = { version = "0.11.20", features = ["rustls", "json"] }
base64 = "0.21.4"
serde_with = "3.3.0"

155
src/client.rs Normal file
View File

@ -0,0 +1,155 @@
use reqwest::Client;
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
use hyper::HeaderMap;
use tracing::instrument;
use tracing::log::debug;
use crate::types::{Current, Project, ReportEntry, TogglQuery};
#[derive(Debug, Clone)]
pub struct TogglClient {
client: Client,
workspace_id: String,
base_url: String,
reports_base_url: String,
headers: HeaderMap,
}
impl TogglClient {
pub async fn check_health(&self) -> bool {
true
}
pub fn new(workspace_id: &str, toggl_auth: &str) -> Self {
let client = Client::builder()
.default_headers(Self::default_headers(toggl_auth))
.build()
.expect("Failed to build reqwest client");
Self {
client,
workspace_id: workspace_id.to_string(),
base_url: "https://api.track.toggl.com/api/v9".to_string(),
reports_base_url: "https://api.track.toggl.com/reports/api/v3".to_string(),
headers: Self::default_headers(toggl_auth),
}
}
fn default_headers(toggl_auth: &str) -> reqwest::header::HeaderMap {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"Authorization",
reqwest::header::HeaderValue::from_str(&format!("Basic {}", toggl_auth)).unwrap(),
);
headers
}
pub async fn fetch_projects(&self) -> Result<Vec<Project>, reqwest::Error> {
let url = format!(
"{base_url}/workspaces/{}/projects",
self.workspace_id,
base_url = self.base_url,
);
let res = self
.client
.get(&url)
.headers(self.headers.clone())
.send()
.await?
.json::<Vec<Project>>()
.await
.unwrap();
Ok(res)
}
pub async fn get_current(&self) -> Result<Option<Current>, reqwest::Error> {
let url = format!(
"{base_url}/me/time_entries/current",
base_url = self.base_url
);
let res = self
.client
.get(url)
.send()
.await?
.json::<Option<Current>>()
.await
.unwrap();
Ok(res)
}
fn create_filters(original_filters: &TogglQuery, last_row_id: u64) -> TogglQuery {
let mut filters: TogglQuery = original_filters.clone();
filters.first_row_number = Some(last_row_id + 1);
filters
}
#[instrument(skip(self, filters))]
pub async fn full_report(
&self,
filters: &TogglQuery,
) -> anyhow::Result<Vec<ReportEntry>> {
let url = format!(
"{base_url}/workspace/{workspace_id}/search/time_entries",
base_url = self.reports_base_url,
workspace_id = self.workspace_id
);
let mut last_row_number = Some(0);
let mut results = vec![];
while let Some(last_row_number_n) = last_row_number {
debug!("Fetching page starting with {}", last_row_number_n);
// 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_millis(1000)).await;
}
let data = self
.client
.post(&url)
.headers(self.headers.clone())
.json(&Self::create_filters(&filters, last_row_number_n))
.send()
.await?
.json::<Vec<ReportEntry>>()
.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 async fn start_time_entry(&self, mut body: HashMap<String, Value>) -> anyhow::Result<()> {
let url = format!(
"{base_url}/workspaces/{workspace_id}/time_entries",
base_url = self.base_url,
workspace_id = self.workspace_id
);
body.insert(
"workspace_id".to_string(),
self.workspace_id.parse::<i32>().unwrap().into(),
);
dbg!(self.client
.post(url)
.headers(self.headers.clone())
.json(&body)
.send()
.await?
.text()
.await?);
Ok(())
}
}

89
src/main.rs Normal file
View File

@ -0,0 +1,89 @@
use crate::client::TogglClient;
use crate::types::{Current, ReportEntry, TogglQuery};
use anyhow::anyhow;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use beachhead::{shutdown_signal, Result};
use clap::Parser;
use serde_json::Value;
use std::collections::HashMap;
use std::net::SocketAddr;
use tower_http::trace::TraceLayer;
mod client;
mod types;
#[derive(Debug, Clone, Parser)]
struct Config {
#[arg(long = "workspace", short, env)]
workspace_id: u32,
#[arg(long = "token", short, env)]
toggl_api_token: String,
#[arg(long = "addr", short, env, default_value = "0.0.0.0:3000")]
address: SocketAddr,
}
pub async fn report(
Extension(toggl_client): Extension<TogglClient>,
Json(query): Json<TogglQuery>,
) -> Result<Json<Vec<ReportEntry>>> {
Ok(toggl_client.full_report(&query).await.map(Json)?)
}
pub async fn current(
Extension(toggl_client): Extension<TogglClient>,
) -> Result<Json<Option<Current>>> {
Ok(toggl_client.get_current().await.map(Json)?)
}
pub async fn start_time_entry(
Extension(toggl_client): Extension<TogglClient>,
Json(body): Json<HashMap<String, Value>>,
) -> Result<impl IntoResponse> {
toggl_client.start_time_entry(body).await?;
Ok((StatusCode::OK, "Ok"))
}
async fn health(Extension(toggl_client): Extension<TogglClient>) -> Result<&'static str> {
return if toggl_client.check_health().await {
Ok("Ok")
} else {
Err(anyhow!("Panopto health check failed").into())
};
}
#[tokio::main]
async fn main() -> Result<()> {
// install global collector configured based on RUST_LOG env var.
tracing_subscriber::fmt::init();
let config = Config::parse();
let toggl_client = TogglClient::new(
&config.workspace_id.to_string(),
&STANDARD.encode(&format!("{}:api_token", config.toggl_api_token)),
);
// build our application with a route
let app = Router::new()
.route("/health", get(health))
.route("/current", get(current))
.route("/report", post(report))
.route("/start_time_entry", post(start_time_entry))
.layer(Extension(toggl_client))
.layer(TraceLayer::new_for_http());
tracing::info!("Listening on {}", config.address);
axum::Server::try_bind(&config.address)?
.serve(app.into_make_service())
.with_graceful_shutdown(shutdown_signal())
.await?;
Ok(())
}

90
src/types.rs Normal file
View File

@ -0,0 +1,90 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_with::skip_serializing_none;
use std::collections::HashMap;
use std::option::Option;
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct TimeEntry {
pub id: u64,
pub seconds: u32,
pub start: String,
pub stop: String,
pub at: String,
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct ReportEntry {
pub user_id: u32,
pub username: String,
pub project_id: Option<u64>,
pub task_id: Option<u64>,
pub billable: bool,
pub description: String,
pub tag_ids: Vec<u64>,
pub billable_amount_in_cents: Option<u64>,
pub hourly_rate_in_cents: Option<u64>,
pub currency: String,
pub time_entries: Vec<TimeEntry>,
pub row_number: u32,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Current {
pub id: u64,
pub workspace_id: u64,
pub project_id: Option<u64>,
pub task_id: Option<u64>,
pub billable: bool,
pub start: String,
pub stop: Option<String>,
pub duration: i64,
pub description: String,
pub tags: Vec<String>,
pub tag_ids: Vec<u64>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Project {
pub id: u64,
workspace_id: u64,
pub client_id: Option<u64>,
name: String,
active: bool,
#[serde(flatten)]
pub rest: HashMap<String, Value>,
}
#[allow(non_snake_case)]
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TogglQuery {
pub billable: Option<bool>,
pub client_ids: Option<Vec<u64>>,
pub description: Option<String>,
pub end_date: Option<String>,
pub first_id: Option<u64>,
pub first_row_number: Option<u64>,
pub first_timestamp: Option<u64>,
pub group_ids: Option<Vec<u64>>,
pub grouped: Option<bool>,
pub hide_amounts: Option<bool>,
pub max_duration_seconds: Option<u64>,
pub min_duration_seconds: Option<u64>,
pub order_by: Option<String>,
pub order_dir: Option<String>,
pub postedFields: Option<Vec<String>>,
pub project_ids: Option<Vec<u64>>,
pub rounding: Option<u64>,
pub rounding_minutes: Option<u64>,
pub startTime: Option<String>,
pub start_date: Option<String>,
pub tag_ids: Option<Vec<u64>>,
pub task_ids: Option<Vec<u64>>,
pub time_entry_ids: Option<Vec<u64>>,
pub user_ids: Option<Vec<u64>>,
#[serde(flatten)]
pub rest: HashMap<String, Value>,
}