use crate::client_error::ClientError;
use crate::token_record::TokenRecord;
use reqwest;
use crate::response;
use std::collections::HashMap;
use std::time::Duration;
#[cfg(test)]
use mockito;
const DEFAULT_TIMEOUT: u64 = 30;
pub struct Client {
access_token: Option<String>,
api_domain: Option<String>,
client_id: String,
client_secret: String,
refresh_token: String,
sandbox: bool,
timeout: u64,
}
impl Client {
pub fn with_creds(
access_token: Option<String>,
api_domain: Option<String>,
client_id: String,
client_secret: String,
refresh_token: String
) -> Client {
Client {
access_token,
api_domain,
client_id,
client_secret,
refresh_token,
sandbox: false,
timeout: DEFAULT_TIMEOUT,
}
}
}
impl Client {
pub fn sandbox(&self) -> bool {
self.sandbox
}
pub fn set_sandbox(&mut self, sandbox: bool) {
self.sandbox = sandbox
}
pub fn timeout(&self) -> u64 {
self.timeout
}
pub fn set_timeout(&mut self, timeout: u64) {
self.timeout = timeout;
}
pub fn access_token(&self) -> Option<String> {
self.access_token.clone()
}
pub fn api_domain(&self) -> Option<String> {
if self.sandbox() {
Some(String::from("https://crmsandbox.zoho.com"))
} else {
self.api_domain.clone()
}
}
pub fn abbreviated_access_token(&self) -> Option<String> {
match &self.access_token {
Some(access_token) => {
let prefix = &access_token[0..9];
let suffix = &access_token.chars()
.rev()
.collect::<String>()[0..4]
.chars()
.rev()
.collect::<String>();
let abbreviated_token = format!("{}..{}", prefix, suffix);
Some(abbreviated_token)
},
None => None
}
}
}
impl Client {
fn get_api_base_path() -> String {
#[cfg(test)]
return mockito::server_url();
#[cfg(not(test))]
return String::from("https://accounts.zoho.com");
}
pub fn get_new_token(&mut self) -> Result<TokenRecord, ClientError> {
let url = format!(
"{}/oauth/v2/token?grant_type=refresh_token&client_id={}&client_secret={}&refresh_token={}",
Client::get_api_base_path(),
self.client_id,
self.client_secret,
self.refresh_token
);
let client = reqwest::Client::new();
let mut response = client.post(url.as_str()).send()?;
let raw_response = response.text()?;
if let Ok(response) = serde_json::from_str::<response::AuthErrorResponse>(&raw_response) {
return Err(ClientError::General(response.error));
}
let api_response: TokenRecord = serde_json::from_str(&raw_response)?;
self.access_token = api_response.access_token.clone();
self.api_domain = api_response.api_domain.clone();
match &self.access_token {
Some(_) => Ok(api_response),
None => Err(ClientError::from("No token received"))
}
}
pub fn get<T: serde::de::DeserializeOwned>(&mut self, module: &str, id: &str) -> Result<response::ApiGetResponse<T>, ClientError> {
if self.access_token.is_none() {
self.get_new_token()?;
}
let token = self.access_token.clone().unwrap();
let timeout = Duration::from_secs(self.timeout);
let client = reqwest::Client::builder().timeout(timeout).build()?;
let url = format!("{}/crm/v2/{}/{}", self.api_domain().unwrap(), module, id);
let mut response = client
.get(url.as_str())
.header("Authorization", format!("Zoho-oauthtoken {}", token))
.send()?;
let raw_response = response.text()?;
if let Ok(response) = serde_json::from_str::<response::ApiErrorResponse>(&raw_response) {
return Err(ClientError::ApiError(response));
}
match serde_json::from_str::<response::ApiGetResponse<T>>(&raw_response) {
Ok(data) => Ok(data),
Err(_) => {
if raw_response.len() > 0 {
Err(ClientError::UnexpectedResponseType(raw_response))
} else {
Err(ClientError::General(String::from("Empty response")))
}
},
}
}
pub fn get_many<T: serde::de::DeserializeOwned>(&mut self, module: &str, params: Option<String>) -> Result<response::ApiGetManyResponse<T>, ClientError> {
if self.access_token.is_none() {
self.get_new_token()?;
}
let token = self.access_token().unwrap();
let api_domain = self.api_domain().unwrap();
let timeout = Duration::from_secs(self.timeout);
let client = reqwest::Client::builder().timeout(timeout).build()?;
let mut url = format!("{}/crm/v2/{}", api_domain, module);
if params.is_none() == false {
url = url + &format!("?{}", params.unwrap());
}
let mut response = client
.get(url.as_str())
.header("Authorization", String::from("Zoho-oauthtoken ") + &token)
.send()?;
let raw_response = response.text()?;
if let Ok(response) = serde_json::from_str::<response::ApiErrorResponse>(&raw_response) {
return Err(ClientError::ApiError(response));
}
match serde_json::from_str::<response::ApiGetManyResponse<T>>(&raw_response) {
Ok(data) => Ok(data),
Err(_) => {
if raw_response.len() > 0 {
Err(ClientError::UnexpectedResponseType(raw_response))
} else {
Err(ClientError::General(String::from("Empty response")))
}
},
}
}
pub fn insert<T>(&mut self, module: &str, data: Vec<T>) -> Result<response::ApiSuccessResponse, ClientError>
where T: serde::ser::Serialize
{
if self.access_token.is_none() {
self.get_new_token()?;
}
let token = self.access_token().unwrap();
let api_domain = self.api_domain().unwrap();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(self.timeout))
.build()?;
let url = format!("{}/crm/v2/{}", api_domain, module);
let mut params: HashMap<&str, Vec<T>> = HashMap::new();
params.insert("data", data);
let mut response = client
.post(url.as_str())
.header("Authorization", String::from("Zoho-oauthtoken ") + &token)
.json(¶ms)
.send()?;
let raw_response = response.text()?;
if let Ok(response) = serde_json::from_str::<response::ApiErrorResponse>(&raw_response) {
return Err(ClientError::ApiError(response));
}
match serde_json::from_str::<response::ApiSuccessResponse>(&raw_response) {
Ok(response) => Ok(response),
Err(_) => {
if raw_response.len() > 0 {
Err(ClientError::UnexpectedResponseType(raw_response))
} else {
Err(ClientError::General(String::from("Empty response")))
}
},
}
}
pub fn update_many<T>(&mut self, module: &str, data: Vec<T>)-> Result<response::ApiSuccessResponse, ClientError>
where T: serde::ser::Serialize
{
if self.access_token.is_none() {
self.get_new_token()?;
}
let token = self.access_token().unwrap();
let api_domain = self.api_domain().unwrap();
let timeout = Duration::from_secs(self.timeout);
let client = reqwest::Client::builder().timeout(timeout).build()?;
let url = format!("{}/crm/v2/{}", api_domain, module);
let mut params: HashMap<&str, Vec<T>> = HashMap::new();
params.insert("data", data);
let mut response = client
.put(url.as_str())
.header("Authorization", String::from("Zoho-oauthtoken ") + &token)
.json(¶ms)
.send()?;
let raw_response = response.text()?;
if let Ok(response) = serde_json::from_str::<response::ApiErrorResponse>(&raw_response) {
return Err(ClientError::ApiError(response));
}
match serde_json::from_str::<response::ApiSuccessResponse>(&raw_response) {
Ok(response) => Ok(response),
Err(_) => {
if raw_response.len() > 0 {
Err(ClientError::UnexpectedResponseType(raw_response))
} else {
Err(ClientError::General(String::from("Empty response")))
}
},
}
}
}
#[allow(dead_code)]
pub fn parse_params(params: impl serde::ser::Serialize) -> Result<String, serde_urlencoded::ser::Error> {
serde_urlencoded::to_string(params)
}
#[cfg(test)]
mod tests {
extern crate mockito;
use mockito::{mock, Matcher, Mock};
use super::*;
use serde::Deserialize;
use std::collections::HashMap;
#[derive(Debug, Deserialize)]
struct ResponseRecord {
id: String,
}
fn get_client(access_token: Option<String>, api_domain: Option<String>) -> Client {
let id = String::from("id");
let secret = String::from("secret");
let refresh_token = String::from("refresh_token");
Client::with_creds(access_token, api_domain, id, secret, refresh_token)
}
fn get_mocker<T: Into<Matcher>>(method: &str, url: T, body: Option<&str>) -> Mock {
let mut mocker = mock(method, url)
.with_status(200)
.with_header("Content-Type", "application/json;charset=UTF-8");
if let Some(body) = body {
mocker = mocker
.with_header("Content-Length", &body.to_string().len().to_string())
.with_body(body);
}
mocker = mocker.create();
mocker
}
#[test]
fn no_access_token() {
let client = get_client(None, Some(String::from("api_domain")));
assert_eq!(client.access_token(), None);
}
#[test]
fn no_domain() {
let client = get_client(Some(String::from("access_token")), None);
assert_eq!(client.api_domain(), None);
}
#[test]
fn preset_access_token() {
let access_token = String::from("access_token");
let client = get_client(Some(access_token.clone()), None);
assert_eq!(client.access_token(), Some(access_token));
}
#[test]
fn preset_api_domain() {
let domain = String::from("api_domain");
let client = get_client(None, Some(domain.clone()));
assert_eq!(client.api_domain(), Some(domain));
}
#[test]
fn empty_abbreviated_token() {
let client = get_client(None, None);
assert_eq!(client.abbreviated_access_token(), None);
}
#[test]
fn valid_abbreviated_token() {
let access_token = String::from("12345678901234567890");
let client = get_client(Some(access_token), None);
assert_ne!(client.access_token().unwrap().len(), 15);
assert_eq!(client.abbreviated_access_token().unwrap().len(), 15);
}
#[test]
fn api_domain() {
let api_domain = "https://test.com";
let client = get_client(None, Some(api_domain.to_string()));
assert_eq!(api_domain, client.api_domain().unwrap());
}
#[test]
fn api_domain_sandbox() {
let api_domain = "https://test.com";
let sandbox_api_domain = "https://crmsandbox.zoho.com";
let id = String::from("id");
let secret = String::from("secret");
let refresh_token = String::from("refresh_token");
let mut client = Client::with_creds(None, Some(api_domain.to_string()), id, secret, refresh_token);
client.set_sandbox(true);
assert_eq!(sandbox_api_domain, client.api_domain().unwrap());
}
#[test]
fn get_new_token_success() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = "https://www.zohoapis.com";
let body = format!("{{\"access_token\":\"{}\",\"expires_in_sec\":3600,\"api_domain\":\"{}\",\"token_type\":\"Bearer\",\"expires_in\":3600000}}", access_token, api_domain);
let mocker = get_mocker("POST", Matcher::Any, Some(&body));
let mut client = get_client(None, None);
match client.get_new_token() {
Ok(e) => println!("Good: {:#?}", e),
Err(error) => println!("Bad: {:#?}", error),
}
mocker.assert();
assert_eq!(client.access_token(), Some(access_token.to_string()));
}
#[test]
fn get_new_api_domain_success() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = "https://www.zohoapis.com";
let body = format!(r#"{{"access_token":"{}","expires_in_sec":3600,"api_domain":"{}","token_type":"Bearer","expires_in":3600000}}"#, access_token, api_domain);
let mocker = get_mocker("POST", Matcher::Any, Some(&body));
let mut client = get_client(None, None);
client.get_new_token().unwrap();
mocker.assert();
assert_eq!(client.api_domain(), Some(api_domain.to_string()));
}
#[test]
fn get_new_token_invalid_token() {
let error_message = "invalid_token";
let body = format!(r#"{{"error":"{}"}}"#, error_message);
let mocker = get_mocker("POST", Matcher::Any, Some(&body));
let mut client = get_client(None, None);
match client.get_new_token() {
Ok(_) => panic!("Error was not thrown"),
Err(error) => {
assert_eq!(error_message.to_string(), error.to_string());
}
}
mocker.assert();
}
#[test]
fn return_new_token_success() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = "https://www.zohoapis.com";
let body = format!(r#"{{"access_token":"{}","expires_in_sec":3600,"api_domain":"{}","token_type":"Bearer","expires_in":3600000}}"#, access_token, api_domain);
let mocker = get_mocker("POST", Matcher::Any, Some(&body));
let mut client = get_client(None, None);
let token = client.get_new_token().unwrap();
mocker.assert();
assert_eq!(token.access_token, Some(access_token.to_string()));
}
#[test]
fn return_api_domain_success() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = "https://www.zohoapis.com";
let body = format!(r#"{{"access_token":"{}","expires_in_sec":3600,"api_domain":"{}","token_type":"Bearer","expires_in":3600000}}"#, access_token, api_domain);
let mocker = get_mocker("POST", Matcher::Any, Some(&body));
let mut client = get_client(None, None);
let token = client.get_new_token().unwrap();
mocker.assert();
assert_eq!(token.api_domain, Some(api_domain.to_string()));
}
#[test]
fn get_success() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = mockito::server_url();
let record_id = "40000000123456789";
let body = format!(r#"{{"data":[{{"id":"{}"}}],"info":{{"more_records":true,"per_page":1,"count":1,"page":1}}}}"#, record_id);
let mocker = get_mocker("GET", Matcher::Any, Some(&body));
let mut client = get_client(Some(String::from(access_token)), Some(String::from(api_domain)));
let response = client.get::<ResponseRecord>("Accounts", record_id).unwrap();
mocker.assert();
assert_eq!(response.data.get(0).unwrap().id, record_id);
}
#[test]
fn get_regular_error() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = mockito::server_url();
let error_code = "INVALID_URL_PATTERN";
let body = format!(r#"{{"code":"{}","details":{{}},"message":"Please check if the URL trying to access is a correct one","status":"error"}}"#, error_code);
let mocker = get_mocker("GET", Matcher::Any, Some(&body));
let mut client = get_client(Some(String::from(access_token)), Some(String::from(api_domain)));
match client.get::<ResponseRecord>("INVALID_MODULE", "00000") {
Ok(_) => panic!("Response did not return an error"),
Err(err) => {
match err {
ClientError::ApiError(error) => assert_eq!(error.code, error_code),
_ => panic!("Wrong error type"),
}
}
}
mocker.assert();
}
#[test]
fn get_text_error() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = mockito::server_url();
let error_code = "invalid_client";
let body = format!("{}", error_code);
let mocker = get_mocker("GET", Matcher::Any, Some(&body));
let mut client = get_client(Some(String::from(access_token)), Some(String::from(api_domain)));
match client.get::<ResponseRecord>("INVALID_MODULE", "00000") {
Ok(_) => panic!("Response did not return an error"),
Err(err) => {
assert_eq!(err.to_string(), error_code.to_string());
}
}
mocker.assert();
}
#[test]
fn insert_many_success() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = mockito::server_url();
let record_id = "40000000123456789";
let body = format!(r#"{{
"data": [
{{
"code": "SUCCESS",
"details": {{
"Modified_Time": "2019-05-02T11:17:33+05:30",
"Modified_By": {{
"name": "Patricia Boyle",
"id": "554023000000235011"
}},
"Created_Time": "2019-05-02T11:17:33+05:30",
"id": "{}",
"Created_By": {{
"name": "Patricia Boyle",
"id": "554023000000235011"
}}
}},
"message": "record added",
"status": "success"
}}
]
}}"#, record_id);
let mocker = get_mocker("POST", Matcher::Any, Some(&body));
let mut client = get_client(Some(access_token.to_string()), Some(api_domain.to_string()));
let mut record: HashMap<&str, &str> = HashMap::new();
record.insert("name", "New Record Name");
let response = client.insert("Accounts", vec![record]).unwrap();
let response = response.data.get(0).unwrap();
let details = match &response.details {
response::ResponseDataItemDetails::Error(_) => {
panic!("Experienced an unexpected error");
},
response::ResponseDataItemDetails::Success(details) => details,
};
mocker.assert();
assert_eq!(details.id, record_id);
}
#[test]
fn insert_regular_error() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = mockito::server_url();
let error_code = "INVALID_MODULE";
let body = format!(r#"{{
"code": "{}",
"details": {{}},
"message": "Please check if the URL trying to access is a correct one",
"status": "error"
}}"#, error_code);
let mocker = get_mocker("POST", Matcher::Any, Some(&body));
let mut client = get_client(Some(String::from(access_token)), Some(String::from(api_domain)));
let mut record: HashMap<&str, &str> = HashMap::new();
record.insert("name", "New Record Name");
match client.insert("INVALID_MODULE", vec![record]) {
Ok(_) => panic!("Response did not return an error"),
Err(err) => {
match err {
ClientError::ApiError(error) => assert_eq!(error.code, error_code),
_ => panic!("Wrong error type"),
}
}
}
mocker.assert();
}
#[test]
fn insert_many_text_error() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = mockito::server_url();
let error_code = "invalid_client";
let body = format!("{}", error_code);
let mocker = get_mocker("POST", Matcher::Any, Some(&body));
let mut client = get_client(Some(String::from(access_token)), Some(String::from(api_domain)));
let mut record: HashMap<&str, &str> = HashMap::new();
record.insert("name", "New Record Name");
match client.insert("INVALID_MODULE", vec![record]) {
Ok(_) => panic!("Response did not return an error"),
Err(err) => {
assert_eq!(err.to_string(), error_code.to_string());
}
}
mocker.assert();
}
#[test]
fn update_many_success() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = mockito::server_url();
let record_id = "40000000123456789";
let body = format!(r#"{{
"data": [
{{
"code": "SUCCESS",
"details": {{
"Modified_Time": "2019-05-02T11:17:33+05:30",
"Modified_By": {{
"name": "Patricia Boyle",
"id": "554023000000235011"
}},
"Created_Time": "2019-05-02T11:17:33+05:30",
"id": "{}",
"Created_By": {{
"name": "Patricia Boyle",
"id": "554023000000235011"
}}
}},
"message": "record updated",
"status": "success"
}}
]
}}"#, record_id);
let mocker = get_mocker("PUT", Matcher::Any, Some(&body));
let mut client = get_client(Some(access_token.to_string()), Some(api_domain.to_string()));
let mut record: HashMap<&str, &str> = HashMap::new();
record.insert("name", "New Record Name");
let response = client.update_many("Accounts", vec![record]).unwrap();
let response = response.data.get(0).unwrap();
let details = match &response.details {
response::ResponseDataItemDetails::Error(_) => {
panic!("Experienced an unexpected error");
},
response::ResponseDataItemDetails::Success(details) => details,
};
mocker.assert();
assert_eq!(details.id, record_id);
}
#[test]
fn update_regular_error() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = mockito::server_url();
let error_code = "INVALID_MODULE";
let body = format!(r#"{{
"code": "{}",
"details": {{}},
"message": "Please check if the URL trying to access is a correct one",
"status": "error"
}}"#, error_code);
let mocker = get_mocker("PUT", Matcher::Any, Some(&body));
let mut client = get_client(Some(String::from(access_token)), Some(String::from(api_domain)));
let mut record: HashMap<&str, &str> = HashMap::new();
record.insert("name", "New Record Name");
match client.update_many("INVALID_MODULE", vec![record]) {
Ok(_) => panic!("Response did not return an error"),
Err(err) => {
match err {
ClientError::ApiError(error) => assert_eq!(error.code, error_code),
_ => panic!("Wrong error type"),
}
}
}
mocker.assert();
}
#[test]
fn update_many_text_error() {
let access_token = "9999.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let api_domain = mockito::server_url();
let error_code = "invalid_client";
let body = format!("{}", error_code);
let mocker = get_mocker("PUT", Matcher::Any, Some(&body));
let mut client = get_client(Some(String::from(access_token)), Some(String::from(api_domain)));
let mut record: HashMap<&str, &str> = HashMap::new();
record.insert("name", "New Record Name");
match client.update_many("INVALID_MODULE", vec![record]) {
Ok(_) => panic!("Response did not return an error"),
Err(err) => {
assert_eq!(err.to_string(), error_code.to_string());
}
}
mocker.assert();
}
#[test]
fn test_parse_params() {
let mut params: HashMap<&str, &str> = HashMap::new();
params.insert("cvid", "00000");
params.insert("page", "2");
let converted = parse_params(params).unwrap();
match converted.as_str() {
"page=2&cvid=00000" => (),
"cvid=00000&page=2" => (),
_ => {
panic!("Params did not convert properly");
}
}
}
}