1use std::{
2 collections::BTreeMap,
3 env,
4 sync::Arc,
5 time::{Duration, SystemTime, UNIX_EPOCH},
6};
7
8use rand::RngCore;
9use reqwest::{
10 header::{HeaderMap, ACCEPT, CONTENT_TYPE, USER_AGENT as USER_AGENT_HEADER},
11 redirect::Policy,
12 StatusCode, Url,
13};
14use serde::{de::DeserializeOwned, Serialize};
15use serde_json::Value;
16
17use crate::{signer, MessageService, OtpService, Result, UniError, UniResponse, USER_AGENT};
18
19pub const DEFAULT_ENDPOINT: &str = "https://api.unimtx.com";
21pub const DEFAULT_SIGNING_ALGORITHM: &str = "hmac-sha256";
23const REQUEST_ID_HEADER: &str = "x-uni-request-id";
24const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
25
26#[derive(Clone)]
27pub struct UniClient {
29 pub(crate) inner: Arc<ClientInner>,
30}
31
32pub(crate) struct ClientInner {
33 pub(crate) access_key_id: String,
34 pub(crate) access_key_secret: Option<String>,
35 pub(crate) endpoint: Url,
36 pub(crate) user_agent: String,
37 pub(crate) http: reqwest::Client,
38}
39
40impl UniClient {
41 pub fn new(
43 access_key_id: impl Into<String>,
44 access_key_secret: impl Into<String>,
45 ) -> Result<Self> {
46 Self::builder()
47 .access_key_id(access_key_id)
48 .access_key_secret(access_key_secret)
49 .build()
50 }
51
52 pub fn builder() -> UniClientBuilder {
54 UniClientBuilder::default()
55 }
56
57 pub fn from_env() -> Result<Self> {
59 Self::builder().build()
60 }
61
62 pub fn messages(&self) -> MessageService<'_> {
64 MessageService::new(self)
65 }
66
67 pub fn otp(&self) -> OtpService<'_> {
69 OtpService::new(self)
70 }
71
72 pub async fn request<T, P>(&self, action: &str, data: &P) -> Result<UniResponse<T>>
74 where
75 T: DeserializeOwned,
76 P: Serialize + ?Sized,
77 {
78 let url = self.signed_url(action)?;
79 let response = self
80 .inner
81 .http
82 .post(url)
83 .header(USER_AGENT_HEADER, &self.inner.user_agent)
84 .header(CONTENT_TYPE, "application/json;charset=utf-8")
85 .header(ACCEPT, "application/json")
86 .json(data)
87 .send()
88 .await?;
89
90 let status = response.status();
91 let headers = response.headers().clone();
92 let body = response.text().await?;
93 decode_response(status, headers, body)
94 }
95
96 pub(crate) fn signed_url(&self, action: &str) -> Result<Url> {
97 let mut query = BTreeMap::from([
98 ("accessKeyId".to_owned(), self.inner.access_key_id.clone()),
99 ("action".to_owned(), action.to_owned()),
100 ]);
101 if let Some(secret) = &self.inner.access_key_secret {
102 let timestamp = SystemTime::now()
103 .duration_since(UNIX_EPOCH)
104 .map_err(|error| UniError::Configuration(error.to_string()))?
105 .as_secs();
106 let mut bytes = [0_u8; 8];
107 rand::thread_rng().fill_bytes(&mut bytes);
108 signer::sign(&mut query, secret, timestamp, &hex::encode(bytes))?;
109 }
110
111 let mut url = self.inner.endpoint.clone();
112 url.query_pairs_mut().extend_pairs(query.iter());
113 Ok(url)
114 }
115}
116
117impl std::fmt::Debug for UniClient {
118 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119 formatter
120 .debug_struct("UniClient")
121 .field("access_key_id", &self.inner.access_key_id)
122 .field("endpoint", &self.inner.endpoint)
123 .field("user_agent", &self.inner.user_agent)
124 .field("signed", &self.inner.access_key_secret.is_some())
125 .finish_non_exhaustive()
126 }
127}
128
129#[derive(Clone)]
131pub struct UniClientBuilder {
132 access_key_id: Option<String>,
133 access_key_secret: Option<String>,
134 endpoint: String,
135 user_agent: String,
136 timeout: Duration,
137 http_client: Option<reqwest::Client>,
138}
139
140impl Default for UniClientBuilder {
141 fn default() -> Self {
142 Self {
143 access_key_id: env::var("UNIMTX_ACCESS_KEY_ID").ok(),
144 access_key_secret: env::var("UNIMTX_ACCESS_KEY_SECRET").ok(),
145 endpoint: env::var("UNIMTX_ENDPOINT").unwrap_or_else(|_| DEFAULT_ENDPOINT.to_owned()),
146 user_agent: USER_AGENT.to_owned(),
147 timeout: DEFAULT_TIMEOUT,
148 http_client: None,
149 }
150 }
151}
152
153impl std::fmt::Debug for UniClientBuilder {
154 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 formatter
156 .debug_struct("UniClientBuilder")
157 .field("access_key_id", &self.access_key_id)
158 .field("endpoint", &self.endpoint)
159 .field("user_agent", &self.user_agent)
160 .field("timeout", &self.timeout)
161 .field("signed", &self.access_key_secret.is_some())
162 .finish_non_exhaustive()
163 }
164}
165
166impl UniClientBuilder {
167 pub fn access_key_id(mut self, value: impl Into<String>) -> Self {
169 self.access_key_id = Some(value.into());
170 self
171 }
172
173 pub fn access_key_secret(mut self, value: impl Into<String>) -> Self {
175 self.access_key_secret = Some(value.into());
176 self
177 }
178
179 pub fn unsigned(mut self) -> Self {
181 self.access_key_secret = None;
182 self
183 }
184
185 pub fn endpoint(mut self, value: impl Into<String>) -> Self {
187 self.endpoint = value.into();
188 self
189 }
190
191 pub fn user_agent(mut self, value: impl Into<String>) -> Self {
193 self.user_agent = value.into();
194 self
195 }
196
197 pub fn timeout(mut self, value: Duration) -> Self {
199 self.timeout = value;
200 self
201 }
202
203 pub fn http_client(mut self, value: reqwest::Client) -> Self {
206 self.http_client = Some(value);
207 self
208 }
209
210 pub fn build(self) -> Result<UniClient> {
212 let access_key_id = self
213 .access_key_id
214 .filter(|value| !value.trim().is_empty())
215 .ok_or_else(|| UniError::Configuration("access key ID is required".to_owned()))?;
216 let endpoint = validate_endpoint(&self.endpoint)?;
217 let http = match self.http_client {
218 Some(client) => client,
219 None => reqwest::Client::builder()
220 .timeout(self.timeout)
221 .redirect(Policy::none())
222 .build()?,
223 };
224
225 Ok(UniClient {
226 inner: Arc::new(ClientInner {
227 access_key_id,
228 access_key_secret: self
229 .access_key_secret
230 .filter(|value| !value.trim().is_empty()),
231 endpoint,
232 user_agent: self.user_agent,
233 http,
234 }),
235 })
236 }
237}
238
239pub(crate) fn validate_endpoint(endpoint: &str) -> Result<Url> {
240 let url = Url::parse(endpoint)
241 .map_err(|error| UniError::Configuration(format!("invalid endpoint: {error}")))?;
242 if !matches!(url.scheme(), "http" | "https") {
243 return Err(UniError::Configuration(
244 "endpoint scheme must be http or https".to_owned(),
245 ));
246 }
247 if url.query().is_some() || url.fragment().is_some() {
248 return Err(UniError::Configuration(
249 "endpoint must not contain a query string or fragment".to_owned(),
250 ));
251 }
252 Ok(url)
253}
254
255pub(crate) fn decode_response<T: DeserializeOwned>(
256 status: StatusCode,
257 headers: HeaderMap,
258 body: String,
259) -> Result<UniResponse<T>> {
260 let request_id = headers
261 .get(REQUEST_ID_HEADER)
262 .and_then(|value| value.to_str().ok())
263 .map(str::to_owned);
264 let raw_body: Value = match serde_json::from_str(&body) {
265 Ok(body) => body,
266 Err(_) if !status.is_success() => {
267 return Err(UniError::Http {
268 status,
269 message: status.canonical_reason().unwrap_or("HTTP error").to_owned(),
270 request_id,
271 body: Some(Value::String(body)),
272 });
273 }
274 Err(error) => {
275 return Err(UniError::InvalidResponse {
276 status,
277 request_id,
278 message: format!("response was not valid JSON: {error}"),
279 body,
280 });
281 }
282 };
283 let object = raw_body
284 .as_object()
285 .ok_or_else(|| UniError::InvalidResponse {
286 status,
287 request_id: request_id.clone(),
288 message: "response body was not a JSON object".to_owned(),
289 body: body.clone(),
290 })?;
291 let code = object.get("code").and_then(value_as_string);
292 let message = object
293 .get("message")
294 .and_then(Value::as_str)
295 .unwrap_or_default()
296 .to_owned();
297
298 if let Some(code) = code.as_deref() {
299 if code != "0" {
300 return Err(UniError::Api {
301 code: code.to_owned(),
302 message,
303 status,
304 request_id,
305 body: Some(raw_body),
306 });
307 }
308 }
309 if !status.is_success() {
310 return Err(UniError::Http {
311 status,
312 message: if message.is_empty() {
313 status.canonical_reason().unwrap_or("HTTP error").to_owned()
314 } else {
315 message
316 },
317 request_id,
318 body: Some(raw_body),
319 });
320 }
321 let code = code.ok_or_else(|| UniError::InvalidResponse {
322 status,
323 request_id: request_id.clone(),
324 message: "response did not contain a code".to_owned(),
325 body: body.clone(),
326 })?;
327 let data = object
328 .get("data")
329 .filter(|value| !value.is_null())
330 .map(|value| serde_json::from_value(value.clone()))
331 .transpose()
332 .map_err(|error| UniError::InvalidResponse {
333 status,
334 request_id: request_id.clone(),
335 message: format!("response data did not match the expected type: {error}"),
336 body: body.clone(),
337 })?;
338
339 Ok(UniResponse {
340 status,
341 code,
342 message,
343 data,
344 request_id,
345 headers,
346 raw_body,
347 })
348}
349
350fn value_as_string(value: &Value) -> Option<String> {
351 match value {
352 Value::String(value) => Some(value.clone()),
353 Value::Number(value) => Some(value.to_string()),
354 _ => None,
355 }
356}