[refactor] include Everhour backend

This commit is contained in:
Bodo Junglas 2021-04-24 18:39:22 +02:00
parent 6ea273577e
commit cd1eaaeedd
No known key found for this signature in database
GPG Key ID: A7C4E6F450E47C3A
30 changed files with 1861 additions and 431 deletions

View File

@ -12,6 +12,6 @@ jobs:
- name: Test
run: cargo test --release
- name: Build
uses: docker://untoldwind/rust-musl-builder:v1.41.0
uses: docker://untoldwind/rust-musl-builder:v1.52.0
with:
args: cargo build --release

View File

@ -23,7 +23,7 @@ jobs:
run: cargo publish
working-directory: ./cli
- name: Build
uses: docker://untoldwind/rust-musl-builder:v1.41.0
uses: docker://untoldwind/rust-musl-builder:v1.52.0
with:
args: cargo build --release
- name: Copy binary

130
Cargo.lock generated
View File

@ -2,13 +2,13 @@
# It is not intended for manual editing.
[[package]]
name = "acari-cli"
version = "0.1.10"
version = "0.1.11"
dependencies = [
"acari-lib",
"chrono",
"clap",
"dirs 2.0.2",
"itertools",
"itertools 0.10.0",
"openssl-probe",
"prettytable-rs",
"serde",
@ -19,17 +19,18 @@ dependencies = [
[[package]]
name = "acari-lib"
version = "0.1.10"
version = "0.1.11"
dependencies = [
"chrono",
"dirs 2.0.2",
"pact_consumer",
"pact_mock_server",
"percent-encoding 2.1.0",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"url 2.2.1",
"url 2.2.2",
]
[[package]]
@ -147,12 +148,12 @@ dependencies = [
[[package]]
name = "bstr"
version = "0.2.15"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a40b47ad93e1a5404e6c18dec46b628214fee441c70f4ab5d6942142cc268a3d"
checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279"
dependencies = [
"lazy_static",
"memchr 2.3.4",
"memchr 2.4.0",
"regex-automata",
"serde",
]
@ -308,9 +309,9 @@ checksum = "ea221b5284a47e40033bf9b66f35f984ec0ea2931eb03505246cd27a963f981b"
[[package]]
name = "crossbeam-utils"
version = "0.8.3"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9d99fa91428effe99c5c6d4634cdeba32b8cf784fc428a2a687f61a952c49"
checksum = "4feb231f0d4d6af81aed15928e58ecf5816aa62a2393e2c82f46973e92a9a278"
dependencies = [
"autocfg 1.0.1",
"cfg-if 1.0.0",
@ -336,7 +337,7 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90"
dependencies = [
"memchr 2.3.4",
"memchr 2.4.0",
]
[[package]]
@ -368,7 +369,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901"
dependencies = [
"libc",
"redox_users",
"redox_users 0.3.5",
"winapi 0.3.9",
]
@ -384,12 +385,12 @@ dependencies = [
[[package]]
name = "dirs-sys"
version = "0.3.5"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e93d7f5705de3e49895a2b5e0b8855a1c27f080192ae9c32a6432d50741a57a"
checksum = "03d86534ed367a67548dc68113a0f5db55432fdfbb6e6f9d77704397d95d5780"
dependencies = [
"libc",
"redox_users",
"redox_users 0.4.0",
"winapi 0.3.9",
]
@ -642,7 +643,7 @@ dependencies = [
"futures-macro",
"futures-sink",
"futures-task",
"memchr 2.3.4",
"memchr 2.4.0",
"pin-project-lite 0.2.6",
"pin-utils",
"proc-macro-hack",
@ -889,6 +890,15 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d572918e350e82412fe766d24b15e6682fb2ed2bbe018280caa810397cb319"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "0.4.7"
@ -928,9 +938,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "lexical-core"
version = "0.7.5"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21f866863575d0e1d654fbeeabdc927292fdf862873dc3c96c6f753357e13374"
checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
dependencies = [
"arrayvec",
"bitflags",
@ -941,9 +951,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.93"
version = "0.2.94"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41"
checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e"
[[package]]
name = "lock_api"
@ -1004,9 +1014,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.3.4"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525"
checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc"
[[package]]
name = "mime"
@ -1125,7 +1135,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af"
dependencies = [
"lexical-core",
"memchr 2.3.4",
"memchr 2.4.0",
"version_check 0.9.3",
]
@ -1188,9 +1198,9 @@ dependencies = [
[[package]]
name = "openssl"
version = "0.10.33"
version = "0.10.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a61075b62a23fef5a29815de7536d940aa35ce96d18ce0cc5076272db678a577"
checksum = "6d7830286ad6a3973c0f1d9b73738f69c76b739301d0229c4b96501695cbe4c8"
dependencies = [
"bitflags",
"cfg-if 1.0.0",
@ -1208,9 +1218,9 @@ checksum = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"
[[package]]
name = "openssl-sys"
version = "0.9.61"
version = "0.9.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "313752393519e876837e09e1fa183ddef0be7735868dced3196f4472d536277f"
checksum = "b6b0d6fb7d80f877617dfcb014e605e2b5ab2fb0afdf27935219bb6bd984cb98"
dependencies = [
"autocfg 1.0.1",
"cc",
@ -1241,7 +1251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "929be0f2ee4cf9c0054617d529ce357390748f49ca5097488e0629a915e69335"
dependencies = [
"futures 0.3.14",
"itertools",
"itertools 0.9.0",
"lazy_static",
"libc",
"log 0.4.14",
@ -1251,7 +1261,7 @@ dependencies = [
"regex 0.1.80",
"serde_json",
"tokio",
"url 2.2.1",
"url 2.2.2",
"uuid 0.6.5",
]
@ -1271,7 +1281,7 @@ dependencies = [
"httparse",
"hyper 0.10.16",
"indextree",
"itertools",
"itertools 0.9.0",
"lazy_static",
"log 0.4.14",
"maplit",
@ -1281,7 +1291,7 @@ dependencies = [
"parse-zoneinfo",
"rand 0.6.5",
"rand_regex",
"regex-syntax 0.6.23",
"regex-syntax 0.6.25",
"reqwest",
"semver",
"serde",
@ -1300,7 +1310,7 @@ dependencies = [
"bytes 0.5.6",
"futures 0.3.14",
"hyper 0.13.10",
"itertools",
"itertools 0.9.0",
"lazy_static",
"log 0.4.14",
"maplit",
@ -1342,7 +1352,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41"
dependencies = [
"regex 1.4.5",
"regex 1.5.4",
]
[[package]]
@ -1670,7 +1680,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d3b5a2a79d1a83bb46ea3058c3447e3db8c11ae833c7a695e286762683beb7"
dependencies = [
"rand 0.6.5",
"regex-syntax 0.6.23",
"regex-syntax 0.6.25",
]
[[package]]
@ -1699,9 +1709,9 @@ checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce"
[[package]]
name = "redox_syscall"
version = "0.2.6"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8270314b5ccceb518e7e578952f0b72b88222d02e8f77f5ecf7abbb673539041"
checksum = "742739e41cd49414de871ea5e549afb7e2a3ac77b589bcbebe8c82fab37147fc"
dependencies = [
"bitflags",
]
@ -1717,6 +1727,16 @@ dependencies = [
"rust-argon2",
]
[[package]]
name = "redox_users"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64"
dependencies = [
"getrandom 0.2.2",
"redox_syscall 0.2.8",
]
[[package]]
name = "regex"
version = "0.1.80"
@ -1732,11 +1752,11 @@ dependencies = [
[[package]]
name = "regex"
version = "1.4.5"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19"
checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461"
dependencies = [
"regex-syntax 0.6.23",
"regex-syntax 0.6.25",
]
[[package]]
@ -1756,9 +1776,9 @@ checksum = "f9ec002c35e86791825ed294b50008eea9ddfc8def4420124fbc6b08db834957"
[[package]]
name = "regex-syntax"
version = "0.6.23"
version = "0.6.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548"
checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b"
[[package]]
name = "remove_dir_all"
@ -1801,7 +1821,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.14.1",
"tokio-tls",
"url 2.2.1",
"url 2.2.2",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
@ -1983,9 +2003,9 @@ dependencies = [
[[package]]
name = "slab"
version = "0.4.2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527"
[[package]]
name = "smallvec"
@ -2034,9 +2054,9 @@ dependencies = [
[[package]]
name = "syn"
version = "1.0.69"
version = "1.0.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48fe99c6bd8b1cc636890bcc071842de909d902c81ac7dab53ba33c421ab8ffb"
checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82"
dependencies = [
"proc-macro2",
"quote",
@ -2062,7 +2082,7 @@ dependencies = [
"cfg-if 1.0.0",
"libc",
"rand 0.8.3",
"redox_syscall 0.2.6",
"redox_syscall 0.2.8",
"remove_dir_all",
"winapi 0.3.9",
]
@ -2171,7 +2191,7 @@ dependencies = [
"futures-core",
"iovec",
"lazy_static",
"memchr 2.3.4",
"memchr 2.4.0",
"mio",
"num_cpus",
"pin-project-lite 0.1.12",
@ -2266,9 +2286,9 @@ checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6"
[[package]]
name = "tracing"
version = "0.1.25"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01ebdc2bb4498ab1ab5f5b73c5803825e60199229ccba0698170e3be0e7f959f"
checksum = "09adeb8c97449311ccd28a427f96fb563e7fd31aabf994189879d9da2394b89d"
dependencies = [
"cfg-if 1.0.0",
"log 0.4.14",
@ -2278,9 +2298,9 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.17"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50de3927f93d202783f4513cda820ab47ef17f624b03c096e86ef00c67e6b5f"
checksum = "a9ff14f98b1a4b289c6248a023c1c2fa1491062964e9fed67ab29c4e4da4a052"
dependencies = [
"lazy_static",
]
@ -2382,9 +2402,9 @@ checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3"
[[package]]
name = "unicode-xid"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
[[package]]
name = "untrusted"
@ -2405,9 +2425,9 @@ dependencies = [
[[package]]
name = "url"
version = "2.2.1"
version = "2.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ccd964113622c8e9322cfac19eb1004a07e636c545f325da085d5cdde6f1f8b"
checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c"
dependencies = [
"form_urlencoded",
"idna 0.2.3",

View File

@ -1,6 +1,6 @@
[package]
name = "acari-cli"
version = "0.1.10"
version = "0.1.11"
authors = ["Bodo Junglas <junglas@objectcode.de>"]
edition = "2018"
license = "MIT"
@ -19,7 +19,7 @@ text_io = "0"
prettytable-rs = "0"
itertools = "0"
chrono = { version = "0.4", features = ["serde"] }
acari-lib = { version= "0.1.10", path = "../lib" }
acari-lib = { version= "0.1.11", path = "../lib" }
openssl-probe = "0"

View File

@ -22,10 +22,10 @@ pub struct AddCmd {
impl AddCmd {
pub fn run(&self, client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
let customer = find_customer(client, &self.customer)?;
let project = find_project(client, customer.id, &self.project)?;
let service = find_service(client, &self.service)?;
let project = find_project(client, &customer.id, &self.project)?;
let service = find_service(client, &project.id, &self.service)?;
client.create_time_entry(self.day, project.id, service.id, self.time, self.note.clone())?;
client.create_time_entry(self.day, &project.id, &service.id, self.time, self.note.clone())?;
entries(client, output_format, self.day.into())
}

View File

@ -44,7 +44,9 @@ fn print_json(projects: Vec<Project>) -> Result<(), AcariError> {
fn print_flat(projects: Vec<(&str, Vec<&Project>)>) {
for (customer_name, group) in projects {
for project in group {
println!("{}/{}", customer_name, project.name);
if !project.archived {
println!("{}/{}", customer_name, project.name);
}
}
}
}

View File

@ -22,8 +22,7 @@ fn print_pretty(account: Account, user: User) {
["Name", account.name],
["Title", account.title],
["Currency", account.currency],
["Created at", account.created_at.to_string()],
["Updated at", account.updated_at.to_string()]
["Created at", account.created_at.to_string()]
);
println!("Account");
@ -36,8 +35,7 @@ fn print_pretty(account: Account, user: User) {
["Email", user.email],
["Role", user.role],
["Language", user.language],
["Created at", user.created_at.to_string()],
["Updated at", user.updated_at.to_string()]
["Created at", user.created_at.to_string()]
);
println!();

View File

@ -1,5 +0,0 @@
use acari_lib::{AcariError, CachedClient};
pub fn clear_cache() -> Result<(), AcariError> {
CachedClient::clear_cache()
}

View File

@ -22,7 +22,11 @@ fn print_pretty(customers: Vec<Customer>) {
customers_table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
for customer in customers {
customers_table.add_row(row![customer.name]);
if customer.archived {
customers_table.add_row(row![FY => customer.name]);
} else {
customers_table.add_row(row![customer.name]);
}
}
customers_table.printstd();
}
@ -35,6 +39,8 @@ fn print_json(customers: Vec<Customer>) -> Result<(), AcariError> {
fn print_flat(customers: Vec<Customer>) {
for customer in customers {
println!("{}", customer.name);
if !customer.archived {
println!("{}", customer.name);
}
}
}

View File

@ -1,5 +1,5 @@
use super::OutputFormat;
use acari_lib::{AcariError, Client, DateSpan, Minutes, TimeEntry, TrackingTimeEntry};
use acari_lib::{AcariError, Client, DateSpan, Minutes, TimeEntry};
use chrono::NaiveDate;
use clap::Clap;
use itertools::Itertools;
@ -32,15 +32,15 @@ pub fn entries(client: &dyn Client, output_format: OutputFormat, date_span: Date
.collect();
match output_format {
OutputFormat::Pretty => print_pretty(grouped, tracker.tracking_time_entry),
OutputFormat::Json => print_json(time_entries, tracker.tracking_time_entry)?,
OutputFormat::Pretty => print_pretty(grouped, &tracker.tracking_time_entry),
OutputFormat::Json => print_json(time_entries, &tracker.tracking_time_entry)?,
OutputFormat::Flat => print_flat(grouped, tracker.tracking_time_entry),
}
Ok(())
}
fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry: Option<TrackingTimeEntry>) {
fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry: &Option<TimeEntry>) {
if entries.is_empty() {
println!("No entries found");
return;
@ -56,7 +56,7 @@ fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry
let sum = group
.iter()
.map(|e| {
if let Some(tracking_entry) = tracking_time_entry.filter(|t| t.id == e.id) {
if let Some(tracking_entry) = tracking_time_entry.as_ref().filter(|t| t.id == e.id) {
tracking_entry.minutes
} else {
e.minutes
@ -66,7 +66,7 @@ fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry
total += sum;
entries_table.add_row(row![bFc -> day, bFc -> sum, "", "", "", ""]);
for entry in group {
if let Some(tracking_entry) = tracking_time_entry.filter(|t| t.id == entry.id) {
if let Some(tracking_entry) = tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
entries_table.add_row(row![FY => "", tracking_entry.minutes, entry.customer_name, entry.project_name, entry.service_name, entry.note]);
} else if entry.locked {
entries_table.add_row(row![Fr => "", entry.minutes, entry.customer_name, entry.project_name, entry.service_name, entry.note]);
@ -83,12 +83,12 @@ fn print_pretty(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry
entries_table.printstd();
}
fn print_json(entries: Vec<TimeEntry>, tracking_time_entry: Option<TrackingTimeEntry>) -> Result<(), AcariError> {
fn print_json(entries: Vec<TimeEntry>, tracking_time_entry: &Option<TimeEntry>) -> Result<(), AcariError> {
let json_entries: Result<Vec<Value>, AcariError> = entries
.into_iter()
.map(|entry| match serde_json::to_value(&entry)? {
Value::Object(mut fields) => {
if let Some(tracking_entry) = tracking_time_entry.filter(|t| t.id == entry.id) {
if let Some(tracking_entry) = tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
fields.insert("tracking".to_string(), Value::Bool(true));
fields["minutes"] = json!(tracking_entry.minutes);
} else {
@ -104,10 +104,10 @@ fn print_json(entries: Vec<TimeEntry>, tracking_time_entry: Option<TrackingTimeE
Ok(())
}
fn print_flat(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry: Option<TrackingTimeEntry>) {
fn print_flat(entries: Vec<(&NaiveDate, Vec<&TimeEntry>)>, tracking_time_entry: Option<TimeEntry>) {
for (date, group) in entries {
for entry in group {
if let Some(tracking_entry) = tracking_time_entry.filter(|t| t.id == entry.id) {
if let Some(tracking_entry) = tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
println!(
"{}\t{}\t{}\t{}\t{}\tTRACKING",
date, entry.customer_name, entry.project_name, entry.service_name, tracking_entry.minutes,

View File

@ -1,4 +1,4 @@
use crate::config::{Config, Profile};
use crate::config::{ClientType, Config, Profile};
use std::io::{stdout, Write};
use text_io::try_read;
@ -28,7 +28,14 @@ pub fn init(maybe_existing_config: Option<Config>, maybe_profile: &Option<String
match maybe_profile {
Some(profile) => {
config.profiles.insert(profile.to_string(), Profile { domain, token });
config.profiles.insert(
profile.to_string(),
Profile {
domain,
token,
client: ClientType::Mite,
},
);
}
None => {
config.domain = domain;

View File

@ -3,7 +3,6 @@ use clap::Clap;
mod add;
mod all_projects;
mod check;
mod clear_cache;
mod customers;
mod entries;
mod init;
@ -17,7 +16,6 @@ mod tracker;
pub use add::*;
pub use all_projects::*;
pub use check::*;
pub use clear_cache::*;
pub use customers::*;
pub use entries::*;
pub use init::*;
@ -28,7 +26,7 @@ pub use services::*;
pub use set::*;
pub use tracker::*;
use acari_lib::{user_error, AcariError, Client, Customer, CustomerId, Project, Service};
use acari_lib::{user_error, AcariError, Client, Customer, CustomerId, Project, ProjectId, Service};
#[derive(Clap, Debug, PartialEq)]
pub enum OutputFormat {
@ -46,17 +44,17 @@ fn find_customer(client: &dyn Client, customer_name: &str) -> Result<Customer, A
.ok_or_else(|| user_error!("No customer with name: {}", customer_name))
}
fn find_project(client: &dyn Client, customer_id: CustomerId, project_name: &str) -> Result<Project, AcariError> {
fn find_project(client: &dyn Client, customer_id: &CustomerId, project_name: &str) -> Result<Project, AcariError> {
let projects = client.get_projects()?;
projects
.into_iter()
.find(|p| p.name == project_name && p.customer_id == customer_id)
.find(|p| p.name == project_name && p.customer_id.eq(customer_id))
.ok_or_else(|| user_error!("No project with name: {}", project_name))
}
fn find_service(client: &dyn Client, service_name: &str) -> Result<Service, AcariError> {
let services = client.get_services()?;
fn find_service(client: &dyn Client, project_id: &ProjectId, service_name: &str) -> Result<Service, AcariError> {
let services = client.get_services(project_id)?;
services
.into_iter()

View File

@ -22,8 +22,12 @@ fn print_pretty(projects: Vec<Project>) {
projects_table.set_titles(row!["Projects"]);
projects_table.set_format(*format::consts::FORMAT_NO_LINESEP_WITH_TITLE);
for customer in projects {
projects_table.add_row(row![customer.name]);
for project in projects {
if project.archived {
projects_table.add_row(row![FY => project.name]);
} else {
projects_table.add_row(row![project.name]);
}
}
projects_table.printstd();
}
@ -36,6 +40,8 @@ fn print_json(projects: Vec<Project>) -> Result<(), AcariError> {
fn print_flat(projects: Vec<Project>) {
for project in projects {
println!("{}", project.name);
if !project.archived {
println!("{}", project.name);
}
}
}

View File

@ -1,4 +1,5 @@
use super::OutputFormat;
use super::{find_customer, find_project};
use acari_lib::{AcariError, Client, Service};
use clap::Clap;
use itertools::Itertools;
@ -12,26 +13,32 @@ pub struct ServicesCommand {
project: String,
}
pub fn services(client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
let mut services = client.get_services()?;
impl ServicesCommand {
pub fn run(&self, client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
let customer = find_customer(client, &self.customer)?;
let project = find_project(client, &customer.id, &self.project)?;
let mut services = client.get_services(&project.id)?;
services.sort_by(|s1, s2| s1.name.cmp(&s2.name));
services.sort_by(|s1, s2| s1.name.cmp(&s2.name));
match output_format {
OutputFormat::Pretty => print_pretty(services),
OutputFormat::Json => print_json(services)?,
OutputFormat::Flat => print_flat(services),
match output_format {
OutputFormat::Pretty => print_pretty(services),
OutputFormat::Json => print_json(services)?,
OutputFormat::Flat => print_flat(services),
}
Ok(())
}
Ok(())
}
fn print_pretty(services: Vec<Service>) {
let service_table = table!(
["Billable services"],
[services.iter().filter(|s| s.billable).map(|s| &s.name).join("\n")],
[services.iter().filter(|s| s.billable && !s.archived).map(|s| &s.name).join("\n")],
["Not billable services"],
[services.iter().filter(|s| !s.billable).map(|s| &s.name).join("\n")]
[services.iter().filter(|s| !s.billable && !s.archived).map(|s| &s.name).join("\n")],
["Archived"],
[services.iter().filter(|s| s.archived).map(|s| &s.name).join("\n")]
);
service_table.printstd();
}
@ -44,6 +51,8 @@ fn print_json(services: Vec<Service>) -> Result<(), AcariError> {
fn print_flat(services: Vec<Service>) {
for service in services {
println!("{}", service.name);
if !service.archived {
println!("{}", service.name);
}
}
}

View File

@ -22,20 +22,20 @@ pub struct SetCmd {
impl SetCmd {
pub fn run(&self, client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
let customer = find_customer(client, &self.customer)?;
let project = find_project(client, customer.id, &self.project)?;
let service = find_service(client, &self.service)?;
let project = find_project(client, &customer.id, &self.project)?;
let service = find_service(client, &project.id, &self.service)?;
let date = self.day.as_date();
let mut time_entries = client.get_time_entries(date.into())?;
time_entries.retain(|e| e.date_at == date && e.customer_id == customer.id && e.project_id == project.id && e.service_id == service.id);
time_entries.retain(|e| e.date_at == date && e.customer_id.eq(&customer.id) && e.project_id.eq(&project.id) && e.service_id.eq(&service.id));
if let Some(first) = time_entries.first() {
client.update_time_entry(first.id, self.time, self.note.clone())?;
client.update_time_entry(&first.id, self.time, self.note.clone())?;
for remaining in &time_entries[1..] {
client.delete_time_entry(remaining.id)?;
client.delete_time_entry(&remaining.id)?;
}
} else {
client.create_time_entry(self.day, project.id, service.id, self.time, self.note.clone())?;
client.create_time_entry(self.day, &project.id, &service.id, self.time, self.note.clone())?;
}
entries(client, output_format, date.into())

View File

@ -22,8 +22,8 @@ pub struct StartCmd {
impl StartCmd {
pub fn run(&self, client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
let customer = find_customer(client, &self.customer)?;
let project = find_project(client, customer.id, &self.project)?;
let service = find_service(client, &self.service)?;
let project = find_project(client, &customer.id, &self.project)?;
let service = find_service(client, &project.id, &self.service)?;
let date = Day::Today.as_date();
let maybe_existing = match self.offset {
@ -35,21 +35,21 @@ impl StartCmd {
.filter(|e| e.date_at == date && e.customer_id == customer.id && e.project_id == project.id && e.service_id == service.id)
.collect();
existing.sort_by(|e1, e2| e2.updated_at.cmp(&e1.updated_at));
existing.sort_by(|e1, e2| e2.created_at.cmp(&e1.created_at));
existing.into_iter().next()
}
};
let entry = match maybe_existing {
Some(existing) => existing,
None => client.create_time_entry(date.into(), project.id, service.id, self.offset.unwrap_or_default(), self.note.clone())?,
None => client.create_time_entry(date.into(), &project.id, &service.id, self.offset.unwrap_or_default(), self.note.clone())?,
};
let tracker = client.create_tracker(entry.id)?;
let tracker = client.create_tracker(&entry.id)?;
match output_format {
OutputFormat::Pretty => print_pretty(Some(entry), tracker),
OutputFormat::Json => print_json(Some(entry), tracker)?,
OutputFormat::Flat => print_flat(Some(entry), tracker),
OutputFormat::Pretty => print_pretty(Some(&entry), &tracker),
OutputFormat::Json => print_json(Some(&entry), &tracker)?,
OutputFormat::Flat => print_flat(Some(&entry), &tracker),
}
Ok(())
@ -59,17 +59,17 @@ impl StartCmd {
pub fn tracking(client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
let tracker = client.get_tracker()?;
let maybe_entry = if let Some(tracking_entry) = &tracker.tracking_time_entry {
Some(client.get_time_entry(tracking_entry.id)?)
Some(tracking_entry)
} else if let Some(tracking_entry) = &tracker.stopped_time_entry {
Some(client.get_time_entry(tracking_entry.id)?)
Some(tracking_entry)
} else {
None
};
match output_format {
OutputFormat::Pretty => print_pretty(maybe_entry, tracker),
OutputFormat::Json => print_json(maybe_entry, tracker)?,
OutputFormat::Flat => print_flat(maybe_entry, tracker),
OutputFormat::Pretty => print_pretty(maybe_entry, &tracker),
OutputFormat::Json => print_json(maybe_entry, &tracker)?,
OutputFormat::Flat => print_flat(maybe_entry, &tracker),
}
Ok(())
@ -78,23 +78,23 @@ pub fn tracking(client: &dyn Client, output_format: OutputFormat) -> Result<(),
pub fn stop(client: &dyn Client, output_format: OutputFormat) -> Result<(), AcariError> {
let current_tracker = client.get_tracker()?;
let (update_tracker, maybe_entry) = if let Some(tracking_entry) = &current_tracker.tracking_time_entry {
(client.delete_tracker(tracking_entry.id)?, Some(client.get_time_entry(tracking_entry.id)?))
(client.delete_tracker(&tracking_entry.id)?, Some(tracking_entry))
} else if let Some(tracking_entry) = &current_tracker.stopped_time_entry {
(current_tracker.clone(), Some(client.get_time_entry(tracking_entry.id)?))
(current_tracker.clone(), Some(tracking_entry))
} else {
(current_tracker, None)
};
match output_format {
OutputFormat::Pretty => print_pretty(maybe_entry, update_tracker),
OutputFormat::Json => print_json(maybe_entry, update_tracker)?,
OutputFormat::Flat => print_flat(maybe_entry, update_tracker),
OutputFormat::Pretty => print_pretty(maybe_entry, &update_tracker),
OutputFormat::Json => print_json(maybe_entry, &update_tracker)?,
OutputFormat::Flat => print_flat(maybe_entry, &update_tracker),
}
Ok(())
}
fn print_pretty(maybe_entry: Option<TimeEntry>, tracker: Tracker) {
fn print_pretty(maybe_entry: Option<&TimeEntry>, tracker: &Tracker) {
match maybe_entry {
Some(entry) => {
let mut entry_table = table!(
@ -105,15 +105,15 @@ fn print_pretty(maybe_entry: Option<TimeEntry>, tracker: Tracker) {
);
entry_table.set_format(*format::consts::FORMAT_CLEAN);
if let Some(tracking_entry) = tracker.tracking_time_entry.filter(|t| t.id == entry.id) {
if let Some(tracking_entry) = tracker.tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
entry_table.add_row(row![FY => "Time", tracking_entry.minutes]);
match tracking_entry.since {
match tracker.since {
Some(since) => println!("Currently tracking since {}", since),
None => println!("Currently tracking"),
}
entry_table.printstd();
} else if let Some(tracking_entry) = tracker.stopped_time_entry.filter(|t| t.id == entry.id) {
} else if let Some(tracking_entry) = tracker.stopped_time_entry.as_ref().filter(|t| t.id == entry.id) {
entry_table.add_row(row!["Time", tracking_entry.minutes]);
println!("Stooped tracking");
entry_table.printstd();
@ -125,12 +125,12 @@ fn print_pretty(maybe_entry: Option<TimeEntry>, tracker: Tracker) {
}
}
fn print_json(maybe_entry: Option<TimeEntry>, tracker: Tracker) -> Result<(), AcariError> {
fn print_json(maybe_entry: Option<&TimeEntry>, tracker: &Tracker) -> Result<(), AcariError> {
match maybe_entry {
Some(entry) => {
if let Some(tracking_entry) = tracker.tracking_time_entry.filter(|t| t.id == entry.id) {
if let Some(tracking_entry) = tracker.tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
println!("{}", serde_json::to_string_pretty(&json!({ "entry": entry, "tracking": tracking_entry }))?);
} else if tracker.stopped_time_entry.filter(|t| t.id == entry.id).is_some() {
} else if tracker.stopped_time_entry.as_ref().filter(|t| t.id == entry.id).is_some() {
println!("{}", serde_json::to_string_pretty(&json!({ "stopped": entry }))?);
} else {
println!("{}", serde_json::to_string_pretty(&json!({}))?);
@ -141,15 +141,15 @@ fn print_json(maybe_entry: Option<TimeEntry>, tracker: Tracker) -> Result<(), Ac
Ok(())
}
fn print_flat(maybe_entry: Option<TimeEntry>, tracker: Tracker) {
fn print_flat(maybe_entry: Option<&TimeEntry>, tracker: &Tracker) {
match maybe_entry {
Some(entry) => {
if let Some(tracking_entry) = tracker.tracking_time_entry.filter(|t| t.id == entry.id) {
if let Some(tracking_entry) = tracker.tracking_time_entry.as_ref().filter(|t| t.id == entry.id) {
println!(
"Tracking {}\t{}\t{}\t{}\t{}",
entry.date_at, entry.customer_name, entry.project_name, entry.service_name, tracking_entry.minutes,
);
} else if tracker.stopped_time_entry.filter(|t| t.id == entry.id).is_some() {
} else if tracker.stopped_time_entry.as_ref().filter(|t| t.id == entry.id).is_some() {
println!(
"Stopped {}\t{}\t{}\t{}\t{}",
entry.date_at, entry.customer_name, entry.project_name, entry.service_name, entry.minutes,

View File

@ -1,4 +1,4 @@
use acari_lib::{internal_error, AcariError, CachedClient, Client, StdClient};
use acari_lib::{internal_error, AcariError, CachedClient, Client, EverhourClient, MiteClient};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File};
@ -6,16 +6,33 @@ use std::io::{self, Read, Write};
use std::path::PathBuf;
use std::time::Duration;
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum ClientType {
Mite,
Everhour,
}
impl Default for ClientType {
fn default() -> Self {
ClientType::Mite
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Profile {
pub domain: String,
pub token: String,
#[serde(default)]
pub client: ClientType,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct Config {
pub domain: String,
pub token: String,
#[serde(default)]
pub client: ClientType,
#[serde(default = "default_cache_ttl")]
pub cache_ttl_minutes: u64,
#[serde(default)]
@ -37,21 +54,28 @@ impl Config {
}
pub fn client(&self, maybe_profile: &Option<String>, cached: bool) -> Result<Box<dyn Client>, AcariError> {
let (domain, token) = match maybe_profile {
let (domain, token, client) = match maybe_profile {
Some(profile_name) => {
let profile = self
.profiles
.get(profile_name)
.ok_or_else(|| AcariError::UserError(format!("No such profile: {}", profile_name)))?;
(&profile.domain, &profile.token)
(&profile.domain, &profile.token, &profile.client)
}
None => (&self.domain, &self.token),
None => (&self.domain, &self.token, &self.client),
};
if cached {
Ok(Box::new(CachedClient::new(domain, token, Duration::from_secs(self.cache_ttl_minutes * 60))?))
} else {
Ok(Box::new(StdClient::new(domain, token)?))
}
Ok(match client {
ClientType::Mite if cached => Box::new(CachedClient::new(
MiteClient::new(domain, token)?,
Duration::from_secs(self.cache_ttl_minutes * 60),
)?),
ClientType::Mite => Box::new(MiteClient::new(domain, token)?),
ClientType::Everhour if cached => Box::new(CachedClient::new(
EverhourClient::new(domain, token)?,
Duration::from_secs(self.cache_ttl_minutes * 60),
)?),
ClientType::Everhour => Box::new(EverhourClient::new(domain, token)?),
})
}
pub fn write(&self) -> Result<(), Box<dyn std::error::Error>> {

View File

@ -1,4 +1,4 @@
use acari_lib::AcariError;
use acari_lib::{clear_cache, AcariError};
use clap::Clap;
use std::str;
@ -43,7 +43,7 @@ enum SubCommand {
#[clap(about = "List all projects")]
Projects(commands::ProjectsCmd),
#[clap(about = "List all services")]
Services,
Services(commands::ServicesCommand),
#[clap(about = "Set time for a project at specific day")]
Set(commands::SetCmd),
#[clap(about = "Start tracking time")]
@ -67,12 +67,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
match opts.subcommand {
SubCommand::Add(add_cmd) => add_cmd.run(client.as_ref(), opts.output)?,
SubCommand::Check => commands::check(client.as_ref(), opts.output)?,
SubCommand::ClearCache => commands::clear_cache()?,
SubCommand::ClearCache => clear_cache()?,
SubCommand::Customers => commands::customers(client.as_ref(), opts.output)?,
SubCommand::Entries(entries_cmd) => entries_cmd.run(client.as_ref(), opts.output)?,
SubCommand::Profiles => commands::profiles(config),
SubCommand::Projects(projects_cmd) => projects_cmd.run(client.as_ref(), opts.output)?,
SubCommand::Services => commands::services(client.as_ref(), opts.output)?,
SubCommand::Services(services_cmd) => services_cmd.run(client.as_ref(), opts.output)?,
SubCommand::Set(set_cmd) => set_cmd.run(client.as_ref(), opts.output)?,
SubCommand::Start(start_cmd) => start_cmd.run(client.as_ref(), opts.output)?,
SubCommand::Stop => commands::stop(client.as_ref(), opts.output)?,

View File

@ -1,6 +1,6 @@
[package]
name = "acari-lib"
version = "0.1.10"
version = "0.1.11"
authors = ["Bodo Junglas <junglas@objectcode.de>"]
edition = "2018"
license = "MIT"
@ -16,6 +16,7 @@ serde_json = "1"
reqwest = { version = "0.10", features = ["blocking", "json"] }
chrono = { version = "0.4", features = ["serde"] }
url = "2"
percent-encoding = "2"
[dev-dependencies]
pact_consumer = "0.6"

View File

@ -1,41 +1,43 @@
use crate::error::AcariError;
use crate::model::{Account, Customer, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, User};
use crate::model::{Account, Customer, Minutes, Project, Service, TimeEntry, Tracker, User};
use crate::model::{ProjectId, ServiceId, TimeEntryId};
use crate::query::{DateSpan, Day};
use crate::std_client::StdClient;
use crate::Client;
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use std::fs::{self, File};
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use std::{
fs::{self, File},
path::Path,
};
pub fn clear_cache() -> Result<(), AcariError> {
let cache_dir = cache_dir();
fs::remove_dir_all(cache_dir)?;
Ok(())
}
#[derive(Debug)]
pub struct CachedClient {
client: StdClient,
pub struct CachedClient<C> {
client: C,
cache_dir: PathBuf,
cache_ttl: Duration,
}
impl CachedClient {
pub fn new(domain: &str, token: &str, cache_ttl: Duration) -> Result<CachedClient, AcariError> {
let cache_dir = cache_dir().join(domain);
impl<C> CachedClient<C>
where
C: Client,
{
pub fn new(client: C, cache_ttl: Duration) -> Result<CachedClient<C>, AcariError> {
let cache_dir = cache_dir().join(client.get_domain());
fs::create_dir_all(&cache_dir)?;
Ok(CachedClient {
client: StdClient::new(domain, token)?,
cache_dir,
cache_ttl,
})
}
pub fn clear_cache() -> Result<(), AcariError> {
let cache_dir = cache_dir();
fs::remove_dir_all(cache_dir)?;
Ok(())
Ok(CachedClient { client, cache_dir, cache_ttl })
}
fn cache_data<T, F>(&self, cache_name: &str, fetch_data: F) -> Result<T, AcariError>
@ -60,7 +62,14 @@ impl CachedClient {
}
}
impl Client for CachedClient {
impl<C> Client for CachedClient<C>
where
C: Client,
{
fn get_domain(&self) -> String {
self.client.get_domain()
}
fn get_account(&self) -> Result<Account, AcariError> {
self.cache_data("account.json", || self.client.get_account())
}
@ -77,27 +86,30 @@ impl Client for CachedClient {
self.cache_data("projects.json", || self.client.get_projects())
}
fn get_services(&self) -> Result<Vec<Service>, AcariError> {
self.cache_data("services.json", || self.client.get_services())
}
fn get_time_entry(&self, entry_id: TimeEntryId) -> Result<TimeEntry, AcariError> {
self.client.get_time_entry(entry_id) // This should not be cached
fn get_services(&self, project_id: &ProjectId) -> Result<Vec<Service>, AcariError> {
self.cache_data(&format!("services-{}.json", project_id), || self.client.get_services(project_id))
}
fn get_time_entries(&self, date_span: DateSpan) -> Result<Vec<TimeEntry>, AcariError> {
self.client.get_time_entries(date_span) // This should not be cached
}
fn create_time_entry(&self, day: Day, project_id: ProjectId, service_id: ServiceId, minutes: Minutes, note: Option<String>) -> Result<TimeEntry, AcariError> {
fn create_time_entry(
&self,
day: Day,
project_id: &ProjectId,
service_id: &ServiceId,
minutes: Minutes,
note: Option<String>,
) -> Result<TimeEntry, AcariError> {
self.client.create_time_entry(day, project_id, service_id, minutes, note)
}
fn update_time_entry(&self, entry_id: TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
fn update_time_entry(&self, entry_id: &TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
self.client.update_time_entry(entry_id, minutes, note)
}
fn delete_time_entry(&self, entry_id: TimeEntryId) -> Result<(), AcariError> {
fn delete_time_entry(&self, entry_id: &TimeEntryId) -> Result<(), AcariError> {
self.client.delete_time_entry(entry_id)
}
@ -105,16 +117,16 @@ impl Client for CachedClient {
self.client.get_tracker() // This should not be cached
}
fn create_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError> {
fn create_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
self.client.create_tracker(entry_id)
}
fn delete_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError> {
fn delete_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
self.client.delete_tracker(entry_id)
}
}
fn file_age(path: &PathBuf) -> Result<Option<Duration>, AcariError> {
fn file_age(path: &Path) -> Result<Option<Duration>, AcariError> {
match fs::metadata(path) {
Ok(metadata) => Ok(Some(metadata.modified()?.elapsed()?)),
Err(ref err) if err.kind() == io::ErrorKind::NotFound => Ok(None),

View File

@ -1,10 +1,11 @@
use std::error::Error;
use std::fmt;
use std::io;
use std::num;
#[derive(Debug)]
pub enum AcariError {
IO(io::Error),
Io(io::Error),
Time(std::time::SystemTimeError),
DateFormat(chrono::format::ParseError),
Request(reqwest::Error),
@ -13,12 +14,13 @@ pub enum AcariError {
Mite(u16, String),
UserError(String),
InternalError(String),
ParseNum(num::ParseIntError),
}
impl fmt::Display for AcariError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AcariError::IO(err) => write!(f, "IO error: {}", err),
AcariError::Io(err) => write!(f, "IO error: {}", err),
AcariError::Time(err) => write!(f, "Time error: {}", err),
AcariError::DateFormat(err) => write!(f, "Date format error: {}", err),
AcariError::Request(err) => write!(f, "Request error: {}", err),
@ -27,6 +29,7 @@ impl fmt::Display for AcariError {
AcariError::Mite(status, error) => write!(f, "Mite error ({}): {}", status, error),
AcariError::UserError(s) => write!(f, "User error: {}", s),
AcariError::InternalError(s) => write!(f, "Internal error: {}", s),
AcariError::ParseNum(err) => write!(f, "Number error: {}", err),
}
}
}
@ -34,11 +37,12 @@ impl fmt::Display for AcariError {
impl Error for AcariError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
AcariError::IO(err) => Some(err),
AcariError::Io(err) => Some(err),
AcariError::Time(err) => Some(err),
AcariError::DateFormat(err) => Some(err),
AcariError::Request(err) => Some(err),
AcariError::Json(err) => Some(err),
AcariError::ParseNum(err) => Some(err),
_ => None,
}
}
@ -54,9 +58,10 @@ macro_rules! acari_error_from {
};
}
acari_error_from!(io::Error, IO);
acari_error_from!(io::Error, Io);
acari_error_from!(std::time::SystemTimeError, Time);
acari_error_from!(serde_json::Error, Json);
acari_error_from!(url::ParseError, Url);
acari_error_from!(chrono::format::ParseError, DateFormat);
acari_error_from!(reqwest::Error, Request);
acari_error_from!(num::ParseIntError, ParseNum);

258
lib/src/everhour_client.rs Normal file
View File

@ -0,0 +1,258 @@
use crate::everhour_model::{
build_time_entry_id, date_span_query_param, parse_time_entry_id, EverhourCreateTimeRecord, EverhourError, EverhourTask, EverhourTimeEntry, EverhourTimer,
EverhourUser,
};
use crate::model::{Account, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, User};
use crate::query::{DateSpan, Day};
use crate::Client;
use crate::{error::AcariError, everhour_model::EverhourProject};
use chrono::Utc;
use reqwest::{blocking, header, Method, StatusCode};
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use serde_json::json;
use std::collections::HashMap;
use url::Url;
const USER_AGENT: &str = "acari-lib (https://github.com/untoldwind/acari)";
#[derive(Debug)]
pub struct EverhourClient {
base_url: Url,
client: blocking::Client,
}
impl EverhourClient {
pub fn new(domain: &str, token: &str) -> Result<EverhourClient, AcariError> {
Ok(Self::new_form_url(format!("https://{}@{}", token, domain).parse()?))
}
pub fn new_form_url(base_url: Url) -> EverhourClient {
EverhourClient {
base_url,
client: blocking::Client::new(),
}
}
fn base_request(&self, method: Method, uri: &str) -> Result<blocking::RequestBuilder, AcariError> {
Ok(
self
.client
.request(method, self.base_url.join(uri)?.as_str())
.header(header::USER_AGENT, USER_AGENT)
.header(header::HOST, self.base_url.host_str().unwrap_or(""))
.header("X-Api-Key", self.base_url.username()),
)
}
fn request<T: DeserializeOwned>(&self, method: Method, uri: &str) -> Result<T, AcariError> {
let response = self.base_request(method, uri)?.send()?;
Self::handle_response(response)
}
fn request_with_body<T: DeserializeOwned, D: Serialize>(&self, method: Method, uri: &str, data: D) -> Result<T, AcariError> {
let response = self.base_request(method, uri)?.json(&data).send()?;
Self::handle_response(response)
}
fn handle_response<T: DeserializeOwned>(response: blocking::Response) -> Result<T, AcariError> {
match response.status() {
StatusCode::OK | StatusCode::CREATED => Ok(response.json()?),
status => match response.json::<EverhourError>() {
Ok(err) => Err(AcariError::Mite(err.code, err.message)),
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
},
}
}
fn entry_from_timer(&self, timer: EverhourTimer) -> Result<Option<TimeEntry>, AcariError> {
match (timer.status.as_str(), timer.task, timer.user) {
("active", Some(task), Some(user)) => {
let maybe_project = match task.projects.get(0) {
Some(project_id) => Some(self.request::<EverhourProject>(Method::GET, &format!("/projects/{}", project_id.path_encoded()))?),
None => None,
};
Ok(Some(TimeEntry {
id: build_time_entry_id(&user.id, &task.id, &timer.started_at.naive_utc().date()),
date_at: timer.started_at.naive_utc().date(),
minutes: timer.duration,
customer_id: maybe_project.as_ref().map(|p| p.workspace_id.clone()).unwrap_or_default(),
customer_name: maybe_project.as_ref().map(|p| p.workspace_name.clone()).unwrap_or_default(),
project_id: maybe_project.as_ref().map(|p| p.id.clone()).unwrap_or_default(),
project_name: maybe_project.as_ref().map(|p| p.name.clone()).unwrap_or_default(),
service_id: task.id,
service_name: task.name,
user_id: user.id.clone(),
user_name: user.name.clone(),
note: timer.comment.unwrap_or_default(),
billable: true,
locked: false,
created_at: timer.started_at,
}))
}
_ => Ok(None),
}
}
}
impl Client for EverhourClient {
fn get_domain(&self) -> String {
self.base_url.host_str().unwrap_or("").to_owned()
}
fn get_account(&self) -> Result<Account, AcariError> {
Ok(self.request::<EverhourUser>(Method::GET, "/users/me")?.into())
}
fn get_myself(&self) -> Result<User, AcariError> {
Ok(self.request::<EverhourUser>(Method::GET, "/users/me")?.into())
}
fn get_customers(&self) -> Result<Vec<Customer>, AcariError> {
let projects = self.request::<Vec<EverhourProject>>(Method::GET, "/projects")?;
let mut customers_map: HashMap<CustomerId, Customer> = HashMap::new();
for project in projects {
let created_at = project.created_at;
let archived = project.status != "open";
let customer_ref = customers_map.entry(project.workspace_id.clone()).or_insert_with(|| project.into());
if created_at < customer_ref.created_at {
customer_ref.created_at = created_at;
}
if !archived {
customer_ref.archived = false;
}
}
Ok(customers_map.into_iter().map(|(_, v)| v).collect())
}
fn get_projects(&self) -> Result<Vec<Project>, AcariError> {
let projects = self.request::<Vec<EverhourProject>>(Method::GET, "/projects")?;
Ok(projects.into_iter().map(Into::into).collect())
}
fn get_services(&self, project_id: &ProjectId) -> Result<Vec<Service>, AcariError> {
let tasks = self.request::<Vec<EverhourTask>>(Method::GET, &format!("/projects/{}/tasks", project_id.path_encoded()))?;
Ok(tasks.into_iter().map(Into::into).collect())
}
fn get_time_entries(&self, date_span: DateSpan) -> Result<Vec<TimeEntry>, AcariError> {
let user = self.request::<EverhourUser>(Method::GET, "/users/me")?;
let project_map: HashMap<ProjectId, EverhourProject> = self
.request::<Vec<EverhourProject>>(Method::GET, "/projects")?
.into_iter()
.map(|p| (p.id.clone(), p))
.collect();
let entries = self.request::<Vec<EverhourTimeEntry>>(Method::GET, &format!("/users/me/time?{}", date_span_query_param(&date_span)))?;
Ok(entries.into_iter().filter_map(|e| e.into_entry(&project_map, &user)).collect())
}
fn create_time_entry(&self, day: Day, _: &ProjectId, service_id: &ServiceId, minutes: Minutes, note: Option<String>) -> Result<TimeEntry, AcariError> {
let user = self.request::<EverhourUser>(Method::GET, "/users/me")?;
let project_map: HashMap<ProjectId, EverhourProject> = self
.request::<Vec<EverhourProject>>(Method::GET, "/projects")?
.into_iter()
.map(|p| (p.id.clone(), p))
.collect();
let entry: EverhourTimeEntry = self.request_with_body(
Method::POST,
&format!("/tasks/{}/time", service_id.path_encoded()),
EverhourCreateTimeRecord {
date: day.as_date(),
user: user.id.clone(),
time: minutes,
comment: note,
},
)?;
entry
.into_entry(&project_map, &user)
.ok_or_else(|| AcariError::InternalError("Invalid time entry id (invalid parts)".to_string()))
}
fn update_time_entry(&self, entry_id: &TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
let (user_id, service_id, date) = parse_time_entry_id(entry_id)?;
let _: EverhourTimeEntry = self.request_with_body(
Method::POST,
&format!("/tasks/{}/time", service_id.path_encoded()),
EverhourCreateTimeRecord {
date,
user: user_id,
time: minutes,
comment: note,
},
)?;
Ok(())
}
fn delete_time_entry(&self, entry_id: &TimeEntryId) -> Result<(), AcariError> {
let (user_id, service_id, date) = parse_time_entry_id(entry_id)?;
let _: EverhourTimeEntry = self.request_with_body(
Method::POST,
&format!("/tasks/{}/time", service_id.path_encoded()),
json!({
"user": user_id,
"date": date,
}),
)?;
Ok(())
}
fn get_tracker(&self) -> Result<Tracker, AcariError> {
let timer = self.request::<EverhourTimer>(Method::GET, "/timers/current")?;
let started_at = timer.started_at;
match self.entry_from_timer(timer)? {
Some(time_entry) => Ok(Tracker {
since: Some(started_at),
tracking_time_entry: Some(time_entry),
stopped_time_entry: None,
}),
_ => Ok(Tracker {
since: None,
tracking_time_entry: None,
stopped_time_entry: None,
}),
}
}
fn create_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
let (_, service_id, date) = parse_time_entry_id(entry_id)?;
let timer: EverhourTimer = self.request_with_body(
Method::POST,
"/timers",
json!({
"task": service_id,
"userDate": date,
}),
)?;
Ok(Tracker {
since: Some(Utc::now()),
tracking_time_entry: self.entry_from_timer(timer)?,
stopped_time_entry: None,
})
}
fn delete_tracker(&self, _: &TimeEntryId) -> Result<Tracker, AcariError> {
let timer = self.request::<EverhourTimer>(Method::DELETE, "/timers/current")?;
Ok(Tracker {
since: None,
tracking_time_entry: None,
stopped_time_entry: self.entry_from_timer(timer)?,
})
}
}

View File

@ -0,0 +1,343 @@
use super::{Account, AccountId, Client, Customer, CustomerId, EverhourClient, Project, ProjectId, Service, ServiceId, User, UserId};
use chrono::{TimeZone, Utc};
use pact_consumer::prelude::*;
use pact_consumer::term;
use serde_json::json;
const CONSUMER: &str = "acari-lib";
const PROVIDER: &str = "everhour API";
#[test]
fn test_get_account() -> Result<(), Box<dyn std::error::Error>> {
let pact = PactBuilder::new(CONSUMER, PROVIDER)
.interaction("get account", |i| {
i.given("User with API token");
i.request.get().path("/users/me").header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(json!({
"id": 12345,
"name": "August Ausgedacht",
"email": "august.ausgedacht@demo.de",
"status": "active",
"role": "member",
"headline": "",
"isSuspended": false,
"createdAt": "2021-01-29 12:00:50",
"accounts": [{
}],
"team": {
"id": 1234,
"name": "Demo GmbH",
"createdAt": "2021-01-14 18:59:59",
"currencyDetails": {
"code": "EUR",
"name": "Euro",
"symbol": "",
"favorite": 2
}
},
}));
})
.build();
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = EverhourClient::new_form_url(url);
let account = client.get_account()?;
assert_eq!(
Account {
id: AccountId::Num(1234),
name: "Demo GmbH".to_string(),
title: "Demo GmbH".to_string(),
currency: "EUR".to_string(),
created_at: Utc.ymd(2021, 1, 14).and_hms(18, 59, 59),
},
account
);
Ok(())
}
#[test]
fn test_get_myself() -> Result<(), Box<dyn std::error::Error>> {
let pact = PactBuilder::new(CONSUMER, PROVIDER)
.interaction("get myself", |i| {
i.given("User with API token");
i.request.get().path("/users/me").header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(json!({
"id": 12345,
"name": "August Ausgedacht",
"email": "august.ausgedacht@demo.de",
"status": "active",
"role": "member",
"headline": "",
"isSuspended": false,
"createdAt": "2021-01-29 12:00:50",
"accounts": [{
}],
"team": {
"id": 1234,
"name": "Demo GmbH",
"createdAt": "2021-01-14 18:59:59",
"currencyDetails": {
"code": "EUR",
"name": "Euro",
"symbol": "",
"favorite": 2
}
},
}));
})
.build();
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = EverhourClient::new_form_url(url);
let user = client.get_myself()?;
assert_eq!(
User {
id: UserId::Num(12345),
name: "August Ausgedacht".to_string(),
email: "august.ausgedacht@demo.de".to_string(),
note: "".to_string(),
archived: false,
role: "member".to_string(),
language: "".to_string(),
created_at: Utc.ymd(2021, 1, 29).and_hms(12, 0, 50),
},
user
);
Ok(())
}
#[test]
fn test_get_customers() -> Result<(), Box<dyn std::error::Error>> {
let pact = PactBuilder::new(CONSUMER, PROVIDER)
.interaction("get projects", |i| {
i.given("User with API token");
i.request.get().path("/projects").header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(json!([{
"id": "as:12345",
"platform": "as",
"name": "Project 1",
"createdAt": "2021-01-14",
"workspaceId": "as:54321",
"workspaceName": "Workspace 1",
"foreign": false,
"status": "archived",
"estimatesType": "any",
}, {
"id": "as:12346",
"platform": "as",
"name": "Project 2",
"createdAt": "2021-01-15",
"workspaceId": "as:54322",
"workspaceName": "Workspace 2",
"foreign": false,
"status": "open",
"estimatesType": "any",
}, {
"id": "as:12347",
"platform": "as",
"name": "Project 3",
"createdAt": "2021-01-16",
"workspaceId": "as:54321",
"workspaceName": "Workspace 1",
"foreign": false,
"status": "open",
"estimatesType": "any",
}]));
})
.build();
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = EverhourClient::new_form_url(url);
let mut customers = client.get_customers()?;
customers.sort_by(|c1, c2| c1.name.cmp(&c2.name));
assert_eq!(customers.len(), 2);
assert_eq!(
Customer {
id: CustomerId::Str("as:54321".to_string()),
name: "Workspace 1".to_string(),
note: "".to_string(),
archived: false,
created_at: Utc.ymd(2021, 01, 14).and_hms(00, 00, 00),
},
customers[0]
);
assert_eq!(
Customer {
id: CustomerId::Str("as:54322".to_string()),
name: "Workspace 2".to_string(),
note: "".to_string(),
archived: false,
created_at: Utc.ymd(2021, 01, 15).and_hms(00, 00, 00),
},
customers[1]
);
Ok(())
}
#[test]
fn test_get_projects() -> Result<(), Box<dyn std::error::Error>> {
let pact = PactBuilder::new(CONSUMER, PROVIDER)
.interaction("get projects", |i| {
i.given("User with API token");
i.request.get().path("/projects").header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(json!([{
"id": "as:12345",
"platform": "as",
"name": "Project 1",
"createdAt": "2021-01-14",
"workspaceId": "as:54321",
"workspaceName": "Workspace 1",
"foreign": false,
"status": "archived",
"estimatesType": "any",
}, {
"id": "as:12346",
"platform": "as",
"name": "Project 2",
"createdAt": "2021-01-15",
"workspaceId": "as:54322",
"workspaceName": "Workspace 2",
"foreign": false,
"status": "open",
"estimatesType": "any",
}, {
"id": "as:12347",
"platform": "as",
"name": "Project 3",
"createdAt": "2021-01-16",
"workspaceId": "as:54321",
"workspaceName": "Workspace 1",
"foreign": false,
"status": "open",
"estimatesType": "any",
}]));
})
.build();
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = EverhourClient::new_form_url(url);
let projects = client.get_projects()?;
assert_eq!(projects.len(), 3);
assert_eq!(
Project {
id: ProjectId::Str("as:12345".to_string()),
name: "Project 1".to_string(),
note: "".to_string(),
customer_id: CustomerId::Str("as:54321".to_string()),
customer_name: "Workspace 1".to_string(),
archived: true,
created_at: Utc.ymd(2021, 01, 14).and_hms(00, 00, 00),
},
projects[0]
);
assert_eq!(
Project {
id: ProjectId::Str("as:12346".to_string()),
name: "Project 2".to_string(),
note: "".to_string(),
customer_id: CustomerId::Str("as:54322".to_string()),
customer_name: "Workspace 2".to_string(),
archived: false,
created_at: Utc.ymd(2021, 01, 15).and_hms(00, 00, 00),
},
projects[1]
);
assert_eq!(
Project {
id: ProjectId::Str("as:12347".to_string()),
name: "Project 3".to_string(),
note: "".to_string(),
customer_id: CustomerId::Str("as:54321".to_string()),
customer_name: "Workspace 1".to_string(),
archived: false,
created_at: Utc.ymd(2021, 01, 16).and_hms(00, 00, 00),
},
projects[2]
);
Ok(())
}
#[test]
fn test_get_services() -> Result<(), Box<dyn std::error::Error>> {
let pact = PactBuilder::new(CONSUMER, PROVIDER)
.interaction("get project tasks", |i| {
i.given("User with API token");
i.request
.get()
.path("/projects/as%3A12345/tasks")
.header("X-Api-Key", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(json!([{
"id": "as:123451234",
"name": "Task 1",
"iteration": "Untitled section",
"createdAt": "2021-01-18 12:55:55",
"status": "closed",
"projects": [
"as:8353429"
],
}, {
"id": "as:123451235",
"name": "Task 2",
"iteration": "Untitled section",
"createdAt": "2021-01-25 11:54:28",
"status": "open",
"projects": [
"as:8353429"
],
}]));
})
.build();
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = EverhourClient::new_form_url(url);
let services = client.get_services(&ProjectId::Str("as:12345".to_string()))?;
assert_eq!(services.len(), 2);
assert_eq!(
Service {
id: ServiceId::Str("as:123451234".to_string()),
name: "Task 1".to_string(),
note: "Untitled section".to_string(),
archived: true,
billable: true,
created_at: Utc.ymd(2021, 01, 18).and_hms(12, 55, 55),
},
services[0]
);
assert_eq!(
Service {
id: ServiceId::Str("as:123451235".to_string()),
name: "Task 2".to_string(),
note: "Untitled section".to_string(),
archived: false,
billable: true,
created_at: Utc.ymd(2021, 01, 25).and_hms(11, 54, 28),
},
services[1]
);
Ok(())
}

341
lib/src/everhour_model.rs Normal file
View File

@ -0,0 +1,341 @@
use std::collections::HashMap;
use crate::{
model::{Account, AccountId, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, User, UserId},
AcariError, DateSpan, Day,
};
use chrono::offset::Local;
use chrono::{DateTime, Datelike, NaiveDate, Utc, Weekday};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct EverhourError {
pub code: u16,
pub message: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct EverhourCurrency {
pub code: String,
pub name: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourTeam {
pub id: AccountId,
pub name: String,
pub currency_details: EverhourCurrency,
#[serde(with = "date_format")]
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourUser {
pub id: UserId,
pub name: String,
pub email: String,
pub status: String,
pub role: String,
pub headline: String,
pub is_suspended: bool,
pub team: EverhourTeam,
#[serde(with = "date_format")]
pub created_at: DateTime<Utc>,
}
impl From<EverhourUser> for Account {
fn from(f: EverhourUser) -> Self {
Account {
id: f.team.id,
name: f.team.name.clone(),
title: f.team.name,
currency: f.team.currency_details.code,
created_at: f.team.created_at,
}
}
}
impl From<EverhourUser> for User {
fn from(f: EverhourUser) -> Self {
User {
id: f.id,
name: f.name,
email: f.email,
role: f.role,
note: f.headline,
language: "".to_string(),
archived: f.is_suspended,
created_at: f.created_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourProject {
pub id: ProjectId,
pub name: String,
pub workspace_id: CustomerId,
pub workspace_name: String,
pub status: String,
#[serde(with = "date_format")]
pub created_at: DateTime<Utc>,
}
impl From<EverhourProject> for Customer {
fn from(f: EverhourProject) -> Self {
Customer {
id: f.workspace_id,
name: f.workspace_name,
note: "".to_string(),
archived: f.status != "open",
created_at: f.created_at,
}
}
}
impl From<EverhourProject> for Project {
fn from(f: EverhourProject) -> Self {
Project {
id: f.id,
name: f.name,
note: "".to_string(),
customer_id: f.workspace_id,
customer_name: f.workspace_name,
archived: f.status != "open",
created_at: f.created_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourTask {
pub id: ServiceId,
pub name: String,
pub status: String,
pub iteration: String,
pub projects: Vec<ProjectId>,
#[serde(with = "date_format")]
pub created_at: DateTime<Utc>,
}
impl From<EverhourTask> for Service {
fn from(f: EverhourTask) -> Self {
Service {
id: f.id,
name: f.name,
note: f.iteration,
archived: f.status != "open",
billable: true,
created_at: f.created_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourTimeEntry {
pub date: NaiveDate,
#[serde(default)]
pub comment: String,
pub task: Option<EverhourTask>,
#[serde(with = "minutes_in_seconds")]
pub time: Minutes,
pub user: UserId,
pub is_locked: bool,
#[serde(with = "date_format")]
pub created_at: DateTime<Utc>,
}
impl EverhourTimeEntry {
pub fn into_entry(self, project_map: &HashMap<ProjectId, EverhourProject>, user: &EverhourUser) -> Option<TimeEntry> {
match self.task {
Some(task) => match task.projects.iter().filter_map(|p| project_map.get(p)).next() {
Some(project) => Some(TimeEntry {
id: build_time_entry_id(&self.user, &task.id, &self.date),
date_at: self.date,
minutes: self.time,
customer_id: project.workspace_id.clone(),
customer_name: project.workspace_name.clone(),
project_id: project.id.clone(),
project_name: project.name.clone(),
service_id: task.id,
service_name: task.name,
user_id: user.id.clone(),
user_name: user.name.clone(),
note: self.comment,
billable: true,
locked: self.is_locked,
created_at: self.created_at,
}),
_ => None,
},
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourCreateTimeRecord {
pub date: NaiveDate,
#[serde(with = "minutes_in_seconds")]
pub time: Minutes,
pub user: UserId,
pub comment: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourUserSimple {
pub id: UserId,
pub name: String,
pub email: String,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct EverhourTimer {
pub status: String,
pub task: Option<EverhourTask>,
pub user: Option<EverhourUserSimple>,
#[serde(with = "minutes_in_seconds", default)]
pub duration: Minutes,
#[serde(with = "date_format", default = "default_started_at")]
pub started_at: DateTime<Utc>,
pub comment: Option<String>,
}
fn default_started_at() -> DateTime<Utc> {
Utc::now()
}
pub fn day_query_param(day: &Day) -> String {
match day {
Day::Today => format!("{}", Local::now().naive_local().date()),
Day::Yesterday => format!("{}", Local::now().naive_local().date().pred()),
Day::Date(date) => format!("{}", date),
}
}
pub fn date_span_query_param(span: &DateSpan) -> String {
match span {
DateSpan::ThisWeek => {
let now = Local::now().naive_local().date();
let year = now.year();
let week = now.iso_week().week();
let mon = NaiveDate::from_isoywd(year, week, Weekday::Mon);
let sun = NaiveDate::from_isoywd(year, week, Weekday::Sun);
format!("from={}&to={}", mon, sun)
}
DateSpan::LastWeek => {
let now = Local::now().naive_local().date();
let sun = NaiveDate::from_isoywd(now.year(), now.iso_week().week(), Weekday::Mon).succ();
let mon = NaiveDate::from_isoywd(sun.year(), sun.iso_week().week(), Weekday::Mon);
format!("from={}&to={}", mon, sun)
}
DateSpan::ThisMonth => {
let now = Local::now().naive_local().date();
let year = now.year();
let month = now.month();
let start = NaiveDate::from_ymd(year, month, 1);
let end = if month == 12 {
NaiveDate::from_ymd(year + 1, 1, 1).pred()
} else {
NaiveDate::from_ymd(year, month + 1, 1).pred()
};
format!("from={}&to={}", start, end)
}
DateSpan::LastMonth => {
let now = Local::now().naive_local().date();
let end = NaiveDate::from_ymd(now.year(), now.month(), 1).pred();
let year = end.year();
let month = end.month();
let start = if month == 1 {
NaiveDate::from_ymd(year - 1, 12, 1)
} else {
NaiveDate::from_ymd(year, month - 1, 1)
};
format!("from={}&to={}", start, end)
}
DateSpan::Day(date) => format!("from={}&to={}", day_query_param(&date), day_query_param(&date)),
DateSpan::FromTo(from, to) => format!("from={}&to={}", from, to),
}
}
pub fn build_time_entry_id(user_id: &UserId, service_id: &ServiceId, date: &NaiveDate) -> TimeEntryId {
TimeEntryId::Str(format!("{}|{}|{}", user_id.str_encoded(), service_id.str_encoded(), date))
}
pub fn parse_time_entry_id(time_entry_id: &TimeEntryId) -> Result<(UserId, ServiceId, NaiveDate), AcariError> {
let parts: Vec<&str> = match time_entry_id {
TimeEntryId::Str(s) => s.split('|').collect(),
_ => return Err(AcariError::InternalError("Invalid time entry id (no number)".to_string())),
};
if parts.len() != 3 {
return Err(AcariError::InternalError("Invalid time entry id (invalid parts)".to_string()));
}
let user_id = UserId::parse_encoded(&parts[0])?;
let service_id = ServiceId::parse_encoded(&parts[1])?;
let date = NaiveDate::parse_from_str(parts[2], "%Y-%m-%d")?;
Ok((user_id, service_id, date))
}
mod date_format {
use chrono::{DateTime, TimeZone, Utc};
use serde::{self, Deserialize, Deserializer, Serializer};
const FORMAT: &str = "%Y-%m-%d %H:%M:%S";
pub fn serialize<S>(date: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s = format!("{}", date.format(FORMAT));
serializer.serialize_str(&s)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Utc
.datetime_from_str(&s, FORMAT)
.or_else(|_| Utc.datetime_from_str(&format!("{} 00:00:00", &s), FORMAT))
.map_err(serde::de::Error::custom)
}
}
mod minutes_in_seconds {
use crate::model::Minutes;
use serde::{self, Deserialize, Deserializer, Serializer};
pub fn serialize<S>(minutes: &Minutes, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_u32(minutes.0 * 60)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<Minutes, D::Error>
where
D: Deserializer<'de>,
{
let seconds = u32::deserialize(deserializer)?;
Ok(Minutes(seconds / 60))
}
}

View File

@ -1,21 +1,29 @@
mod cached_client;
mod error;
mod everhour_client;
mod everhour_model;
mod mite_client;
mod mite_model;
mod model;
mod query;
mod std_client;
pub use cached_client::CachedClient;
pub use cached_client::{clear_cache, CachedClient};
pub use error::AcariError;
pub use model::{
Account, AccountId, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, TrackingTimeEntry, User, UserId,
};
pub use everhour_client::EverhourClient;
pub use mite_client::MiteClient;
pub use model::{Account, Customer, Minutes, Project, Service, TimeEntry, Tracker, User};
pub use model::{AccountId, CustomerId, ProjectId, ServiceId, TimeEntryId, UserId};
pub use query::{DateSpan, Day};
pub use std_client::StdClient;
#[cfg(test)]
mod tests;
mod mite_client_tests;
#[cfg(test)]
mod everhour_client_tests;
pub trait Client {
fn get_domain(&self) -> String;
fn get_account(&self) -> Result<Account, AcariError>;
fn get_myself(&self) -> Result<User, AcariError>;
@ -24,23 +32,28 @@ pub trait Client {
fn get_projects(&self) -> Result<Vec<Project>, AcariError>;
fn get_services(&self) -> Result<Vec<Service>, AcariError>;
fn get_time_entry(&self, entry_id: TimeEntryId) -> Result<TimeEntry, AcariError>;
fn get_services(&self, project_id: &ProjectId) -> Result<Vec<Service>, AcariError>;
fn get_time_entries(&self, date_span: DateSpan) -> Result<Vec<TimeEntry>, AcariError>;
fn create_time_entry(&self, day: Day, project_id: ProjectId, service_id: ServiceId, minutes: Minutes, note: Option<String>) -> Result<TimeEntry, AcariError>;
fn create_time_entry(
&self,
day: Day,
project_id: &ProjectId,
service_id: &ServiceId,
minutes: Minutes,
note: Option<String>,
) -> Result<TimeEntry, AcariError>;
fn update_time_entry(&self, entry_id: TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError>;
fn update_time_entry(&self, entry_id: &TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError>;
fn delete_time_entry(&self, entry_id: TimeEntryId) -> Result<(), AcariError>;
fn delete_time_entry(&self, entry_id: &TimeEntryId) -> Result<(), AcariError>;
fn get_tracker(&self) -> Result<Tracker, AcariError>;
fn create_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError>;
fn create_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError>;
fn delete_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError>;
fn delete_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError>;
}
#[macro_export]

View File

@ -1,30 +1,30 @@
use crate::error::AcariError;
use crate::model::{Account, Customer, Minutes, MiteEntity, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, User};
use crate::mite_model::{date_span_query_param, MiteEntity, MiteTracker};
use crate::model::{Account, Customer, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, Tracker, User};
use crate::query::{DateSpan, Day};
use crate::Client;
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::ser::Serialize;
use serde_json::json;
use url::Url;
use reqwest::{blocking, header, StatusCode};
use reqwest::{blocking, header, Method, StatusCode};
const USER_AGENT: &str = "acari-lib (https://github.com/untoldwind/acari)";
#[derive(Debug)]
pub struct StdClient {
pub struct MiteClient {
base_url: Url,
client: blocking::Client,
}
impl StdClient {
pub fn new(domain: &str, token: &str) -> Result<StdClient, AcariError> {
impl MiteClient {
pub fn new(domain: &str, token: &str) -> Result<MiteClient, AcariError> {
Ok(Self::new_form_url(format!("https://{}@{}", token, domain).parse()?))
}
pub fn new_form_url(base_url: Url) -> StdClient {
StdClient {
pub fn new_form_url(base_url: Url) -> MiteClient {
MiteClient {
base_url,
client: blocking::Client::new(),
}
@ -44,39 +44,81 @@ impl StdClient {
fn request<T: DeserializeOwned>(&self, method: Method, uri: &str) -> Result<T, AcariError> {
let response = self.base_request(method, uri)?.send()?;
handle_response(response)
Self::handle_response(response)
}
fn request_empty(&self, method: Method, uri: &str) -> Result<(), AcariError> {
let response = self.base_request(method, uri)?.send()?;
handle_empty_response(response)
Self::handle_empty_response(response)
}
fn request_with_body<T: DeserializeOwned, D: Serialize>(&self, method: Method, uri: &str, data: D) -> Result<T, AcariError> {
let response = self.base_request(method, uri)?.json(&data).send()?;
handle_response(response)
Self::handle_response(response)
}
fn request_empty_with_body<D: Serialize>(&self, method: Method, uri: &str, data: D) -> Result<(), AcariError> {
let response = self.base_request(method, uri)?.json(&data).send()?;
handle_empty_response(response)
Self::handle_empty_response(response)
}
fn handle_empty_response(response: blocking::Response) -> Result<(), AcariError> {
match response.status() {
StatusCode::OK | StatusCode::CREATED => Ok(()),
status => match response.json::<MiteEntity>() {
Ok(MiteEntity::Error(msg)) => Err(AcariError::Mite(status.as_u16(), msg)),
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
},
}
}
fn handle_response<T: DeserializeOwned>(response: blocking::Response) -> Result<T, AcariError> {
match response.status() {
StatusCode::OK | StatusCode::CREATED => Ok(response.json()?),
status => match response.json::<MiteEntity>() {
Ok(MiteEntity::Error(msg)) => Err(AcariError::Mite(status.as_u16(), msg)),
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
},
}
}
fn get_time_entry(&self, entry_id: &TimeEntryId) -> Result<TimeEntry, AcariError> {
match self.request(Method::GET, &format!("/time_entries/{}.json", entry_id))? {
MiteEntity::TimeEntry(time_entry) => Ok(time_entry.into()),
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
}
}
fn convert_tracker(&self, tracker: MiteTracker) -> Result<Tracker, AcariError> {
let tracking_time_entry = tracker.tracking_time_entry.as_ref().map(|e| self.get_time_entry(&e.id)).transpose()?;
let stopped_time_entry = tracker.stopped_time_entry.as_ref().map(|e| self.get_time_entry(&e.id)).transpose()?;
Ok(Tracker {
since: tracker.tracking_time_entry.and_then(|e| e.since),
tracking_time_entry,
stopped_time_entry,
})
}
}
impl Client for StdClient {
impl Client for MiteClient {
fn get_domain(&self) -> String {
self.base_url.host_str().unwrap_or("").to_owned()
}
fn get_account(&self) -> Result<Account, AcariError> {
match self.request(Method::GET, "/account.json")? {
MiteEntity::Account(account) => Ok(account),
MiteEntity::Account(account) => Ok(account.into()),
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
}
}
fn get_myself(&self) -> Result<User, AcariError> {
match self.request(Method::GET, "/myself.json")? {
MiteEntity::User(user) => Ok(user),
MiteEntity::User(user) => Ok(user.into()),
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
}
}
@ -87,7 +129,7 @@ impl Client for StdClient {
.request::<Vec<MiteEntity>>(Method::GET, "/customers.json")?
.into_iter()
.filter_map(|entity| match entity {
MiteEntity::Customer(customer) => Some(customer),
MiteEntity::Customer(customer) => Some(customer.into()),
_ => None,
})
.collect(),
@ -100,47 +142,47 @@ impl Client for StdClient {
.request::<Vec<MiteEntity>>(Method::GET, "/projects.json")?
.into_iter()
.filter_map(|entity| match entity {
MiteEntity::Project(project) => Some(project),
MiteEntity::Project(project) => Some(project.into()),
_ => None,
})
.collect(),
)
}
fn get_services(&self) -> Result<Vec<Service>, AcariError> {
fn get_services(&self, _: &ProjectId) -> Result<Vec<Service>, AcariError> {
Ok(
self
.request::<Vec<MiteEntity>>(Method::GET, "/services.json")?
.into_iter()
.filter_map(|entity| match entity {
MiteEntity::Service(service) => Some(service),
MiteEntity::Service(service) => Some(service.into()),
_ => None,
})
.collect(),
)
}
fn get_time_entry(&self, entry_id: TimeEntryId) -> Result<TimeEntry, AcariError> {
match self.request(Method::GET, &format!("/time_entries/{}.json", entry_id))? {
MiteEntity::TimeEntry(time_entry) => Ok(time_entry),
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
}
}
fn get_time_entries(&self, date_span: DateSpan) -> Result<Vec<TimeEntry>, AcariError> {
Ok(
self
.request::<Vec<MiteEntity>>(Method::GET, &format!("/time_entries.json?user=current&{}", date_span.query_param()))?
.request::<Vec<MiteEntity>>(Method::GET, &format!("/time_entries.json?user=current&{}", date_span_query_param(&date_span)))?
.into_iter()
.filter_map(|entity| match entity {
MiteEntity::TimeEntry(time_entry) => Some(time_entry),
MiteEntity::TimeEntry(time_entry) => Some(time_entry.into()),
_ => None,
})
.collect(),
)
}
fn create_time_entry(&self, day: Day, project_id: ProjectId, service_id: ServiceId, minutes: Minutes, note: Option<String>) -> Result<TimeEntry, AcariError> {
fn create_time_entry(
&self,
day: Day,
project_id: &ProjectId,
service_id: &ServiceId,
minutes: Minutes,
note: Option<String>,
) -> Result<TimeEntry, AcariError> {
match self.request_with_body(
Method::POST,
"/time_entries.json",
@ -154,12 +196,12 @@ impl Client for StdClient {
}
}),
)? {
MiteEntity::TimeEntry(time_entry) => Ok(time_entry),
MiteEntity::TimeEntry(time_entry) => Ok(time_entry.into()),
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
}
}
fn update_time_entry(&self, entry_id: TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
fn update_time_entry(&self, entry_id: &TimeEntryId, minutes: Minutes, note: Option<String>) -> Result<(), AcariError> {
self.request_empty_with_body(
Method::PATCH,
&format!("/time_entries/{}.json", entry_id),
@ -172,48 +214,28 @@ impl Client for StdClient {
)
}
fn delete_time_entry(&self, entry_id: TimeEntryId) -> Result<(), AcariError> {
fn delete_time_entry(&self, entry_id: &TimeEntryId) -> Result<(), AcariError> {
self.request_empty(Method::DELETE, &format!("/time_entries/{}.json", entry_id))
}
fn get_tracker(&self) -> Result<Tracker, AcariError> {
match self.request(Method::GET, "/tracker.json")? {
MiteEntity::Tracker(tracker) => Ok(tracker),
MiteEntity::Tracker(tracker) => Ok(self.convert_tracker(tracker)?),
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
}
}
fn create_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError> {
fn create_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
match self.request(Method::PATCH, &format!("/tracker/{}.json", entry_id))? {
MiteEntity::Tracker(tracker) => Ok(tracker),
MiteEntity::Tracker(tracker) => Ok(self.convert_tracker(tracker)?),
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
}
}
fn delete_tracker(&self, entry_id: TimeEntryId) -> Result<Tracker, AcariError> {
fn delete_tracker(&self, entry_id: &TimeEntryId) -> Result<Tracker, AcariError> {
match self.request(Method::DELETE, &format!("/tracker/{}.json", entry_id))? {
MiteEntity::Tracker(tracker) => Ok(tracker),
MiteEntity::Tracker(tracker) => Ok(self.convert_tracker(tracker)?),
response => Err(AcariError::Mite(400, format!("Unexpected response: {:?}", response))),
}
}
}
fn handle_empty_response(response: blocking::Response) -> Result<(), AcariError> {
match response.status() {
StatusCode::OK | StatusCode::CREATED => Ok(()),
status => match response.json::<MiteEntity>() {
Ok(MiteEntity::Error(msg)) => Err(AcariError::Mite(status.as_u16(), msg)),
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
},
}
}
fn handle_response<T: DeserializeOwned>(response: blocking::Response) -> Result<T, AcariError> {
match response.status() {
StatusCode::OK | StatusCode::CREATED => Ok(response.json()?),
status => match response.json::<MiteEntity>() {
Ok(MiteEntity::Error(msg)) => Err(AcariError::Mite(status.as_u16(), msg)),
_ => Err(AcariError::Mite(status.as_u16(), status.to_string())),
},
}
}

View File

@ -5,8 +5,8 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use super::{
Account, AccountId, Client, Customer, CustomerId, DateSpan, Day, Minutes, Project, ProjectId, Service, ServiceId, StdClient, TimeEntry, TimeEntryId, Tracker,
TrackingTimeEntry, User, UserId,
Account, AccountId, Client, Customer, CustomerId, DateSpan, Day, Minutes, MiteClient, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId,
Tracker, User, UserId,
};
const CONSUMER: &str = "acari-lib";
@ -33,18 +33,17 @@ fn test_get_account() -> Result<(), Box<dyn std::error::Error>> {
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let account = client.get_account()?;
assert_eq!(
Account {
id: AccountId(1),
id: AccountId::Num(1),
name: "demo".to_string(),
title: "Demo GmbH".to_string(),
currency: "EUR".to_string(),
created_at: Utc.ymd(2013, 10, 12).and_hms(13, 39, 51),
updated_at: Utc.ymd(2015, 5, 2).and_hms(12, 21, 09),
},
account
);
@ -76,13 +75,13 @@ fn test_get_myself() -> Result<(), Box<dyn std::error::Error>> {
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let user = client.get_myself()?;
assert_eq!(
User {
id: UserId(3456),
id: UserId::Num(3456),
name: "August Ausgedacht".to_string(),
email: "august.ausgedacht@demo.de".to_string(),
note: "".to_string(),
@ -90,7 +89,6 @@ fn test_get_myself() -> Result<(), Box<dyn std::error::Error>> {
role: "admin".to_string(),
language: "de".to_string(),
created_at: Utc.ymd(2013, 6, 23).and_hms(21, 0, 58),
updated_at: Utc.ymd(2015, 7, 24).and_hms(23, 26, 35),
},
user
);
@ -132,20 +130,18 @@ fn test_get_customers() -> Result<(), Box<dyn std::error::Error>> {
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let customers = client.get_customers()?;
assert_eq!(customers.len(), 1);
assert_eq!(
Customer {
id: CustomerId(83241),
id: CustomerId::Num(83241),
name: "Acme Inc.".to_string(),
note: "".to_string(),
archived: false,
hourly_rate: None,
created_at: Utc.ymd(2015, 10, 15).and_hms(12, 33, 19),
updated_at: Utc.ymd(2015, 10, 15).and_hms(12, 29, 03)
},
customers[0]
);
@ -191,24 +187,20 @@ fn test_get_projects() -> Result<(), Box<dyn std::error::Error>> {
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let projects = client.get_projects()?;
assert_eq!(projects.len(), 1);
assert_eq!(
Project {
id: ProjectId(643),
id: ProjectId::Num(643),
name: "Open-Source".to_string(),
note: "valvat, memento et all.".to_string(),
customer_id: CustomerId(291),
customer_id: CustomerId::Num(291),
customer_name: "Yolk".to_string(),
budget: 0,
budget_type: "minutes".to_string(),
hourly_rate: Some(6000),
archived: false,
created_at: Utc.ymd(2011, 08, 17).and_hms(10, 06, 57),
updated_at: Utc.ymd(2015, 02, 19).and_hms(09, 53, 10),
},
projects[0]
);
@ -240,21 +232,19 @@ fn test_get_services() -> Result<(), Box<dyn std::error::Error>> {
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let services = client.get_services()?;
let services = client.get_services(&ProjectId::Num(0))?;
assert_eq!(services.len(), 1);
assert_eq!(
Service {
id: ServiceId(38672),
id: ServiceId::Num(38672),
name: "Website Konzeption".to_string(),
note: "".to_string(),
archived: false,
billable: true,
hourly_rate: Some(3300),
created_at: Utc.ymd(2009, 12, 13).and_hms(11, 12, 00),
updated_at: Utc.ymd(2015, 12, 13).and_hms(06, 20, 04)
},
services[0]
);
@ -287,23 +277,21 @@ fn test_query_entries() -> Result<(), Box<dyn std::error::Error>> {
}
});
let expected = TimeEntry {
id: TimeEntryId(36159117),
id: TimeEntryId::Num(36159117),
minutes: Minutes(15),
date_at: NaiveDate::from_ymd(2015, 10, 16),
note: "Feedback einarbeiten".to_string(),
locked: false,
billable: true,
hourly_rate: 0,
user_id: UserId(211),
user_id: UserId::Num(211),
user_name: "Fridolin Frei".to_string(),
customer_id: CustomerId(3213),
customer_id: CustomerId::Num(3213),
customer_name: "König".to_string(),
service_id: ServiceId(12984),
service_id: ServiceId::Num(12984),
service_name: "Entwurf".to_string(),
project_id: ProjectId(88309),
project_id: ProjectId::Num(88309),
project_name: "API v2".to_string(),
created_at: Utc.ymd(2015, 10, 16).and_hms(10, 19, 00),
updated_at: Utc.ymd(2015, 10, 16).and_hms(10, 39, 00),
};
let pact = PactBuilder::new(CONSUMER, PROVIDER)
@ -317,30 +305,18 @@ fn test_query_entries() -> Result<(), Box<dyn std::error::Error>> {
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(json!([time_entry_json]));
})
.interaction("get time entry by id", |i| {
i.given("User with API token");
i.request
.get()
.path("/time_entries/36159117.json")
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(time_entry_json);
})
.build();
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let entries = client.get_time_entries(DateSpan::Day(Day::Date(NaiveDate::from_ymd(2015, 10, 16))))?;
assert_eq!(entries.len(), 1);
assert_eq!(expected, entries[0]);
let entry = client.get_time_entry(TimeEntryId(36159117))?;
assert_eq!(expected, entry);
Ok(())
}
@ -390,29 +366,33 @@ fn test_create_entry() -> Result<(), Box<dyn std::error::Error>> {
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let entry = client.create_time_entry(Day::Date(NaiveDate::from_ymd(2015, 9, 15)), ProjectId(3456), ServiceId(243), Minutes(185), None)?;
let entry = client.create_time_entry(
Day::Date(NaiveDate::from_ymd(2015, 9, 15)),
&ProjectId::Num(3456),
&ServiceId::Num(243),
Minutes(185),
None,
)?;
assert_eq!(
TimeEntry {
id: TimeEntryId(52324),
id: TimeEntryId::Num(52324),
minutes: Minutes(185),
date_at: NaiveDate::from_ymd(2015, 9, 12),
note: "".to_string(),
locked: false,
billable: true,
hourly_rate: 0,
user_id: UserId(211),
user_id: UserId::Num(211),
user_name: "Fridolin Frei".to_string(),
customer_id: CustomerId(3213),
customer_id: CustomerId::Num(3213),
customer_name: "König".to_string(),
service_id: ServiceId(243),
service_id: ServiceId::Num(243),
service_name: "Dokumentation".to_string(),
project_id: ProjectId(3456),
project_id: ProjectId::Num(3456),
project_name: "Some project".to_string(),
created_at: Utc.ymd(2015, 9, 13).and_hms(16, 54, 45),
updated_at: Utc.ymd(2015, 9, 13).and_hms(16, 54, 45),
},
entry
);
@ -436,9 +416,9 @@ fn test_delete_entry() -> Result<(), Box<dyn std::error::Error>> {
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
client.delete_time_entry(TimeEntryId(52324))?;
client.delete_time_entry(&TimeEntryId::Num(52324))?;
Ok(())
}
@ -465,15 +445,55 @@ fn test_update_entry() -> Result<(), Box<dyn std::error::Error>> {
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
client.update_time_entry(TimeEntryId(52324), Minutes(120), None)?;
client.update_time_entry(&TimeEntryId::Num(52324), Minutes(120), None)?;
Ok(())
}
#[test]
fn test_get_tracker() -> Result<(), Box<dyn std::error::Error>> {
let time_entry_json = json!({
"time_entry": {
"id": 36159117,
"minutes": 15,
"date_at": "2015-10-16",
"note": "Feedback einarbeiten",
"billable": true,
"locked": false,
"revenue": null,
"hourly_rate": 0,
"user_id": 211,
"user_name": "Fridolin Frei",
"project_id": 88309,
"project_name": "API v2",
"customer_id": 3213,
"customer_name": "König",
"service_id": 12984,
"service_name": "Entwurf",
"created_at": "2015-10-16T12:19:00+02:00",
"updated_at": "2015-10-16T12:39:00+02:00"
}
});
let expected = TimeEntry {
id: TimeEntryId::Num(36159117),
minutes: Minutes(15),
date_at: NaiveDate::from_ymd(2015, 10, 16),
note: "Feedback einarbeiten".to_string(),
locked: false,
billable: true,
user_id: UserId::Num(211),
user_name: "Fridolin Frei".to_string(),
customer_id: CustomerId::Num(3213),
customer_name: "König".to_string(),
service_id: ServiceId::Num(12984),
service_name: "Entwurf".to_string(),
project_id: ProjectId::Num(88309),
project_name: "API v2".to_string(),
created_at: Utc.ymd(2015, 10, 16).and_hms(10, 19, 00),
};
let pact = PactBuilder::new(CONSUMER, PROVIDER)
.interaction("get tracker", |i| {
i.given("User with API token");
@ -488,22 +508,27 @@ fn test_get_tracker() -> Result<(), Box<dyn std::error::Error>> {
}
}));
})
.interaction("get tracker time entry by id 1", |i| {
i.given("User with API token");
i.request
.get()
.path("/time_entries/36135321.json")
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(time_entry_json);
})
.build();
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let tracker = client.get_tracker()?;
assert_eq!(
Tracker {
tracking_time_entry: Some(TrackingTimeEntry {
id: TimeEntryId(36135321),
minutes: Minutes(247),
since: Some(Utc.ymd(2015, 10, 15).and_hms(15, 05, 04))
}),
since: Some(Utc.ymd(2015, 10, 15).and_hms(15, 05, 04)),
tracking_time_entry: Some(expected),
stopped_time_entry: None,
},
tracker
@ -514,6 +539,46 @@ fn test_get_tracker() -> Result<(), Box<dyn std::error::Error>> {
#[test]
fn test_create_tracker() -> Result<(), Box<dyn std::error::Error>> {
let time_entry_json = json!({
"time_entry": {
"id": 36159117,
"minutes": 15,
"date_at": "2015-10-16",
"note": "Feedback einarbeiten",
"billable": true,
"locked": false,
"revenue": null,
"hourly_rate": 0,
"user_id": 211,
"user_name": "Fridolin Frei",
"project_id": 88309,
"project_name": "API v2",
"customer_id": 3213,
"customer_name": "König",
"service_id": 12984,
"service_name": "Entwurf",
"created_at": "2015-10-16T12:19:00+02:00",
"updated_at": "2015-10-16T12:39:00+02:00"
}
});
let expected = TimeEntry {
id: TimeEntryId::Num(36159117),
minutes: Minutes(15),
date_at: NaiveDate::from_ymd(2015, 10, 16),
note: "Feedback einarbeiten".to_string(),
locked: false,
billable: true,
user_id: UserId::Num(211),
user_name: "Fridolin Frei".to_string(),
customer_id: CustomerId::Num(3213),
customer_name: "König".to_string(),
service_id: ServiceId::Num(12984),
service_name: "Entwurf".to_string(),
project_id: ProjectId::Num(88309),
project_name: "API v2".to_string(),
created_at: Utc.ymd(2015, 10, 16).and_hms(10, 19, 00),
};
let pact = PactBuilder::new(CONSUMER, PROVIDER)
.interaction("create tracker", |i| {
i.given("User with API token");
@ -535,27 +600,36 @@ fn test_create_tracker() -> Result<(), Box<dyn std::error::Error>> {
}
}));
})
.interaction("get tracker time entry by id 2a", |i| {
i.given("User with API token");
i.request
.get()
.path("/time_entries/36135322.json")
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(time_entry_json.clone());
})
.interaction("get tracker time entry by id 2b", |i| {
i.given("User with API token");
i.request
.get()
.path("/time_entries/36134329.json")
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(time_entry_json);
})
.build();
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let tracker = client.create_tracker(TimeEntryId(36135322))?;
let tracker = client.create_tracker(&TimeEntryId::Num(36135322))?;
assert_eq!(
Tracker {
tracking_time_entry: Some(TrackingTimeEntry {
id: TimeEntryId(36135322),
minutes: Minutes(0),
since: Some(Utc.ymd(2015, 10, 15).and_hms(15, 33, 52)),
}),
stopped_time_entry: Some(TrackingTimeEntry {
id: TimeEntryId(36134329),
minutes: Minutes(46),
since: None,
}),
since: Some(Utc.ymd(2015, 10, 15).and_hms(15, 33, 52)),
tracking_time_entry: Some(expected.clone()),
stopped_time_entry: Some(expected),
},
tracker
);
@ -565,6 +639,46 @@ fn test_create_tracker() -> Result<(), Box<dyn std::error::Error>> {
#[test]
fn test_delete_tracker() -> Result<(), Box<dyn std::error::Error>> {
let time_entry_json = json!({
"time_entry": {
"id": 36159117,
"minutes": 15,
"date_at": "2015-10-16",
"note": "Feedback einarbeiten",
"billable": true,
"locked": false,
"revenue": null,
"hourly_rate": 0,
"user_id": 211,
"user_name": "Fridolin Frei",
"project_id": 88309,
"project_name": "API v2",
"customer_id": 3213,
"customer_name": "König",
"service_id": 12984,
"service_name": "Entwurf",
"created_at": "2015-10-16T12:19:00+02:00",
"updated_at": "2015-10-16T12:39:00+02:00"
}
});
let expected = TimeEntry {
id: TimeEntryId::Num(36159117),
minutes: Minutes(15),
date_at: NaiveDate::from_ymd(2015, 10, 16),
note: "Feedback einarbeiten".to_string(),
locked: false,
billable: true,
user_id: UserId::Num(211),
user_name: "Fridolin Frei".to_string(),
customer_id: CustomerId::Num(3213),
customer_name: "König".to_string(),
service_id: ServiceId::Num(12984),
service_name: "Entwurf".to_string(),
project_id: ProjectId::Num(88309),
project_name: "API v2".to_string(),
created_at: Utc.ymd(2015, 10, 16).and_hms(10, 19, 00),
};
let pact = PactBuilder::new(CONSUMER, PROVIDER)
.interaction("delete tracker", |i| {
i.given("User with API token");
@ -581,23 +695,28 @@ fn test_delete_tracker() -> Result<(), Box<dyn std::error::Error>> {
}
}));
})
.interaction("get tracker time entry by id 3", |i| {
i.given("User with API token");
i.request
.get()
.path("/time_entries/36135322.json")
.header("X-MiteApiKey", term!("[0-9a-f]+", "12345678"));
i.response.ok().json_utf8().json_body(time_entry_json);
})
.build();
let server = pact.start_mock_server();
let mut url = server.url().clone();
url.set_username("12345678").unwrap();
let client = StdClient::new_form_url(url);
let client = MiteClient::new_form_url(url);
let tracker = client.delete_tracker(TimeEntryId(36135322))?;
let tracker = client.delete_tracker(&TimeEntryId::Num(36135322))?;
assert_eq!(
Tracker {
since: None,
tracking_time_entry: None,
stopped_time_entry: Some(TrackingTimeEntry {
id: TimeEntryId(36135322),
minutes: Minutes(4),
since: None,
}),
stopped_time_entry: Some(expected),
},
tracker
);

221
lib/src/mite_model.rs Normal file
View File

@ -0,0 +1,221 @@
use crate::{
model::{Account, AccountId, Customer, CustomerId, Minutes, Project, ProjectId, Service, ServiceId, TimeEntry, TimeEntryId, User, UserId},
DateSpan, Day,
};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct MiteAccount {
pub id: AccountId,
pub name: String,
pub title: String,
pub currency: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<MiteAccount> for Account {
fn from(f: MiteAccount) -> Self {
Account {
id: f.id,
name: f.name,
title: f.title,
currency: f.currency,
created_at: f.created_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct MiteUser {
pub id: UserId,
pub name: String,
pub email: String,
pub note: String,
pub role: String,
pub language: String,
pub archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<MiteUser> for User {
fn from(f: MiteUser) -> Self {
User {
id: f.id,
name: f.name,
email: f.email,
note: f.note,
role: f.role,
language: f.language,
archived: f.archived,
created_at: f.created_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct MiteCustomer {
pub id: CustomerId,
pub name: String,
pub note: String,
pub hourly_rate: Option<u32>,
pub archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<MiteCustomer> for Customer {
fn from(f: MiteCustomer) -> Self {
Customer {
id: f.id,
name: f.name,
note: f.note,
archived: f.archived,
created_at: f.created_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct MiteProject {
pub id: ProjectId,
pub name: String,
pub customer_id: CustomerId,
pub customer_name: String,
pub note: String,
pub budget: u32,
pub budget_type: String,
pub hourly_rate: Option<u32>,
pub archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<MiteProject> for Project {
fn from(f: MiteProject) -> Self {
Project {
id: f.id,
name: f.name,
customer_id: f.customer_id,
customer_name: f.customer_name,
note: f.note,
archived: f.archived,
created_at: f.created_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct MiteService {
pub id: ServiceId,
pub name: String,
pub note: String,
pub hourly_rate: Option<u32>,
pub billable: bool,
pub archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<MiteService> for Service {
fn from(f: MiteService) -> Self {
Service {
id: f.id,
name: f.name,
note: f.note,
billable: f.billable,
archived: f.archived,
created_at: f.created_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct MiteTimeEntry {
pub id: TimeEntryId,
pub date_at: NaiveDate,
pub minutes: Minutes,
pub customer_id: CustomerId,
pub customer_name: String,
pub project_id: ProjectId,
pub project_name: String,
pub service_id: ServiceId,
pub service_name: String,
pub user_id: UserId,
pub user_name: String,
pub note: String,
pub billable: bool,
pub locked: bool,
pub hourly_rate: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl From<MiteTimeEntry> for TimeEntry {
fn from(f: MiteTimeEntry) -> Self {
TimeEntry {
id: f.id,
date_at: f.date_at,
minutes: f.minutes,
customer_id: f.customer_id,
customer_name: f.customer_name,
project_id: f.project_id,
project_name: f.project_name,
service_id: f.service_id,
service_name: f.service_name,
user_id: f.user_id,
user_name: f.user_name,
note: f.note,
billable: f.billable,
locked: f.locked,
created_at: f.created_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct MiteTrackingTimeEntry {
pub id: TimeEntryId,
pub minutes: Minutes,
pub since: Option<DateTime<Utc>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct MiteTracker {
pub tracking_time_entry: Option<MiteTrackingTimeEntry>,
pub stopped_time_entry: Option<MiteTrackingTimeEntry>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum MiteEntity {
Account(MiteAccount),
User(MiteUser),
Customer(MiteCustomer),
Project(MiteProject),
Service(MiteService),
TimeEntry(MiteTimeEntry),
Tracker(MiteTracker),
Error(String),
}
pub fn day_query_param(day: &Day) -> String {
match day {
Day::Today => "today".to_string(),
Day::Yesterday => "yesterday".to_string(),
Day::Date(date) => format!("{}", date),
}
}
pub fn date_span_query_param(span: &DateSpan) -> String {
match span {
DateSpan::ThisWeek => "at=this_week".to_string(),
DateSpan::LastWeek => "at=last_week".to_string(),
DateSpan::ThisMonth => "at=this_month".to_string(),
DateSpan::LastMonth => "at=last_month".to_string(),
DateSpan::Day(date) => format!("at={}", day_query_param(&date)),
DateSpan::FromTo(from, to) => format!("from={}&to={}", from, to),
}
}

View File

@ -1,20 +1,102 @@
use crate::error::AcariError;
use crate::user_error;
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use serde::de::{Error, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;
use std::ops;
use std::str::FromStr;
macro_rules! id_wrapper {
($name: ident) => {
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy)]
#[serde(transparent)]
pub struct $name(pub u32);
#[derive(Debug, PartialEq, Eq, Clone, Hash)]
pub enum $name {
Num(u64),
Str(String),
}
impl $name {
pub fn str_encoded(&self) -> String {
match self {
$name::Num(n) => format!("n{}", n),
$name::Str(s) => format!("s{}", s),
}
}
pub fn parse_encoded(s: &str) -> Result<$name, AcariError> {
match s.chars().next() {
Some('n') => Ok($name::Num(s[1..].parse::<u64>()?)),
Some('s') => Ok($name::Str(s[1..].to_string())),
_ => Err(AcariError::InternalError("Invalid id format".to_string())),
}
}
pub fn path_encoded(&self) -> String {
match self {
$name::Num(n) => n.to_string(),
$name::Str(s) => utf8_percent_encode(&s, NON_ALPHANUMERIC).to_string(),
}
}
}
impl Default for $name {
fn default() -> Self {
$name::Num(0)
}
}
impl Serialize for $name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
$name::Num(n) => serializer.serialize_u64(*n),
$name::Str(s) => serializer.serialize_str(s),
}
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct EnumVisitor;
impl<'de> Visitor<'de> for EnumVisitor {
type Value = $name;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("integer or string")
}
fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E>
where
E: Error,
{
Ok($name::Num(v))
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error,
{
Ok($name::Str(v.to_string()))
}
}
deserializer.deserialize_any(EnumVisitor)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.0)
match self {
$name::Num(n) => write!(f, "{}", n),
$name::Str(s) => write!(f, "{}", s),
}
}
}
};
@ -34,7 +116,6 @@ pub struct Account {
pub title: String,
pub currency: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
@ -47,7 +128,6 @@ pub struct User {
pub language: String,
pub archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
@ -55,10 +135,8 @@ pub struct Customer {
pub id: CustomerId,
pub name: String,
pub note: String,
pub hourly_rate: Option<u32>,
pub archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
@ -68,12 +146,8 @@ pub struct Project {
pub customer_id: CustomerId,
pub customer_name: String,
pub note: String,
pub budget: u32,
pub budget_type: String,
pub hourly_rate: Option<u32>,
pub archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
@ -81,11 +155,35 @@ pub struct Service {
pub id: ServiceId,
pub name: String,
pub note: String,
pub hourly_rate: Option<u32>,
pub billable: bool,
pub archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
pub struct TimeEntry {
pub id: TimeEntryId,
pub date_at: NaiveDate,
pub minutes: Minutes,
pub customer_id: CustomerId,
pub customer_name: String,
pub project_id: ProjectId,
pub project_name: String,
pub service_id: ServiceId,
pub service_name: String,
pub user_id: UserId,
pub user_name: String,
pub note: String,
pub billable: bool,
pub locked: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Tracker {
pub since: Option<DateTime<Utc>>,
pub tracking_time_entry: Option<TimeEntry>,
pub stopped_time_entry: Option<TimeEntry>,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Copy, Default)]
@ -138,53 +236,6 @@ impl FromStr for Minutes {
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
pub struct TimeEntry {
pub id: TimeEntryId,
pub date_at: NaiveDate,
pub minutes: Minutes,
pub customer_id: CustomerId,
pub customer_name: String,
pub project_id: ProjectId,
pub project_name: String,
pub service_id: ServiceId,
pub service_name: String,
pub user_id: UserId,
pub user_name: String,
pub note: String,
pub billable: bool,
pub locked: bool,
pub hourly_rate: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrackingTimeEntry {
pub id: TimeEntryId,
pub minutes: Minutes,
pub since: Option<DateTime<Utc>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Tracker {
pub tracking_time_entry: Option<TrackingTimeEntry>,
pub stopped_time_entry: Option<TrackingTimeEntry>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub enum MiteEntity {
Account(Account),
User(User),
Customer(Customer),
Project(Project),
Service(Service),
TimeEntry(TimeEntry),
Tracker(Tracker),
Error(String),
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -10,14 +10,6 @@ pub enum Day {
}
impl Day {
pub fn query_param(self) -> String {
match self {
Day::Today => "today".to_string(),
Day::Yesterday => "yesterday".to_string(),
Day::Date(date) => format!("{}", date),
}
}
pub fn as_date(self) -> NaiveDate {
match self {
Day::Today => Local::now().naive_local().date(),
@ -55,19 +47,6 @@ pub enum DateSpan {
FromTo(NaiveDate, NaiveDate),
}
impl DateSpan {
pub fn query_param(&self) -> String {
match self {
DateSpan::ThisWeek => "at=this_week".to_string(),
DateSpan::LastWeek => "at=last_week".to_string(),
DateSpan::ThisMonth => "at=this_month".to_string(),
DateSpan::LastMonth => "at=last_month".to_string(),
DateSpan::Day(date) => format!("at={}", date.query_param()),
DateSpan::FromTo(from, to) => format!("from={}&to={}", from, to),
}
}
}
impl FromStr for DateSpan {
type Err = AcariError;