1use std::time::{Duration, SystemTime};
2
3use derive_builder::Builder;
4use hmac::{Hmac, Mac};
5use hyperx::header::HttpDate;
6use md5::{Digest, Md5};
7use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE, DATE, USER_AGENT};
8use serde::de::DeserializeOwned;
9use serde_json::{Map, Value};
10use sha1::Sha1;
11
12const API_URL: &str = "https://api.remitano.com";
13
14type HmacSha1 = Hmac<Sha1>;
15
16#[derive(Default, Builder, Debug)]
17pub struct RemitanoApi {
18 pub key: String,
19
20 pub secret: String,
21
22 #[builder(default = r#"API_URL.to_string()"#)]
23 pub api_url: String,
24
25 #[builder(default = "3000")]
26 pub timeout_ms: u64,
27}
28
29pub use reqwest::Method;
30
31impl RemitanoApi {
32 fn hmac(&self, data: &Option<Value>) -> anyhow::Result<String> {
33 let value = match data {
34 Some(data) => match data {
35 Value::String(data) => data.as_bytes().to_vec(),
36 _ => serde_json::to_vec(&data)?,
37 },
38 None => vec![],
39 };
40
41 let mut mac = HmacSha1::new_from_slice(self.secret.as_bytes())?;
42 mac.update(&value);
43 let result = mac.finalize().into_bytes();
44
45 Ok(base64::encode(result))
46 }
47
48 fn md5(&self, data: &Option<Value>) -> anyhow::Result<String> {
49 let value = match data {
50 Some(data) => match data {
51 Value::String(data) => data.as_bytes().to_vec(),
52 _ => serde_json::to_vec(&data)?,
53 },
54 None => vec![],
55 };
56
57 let mut hasher = Md5::new();
58 hasher.update(&value);
59 let result = hasher.finalize();
60
61 Ok(base64::encode(result))
62 }
63
64 pub async fn request<T: DeserializeOwned>(
65 &self,
66 method: Method,
67 endpoint: &str,
68 params: Option<Map<String, Value>>,
69 body: Option<Value>,
70 ) -> anyhow::Result<T> {
71 let mut headers = HeaderMap::new();
72 headers.insert(
73 USER_AGENT,
74 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:85.0) Gecko/20100101 Firefox/85.0"
75 .parse()?,
76 );
77 headers.insert(ACCEPT, "application/json".parse()?);
78 headers.insert(CONTENT_TYPE, "application/json".parse()?);
79 headers.insert("Content-MD5", self.md5(&body)?.parse()?);
80 headers.insert(DATE, HttpDate::from(SystemTime::now()).to_string().parse()?);
81
82 let query = if let Some(params) = ¶ms {
83 format!("?{}", &serde_qs::to_string(¶ms)?)
84 } else {
85 "".to_string()
86 };
87
88 let request_url = format!("api/v1/{}{}", &endpoint, &query);
89 let request_str = format!(
90 "{},application/json,{},/{},{}",
91 &method,
92 headers
93 .get("Content-MD5")
94 .map_or_else(|| Some(""), |v| v.to_str().ok())
95 .unwrap(),
96 &request_url,
97 headers
98 .get(DATE)
99 .map_or_else(|| Some(""), |v| v.to_str().ok())
100 .unwrap(),
101 );
102 let sig = self.hmac(&Some(Value::String(request_str)))?;
103 headers.insert(
104 AUTHORIZATION,
105 format!("APIAuth {}:{}", &self.key, &sig).parse()?,
106 );
107
108 let client = reqwest::Client::new();
109 let resp: T = client
110 .request(method, format!("{}/{}", &self.api_url, &request_url))
111 .headers(headers)
112 .json(&body.unwrap_or_default())
113 .timeout(Duration::from_millis(self.timeout_ms))
114 .send()
115 .await?
116 .json()
117 .await?;
118
119 Ok(resp)
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use serde_json::json;
126
127 use crate::*;
128
129 #[test]
130 fn test_md5() {
131 let remitano_api = RemitanoApiBuilder::default()
132 .key("key".to_string())
133 .secret("secret".to_string())
134 .build()
135 .unwrap();
136
137 let input = "hash me";
138 let result = remitano_api.md5(&Some(json!(input))).unwrap();
139 assert_eq!("F7Mdzpa51sbQprqV9HeW+w==", result);
140 }
141
142 #[test]
143 fn test_hmac256() {
144 let remitano_api = RemitanoApiBuilder::default()
145 .key("key".to_string())
146 .secret("secret".to_string())
147 .build()
148 .unwrap();
149
150 let input = "hash me";
151 let result = remitano_api.hmac(&Some(json!(input))).unwrap();
152 assert_eq!("oSVlCBpf9BqviWbUjOm4DXEcgRo=", result);
153 }
154}