espocrm_rs/
espocrm_api_client.rs

1use crate::espocrm_types::Params;
2use crate::{debug_if, trace_if};
3use hmac::{Hmac, Mac};
4use serde::Serialize;
5use sha2::Sha256;
6use std::fmt::Debug;
7use reqwest::{Client, RequestBuilder};
8use tap::TapFallible;
9
10type HmacSha256 = Hmac<Sha256>;
11
12/// Used to indicate the required GenericType is not needed
13/// Used when calling [request()](EspoApiClient::request) with the GET method
14pub type NoGeneric = ();
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub enum Method {
18    Get,
19    Post,
20    Put,
21    Delete,
22}
23
24impl From<Method> for reqwest::Method {
25    fn from(a: Method) -> reqwest::Method {
26        match a {
27            Method::Get => reqwest::Method::GET,
28            Method::Post => reqwest::Method::POST,
29            Method::Put => reqwest::Method::PUT,
30            Method::Delete => reqwest::Method::DELETE,
31        }
32    }
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct EspoApiClient {
37    pub(crate) url: String,
38    pub(crate) username: Option<String>,
39    pub(crate) password: Option<String>,
40    pub(crate) api_key: Option<String>,
41    pub(crate) secret_key: Option<String>,
42    pub(crate) url_path: String,
43}
44
45impl EspoApiClient {
46    /// Create an instance of EspoApiClient.
47    pub fn new(url: &str) -> EspoApiClient {
48        EspoApiClient {
49            url: url.to_string(),
50            username: None,
51            password: None,
52            api_key: None,
53            secret_key: None,
54            url_path: "/api/v1/".to_string(),
55        }
56    }
57
58    pub fn build(&self) -> Self {
59        self.clone()
60    }
61
62    /// Set the URL where EspoCRM is located.
63    pub fn set_url<S: AsRef<str>>(&mut self, url: S) -> &mut EspoApiClient {
64        let url = url.as_ref();
65
66        let url = if url.ends_with("/") {
67            let mut url = url.to_string();
68            url.pop();
69            url
70        } else {
71            url.to_string()
72        };
73
74        self.url = url;
75        self
76    }
77
78    /// Set the username to use for authentication.
79    /// If you use this you must also call [`Self::set_password()`]
80    /// It is not recommended that you use this. Instead you should use API Key authorization or HMAC
81    pub fn set_username<S: AsRef<str>>(&mut self, username: S) -> &mut EspoApiClient {
82        self.username = Some(username.as_ref().to_string());
83        self
84    }
85
86    /// Set the password to use for authentication
87    /// If you use this you must also call [`Self::set_username()`]
88    /// It is not recommended that you use this. Instead you should use API Key authorization or HMAC authorization
89    pub fn set_password<S: AsRef<str>>(&mut self, password: S) -> &mut EspoApiClient {
90        self.password = Some(password.as_ref().to_string());
91        self
92    }
93
94    /// Set the API Key to use for authorization
95    /// If you only provide the API key, and not the secret_key, API Key authorization will be used.
96    /// If you wish to use HMAC authorization, you must also call [`Self::set_secret_key()`]
97    pub fn set_api_key<S: AsRef<str>>(&mut self, api_key: S) -> &mut EspoApiClient {
98        self.api_key = Some(api_key.as_ref().to_string());
99        self
100    }
101
102    /// Set the Secret Key to use for HMAC authorization
103    /// If you use this you must also call [`Self::set_api_key()`]
104    pub fn set_secret_key<S: AsRef<str>>(&mut self, secret_key: S) -> &mut EspoApiClient {
105        self.secret_key = Some(secret_key.as_ref().to_string());
106        self
107    }
108
109    pub(crate) fn normalize_url<S: AsRef<str>>(&self, action: S) -> String {
110        format!("{}{}{}", self.url, self.url_path, action.as_ref())
111    }
112
113    /// Make a POST request to EspoCRM to create an entity.
114    /// This request will skip duplicate checks, which would otherwhise result in a HTTP `409`.
115    ///
116    /// For information about what the `data` and `action` should be, refer to the [EspoCRM API Documentation](https://docs.espocrm.com/development/).
117    ///
118    /// # Errors
119    ///
120    /// If the request fails
121    pub async fn create_allow_duplicates<T, S>(&self, action: S, data: T) -> reqwest::Result<reqwest::Response> where T: Serialize + Clone + Debug, S: AsRef<str> {
122        let url = self.normalize_url(&action);
123        let client = Client::new();
124        let mut request = client.post(url);
125        request = self.configure_client_auth(request, reqwest::Method::POST, action.as_ref());
126
127        #[allow(unused)] // `x` in the tap_ functions
128        request
129            .header("X-Skip-Duplicate-Check", "true")
130            .json(&data)
131            .send()
132            .await
133            .tap_err(|x| debug_if!("Got an error from EspoCRM: {x}"))
134            .tap_ok(|x| debug_if!("Got response from EspoCRM with status code: {}", x.status()))
135
136    }
137
138    /// Make a POST request to EspoCRM to create an entity.
139    /// This request will perform duplicate checks and return a HTTP `409` if one is found. If you want to avoid this use [Self::create_allow_duplicates].
140    ///
141    /// For information about what the `data` and `action` should be, refer to the [EspoCRM API Documentation](https://docs.espocrm.com/development/).
142    ///
143    /// # Errors
144    ///
145    /// If the request fails
146    pub async fn create<T, S>(&self, action: S, data: T) -> reqwest::Result<reqwest::Response> where T: Serialize + Clone + Debug, S: AsRef<str> {
147        let url = self.normalize_url(&action.as_ref());
148        let client = Client::new();
149        let mut request = client.post(url);
150        request = self.configure_client_auth(request, reqwest::Method::POST, action.as_ref());
151
152        #[allow(unused)] // `x` in the tap_ functions
153        request
154            .json(&data)
155            .send()
156            .await
157            .tap_err(|x| debug_if!("Got an error from EspoCRM: {x}"))
158            .tap_ok(|x| debug_if!("Got response from EspoCRM with status code: {}", x.status()))
159    }
160
161    /// Make a request to EspoCRM
162    /// For more information, see the [EspoCRM API Documentation](https://docs.espocrm.com/development/)
163    ///
164    /// If you are making a GET request, you will still need to provide a type declaration for T. You can use the type NoGeneric for this.
165    ///
166    /// * method: The HTTP method to be used. E.g GET or POST
167    /// * action: On what EspoCRM Object should the action be performed on. E.g "Contact" or "Contact/ID". Essentially this is everything after "/api/v1/" in the URL.
168    /// * data_get: The filter to use on a GET request. Will be serialized according to PHP's http_build_query function.
169    /// * data_post: The data to send on everything that is not a GET request. It will be serialized to JSON and send as the request body.
170    #[cfg_attr(feature = "tracing", tracing::instrument(skip(data_get, data_post)))]
171    pub async fn request<T, S>(
172        &self,
173        method: Method,
174        action: S,
175        data_get: Option<Params>,
176        data_post: Option<T>,
177    ) -> reqwest::Result<reqwest::Response>
178    where
179        T: Serialize + Clone + Debug,
180        S: AsRef<str> + Debug,
181    {
182        let mut url = self.normalize_url(&action.as_ref());
183        debug_if!("Using URL {url} to request from EspoCRM");
184
185        let reqwest_method = reqwest::Method::from(method);
186
187        url = if data_get.is_some() && reqwest_method == reqwest::Method::GET {
188            format!(
189                "{}?{}",
190                url,
191                crate::serializer::serialize(data_get.unwrap()).unwrap()
192            )
193        } else {
194            url
195        };
196
197        let client = Client::new();
198        let mut request_builder = client.request(reqwest_method.clone(), url);
199        request_builder = self.configure_client_auth(request_builder, reqwest_method.clone(), action.as_ref());
200
201        if data_post.is_some() {
202            if reqwest_method != reqwest::Method::GET {
203                request_builder = request_builder.json(&data_post.clone().unwrap());
204                request_builder = request_builder.header("Content-Type", "application/json");
205            }
206        }
207
208        trace_if!("Sending request to EspoCRM");
209        #[allow(unused)]
210        request_builder
211            .send()
212            .await
213            .tap_err(|x| debug_if!("Got an error from EspoCRM: {x}"))
214            .tap_ok(|x| debug_if!("Got response from EspoCRM with status code: {}", x.status()))
215    }
216
217    fn configure_client_auth(&self, mut request_builder: RequestBuilder, request_method: reqwest::Method, action: &str) -> RequestBuilder {
218        //Basic authentication
219        if self.username.is_some() && self.password.is_some() {
220            trace_if!("Using basic authentication");
221            request_builder =
222                request_builder.basic_auth(self.username.clone().unwrap(), self.password.clone());
223
224            //HMAC authentication
225        } else if self.api_key.is_some() && self.secret_key.is_some() {
226            trace_if!("Using HMAC authentication.");
227
228            let str = format!(
229                "{} /{}",
230                request_method.clone().to_string(),
231                action,
232            );
233
234            let mut mac = HmacSha256::new_from_slice(self.secret_key.clone().unwrap().as_bytes())
235                .expect("Unable to create Hmac instance. Is your key valid?");
236            mac.update(str.as_bytes());
237            let mac_result = mac.finalize().into_bytes();
238
239            let auth_part = format!(
240                "{}{}{}",
241                base64::encode(self.api_key.clone().unwrap().as_bytes()),
242                "6", //: in base64, for some reason this works, and turning ':' into base64 does not.
243                base64::encode(mac_result)
244            );
245
246            request_builder = request_builder.header("X-Hmac-Authorization", auth_part);
247
248            //Basic api key authentication
249        } else if self.api_key.is_some() {
250            trace_if!("Authenticating with an API key");
251
252            request_builder = request_builder.header("X-Api-Key", self.api_key.clone().unwrap());
253        }
254
255        request_builder
256    }
257}