Skip to main content

uni_sdk/
client.rs

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
19/// Default base URL for Unimatrix API requests.
20pub const DEFAULT_ENDPOINT: &str = "https://api.unimtx.com";
21/// Signing algorithm used for authenticated requests.
22pub 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)]
27/// Asynchronous client for the Unimatrix HTTP API.
28pub 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    /// Creates a client with explicit credentials.
42    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    /// Creates a builder pre-populated from `UNIMTX_*` environment variables.
53    pub fn builder() -> UniClientBuilder {
54        UniClientBuilder::default()
55    }
56
57    /// Creates a client using credentials and endpoint from the environment.
58    pub fn from_env() -> Result<Self> {
59        Self::builder().build()
60    }
61
62    /// Returns the SMS message service.
63    pub fn messages(&self) -> MessageService<'_> {
64        MessageService::new(self)
65    }
66
67    /// Returns the one-time passcode service.
68    pub fn otp(&self) -> OtpService<'_> {
69        OtpService::new(self)
70    }
71
72    /// Calls an arbitrary Unimatrix action with a serializable JSON body.
73    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/// Builder for [`UniClient`].
130#[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    /// Sets the access key ID included with every request.
168    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    /// Sets the secret used to sign requests.
174    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    /// Disables request signing while retaining the access key ID.
180    pub fn unsigned(mut self) -> Self {
181        self.access_key_secret = None;
182        self
183    }
184
185    /// Sets the API endpoint.
186    pub fn endpoint(mut self, value: impl Into<String>) -> Self {
187        self.endpoint = value.into();
188        self
189    }
190
191    /// Overrides the SDK user agent.
192    pub fn user_agent(mut self, value: impl Into<String>) -> Self {
193        self.user_agent = value.into();
194        self
195    }
196
197    /// Sets the timeout applied to the entire HTTP request.
198    pub fn timeout(mut self, value: Duration) -> Self {
199        self.timeout = value;
200        self
201    }
202
203    /// Uses an existing reqwest client. Timeout and redirect settings on this
204    /// builder do not alter a custom client.
205    pub fn http_client(mut self, value: reqwest::Client) -> Self {
206        self.http_client = Some(value);
207        self
208    }
209
210    /// Validates the configuration and creates a client.
211    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}