mirror of
https://github.com/gosticks/acari.git
synced 2025-10-16 11:45:37 +00:00
Fix update api
This commit is contained in:
parent
cd141258cf
commit
6108694f5d
@ -1,270 +0,0 @@
|
||||
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,
|
||||
};
|
||||
let minutes = Minutes((timer.duration.unwrap_or_default() + timer.today.unwrap_or_default()) / 60);
|
||||
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,
|
||||
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::PUT,
|
||||
&format!("/tasks/{}/time", service_id.path_encoded()),
|
||||
EverhourCreateTimeRecord {
|
||||
date: day.as_date(),
|
||||
user: user.id.clone(),
|
||||
time: minutes,
|
||||
comment: note.unwrap_or_default(),
|
||||
},
|
||||
)?;
|
||||
|
||||
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)?;
|
||||
|
||||
if minutes.0 == 0 {
|
||||
let _: EverhourTimeEntry = self.request_with_body(
|
||||
Method::DELETE,
|
||||
&format!("/tasks/{}/time", service_id.path_encoded()),
|
||||
json!({
|
||||
"date": date,
|
||||
"user": user_id,
|
||||
}),
|
||||
)?;
|
||||
} else {
|
||||
let _: EverhourTimeEntry = self.request_with_body(
|
||||
Method::PUT,
|
||||
&format!("/tasks/{}/time", service_id.path_encoded()),
|
||||
EverhourCreateTimeRecord {
|
||||
date,
|
||||
user: user_id,
|
||||
time: minutes,
|
||||
comment: note.unwrap_or_default(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
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)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,343 +0,0 @@
|
||||
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(())
|
||||
}
|
||||
@ -1,334 +0,0 @@
|
||||
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: 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>,
|
||||
pub duration: Option<u32>,
|
||||
pub today: Option<u32>,
|
||||
#[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 start = NaiveDate::from_ymd(end.year(), end.month(), 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))
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,5 @@
|
||||
mod cached_client;
|
||||
mod error;
|
||||
mod everhour_client;
|
||||
mod everhour_model;
|
||||
mod mite_client;
|
||||
mod mite_model;
|
||||
mod model;
|
||||
@ -9,7 +7,6 @@ mod query;
|
||||
|
||||
pub use cached_client::{clear_cache, CachedClient};
|
||||
pub use error::AcariError;
|
||||
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};
|
||||
|
||||
@ -77,7 +77,15 @@ impl MiteClient {
|
||||
|
||||
fn handle_response<T: DeserializeOwned>(response: blocking::Response) -> Result<T, AcariError> {
|
||||
match response.status() {
|
||||
StatusCode::OK | StatusCode::CREATED => Ok(response.json()?),
|
||||
StatusCode::OK | StatusCode::CREATED => {
|
||||
let text = response.text().unwrap_or_default();
|
||||
// println!("Response: {:?}", text);
|
||||
// decode json here from text
|
||||
match serde_json::from_str::<T>(text.as_str()) {
|
||||
Ok(t) => Ok(t),
|
||||
Err(e) => Err(AcariError::InternalError(format!("Failed to decode json: {:?}", e))),
|
||||
}
|
||||
}
|
||||
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())),
|
||||
@ -174,7 +182,10 @@ impl Client for MiteClient {
|
||||
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(&date_span)))?
|
||||
.request::<Vec<MiteEntity>>(
|
||||
Method::GET,
|
||||
&format!("/time_entries.json?user_id=current&{}", date_span_query_param(&date_span)),
|
||||
)?
|
||||
.into_iter()
|
||||
.filter_map(|entity| match entity {
|
||||
MiteEntity::TimeEntry(time_entry) => Some(time_entry.into()),
|
||||
|
||||
@ -137,10 +137,10 @@ 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 customer_id: Option<CustomerId>,
|
||||
pub customer_name: Option<String>,
|
||||
pub project_id: Option<ProjectId>,
|
||||
pub project_name: Option<String>,
|
||||
pub service_id: ServiceId,
|
||||
pub service_name: String,
|
||||
pub user_id: UserId,
|
||||
|
||||
@ -165,10 +165,10 @@ 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 customer_id: Option<CustomerId>,
|
||||
pub customer_name: Option<String>,
|
||||
pub project_id: Option<ProjectId>,
|
||||
pub project_name: Option<String>,
|
||||
pub service_id: ServiceId,
|
||||
pub service_name: String,
|
||||
pub user_id: UserId,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user