Fix update api

This commit is contained in:
Wlad 2024-08-18 19:08:39 +02:00
parent cd141258cf
commit 6108694f5d
7 changed files with 21 additions and 960 deletions

View File

@ -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)?,
})
}
}

View File

@ -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(())
}

View File

@ -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))
}
}

View File

@ -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};

View File

@ -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()),

View File

@ -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,

View File

@ -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,