use std::{
collections::BTreeMap,
env,
sync::Arc,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use rand::RngCore;
use reqwest::{
header::{HeaderMap, ACCEPT, CONTENT_TYPE, USER_AGENT as USER_AGENT_HEADER},
redirect::Policy,
StatusCode, Url,
};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::Value;
use crate::{signer, MessageService, OtpService, Result, UniError, UniResponse, USER_AGENT};
pub const DEFAULT_ENDPOINT: &str = "https://api.unimtx.com";
pub const DEFAULT_SIGNING_ALGORITHM: &str = "hmac-sha256";
const REQUEST_ID_HEADER: &str = "x-uni-request-id";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[derive(Clone)]
pub struct UniClient {
pub(crate) inner: Arc<ClientInner>,
}
pub(crate) struct ClientInner {
pub(crate) access_key_id: String,
pub(crate) access_key_secret: Option<String>,
pub(crate) endpoint: Url,
pub(crate) user_agent: String,
pub(crate) http: reqwest::Client,
}
impl UniClient {
pub fn new(
access_key_id: impl Into<String>,
access_key_secret: impl Into<String>,
) -> Result<Self> {
Self::builder()
.access_key_id(access_key_id)
.access_key_secret(access_key_secret)
.build()
}
pub fn builder() -> UniClientBuilder {
UniClientBuilder::default()
}
pub fn from_env() -> Result<Self> {
Self::builder().build()
}
pub fn messages(&self) -> MessageService<'_> {
MessageService::new(self)
}
pub fn otp(&self) -> OtpService<'_> {
OtpService::new(self)
}
pub async fn request<T, P>(&self, action: &str, data: &P) -> Result<UniResponse<T>>
where
T: DeserializeOwned,
P: Serialize + ?Sized,
{
let url = self.signed_url(action)?;
let response = self
.inner
.http
.post(url)
.header(USER_AGENT_HEADER, &self.inner.user_agent)
.header(CONTENT_TYPE, "application/json;charset=utf-8")
.header(ACCEPT, "application/json")
.json(data)
.send()
.await?;
let status = response.status();
let headers = response.headers().clone();
let body = response.text().await?;
decode_response(status, headers, body)
}
pub(crate) fn signed_url(&self, action: &str) -> Result<Url> {
let mut query = BTreeMap::from([
("accessKeyId".to_owned(), self.inner.access_key_id.clone()),
("action".to_owned(), action.to_owned()),
]);
if let Some(secret) = &self.inner.access_key_secret {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| UniError::Configuration(error.to_string()))?
.as_secs();
let mut bytes = [0_u8; 8];
rand::thread_rng().fill_bytes(&mut bytes);
signer::sign(&mut query, secret, timestamp, &hex::encode(bytes))?;
}
let mut url = self.inner.endpoint.clone();
url.query_pairs_mut().extend_pairs(query.iter());
Ok(url)
}
}
impl std::fmt::Debug for UniClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("UniClient")
.field("access_key_id", &self.inner.access_key_id)
.field("endpoint", &self.inner.endpoint)
.field("user_agent", &self.inner.user_agent)
.field("signed", &self.inner.access_key_secret.is_some())
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct UniClientBuilder {
access_key_id: Option<String>,
access_key_secret: Option<String>,
endpoint: String,
user_agent: String,
timeout: Duration,
http_client: Option<reqwest::Client>,
}
impl Default for UniClientBuilder {
fn default() -> Self {
Self {
access_key_id: env::var("UNIMTX_ACCESS_KEY_ID").ok(),
access_key_secret: env::var("UNIMTX_ACCESS_KEY_SECRET").ok(),
endpoint: env::var("UNIMTX_ENDPOINT").unwrap_or_else(|_| DEFAULT_ENDPOINT.to_owned()),
user_agent: USER_AGENT.to_owned(),
timeout: DEFAULT_TIMEOUT,
http_client: None,
}
}
}
impl std::fmt::Debug for UniClientBuilder {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("UniClientBuilder")
.field("access_key_id", &self.access_key_id)
.field("endpoint", &self.endpoint)
.field("user_agent", &self.user_agent)
.field("timeout", &self.timeout)
.field("signed", &self.access_key_secret.is_some())
.finish_non_exhaustive()
}
}
impl UniClientBuilder {
pub fn access_key_id(mut self, value: impl Into<String>) -> Self {
self.access_key_id = Some(value.into());
self
}
pub fn access_key_secret(mut self, value: impl Into<String>) -> Self {
self.access_key_secret = Some(value.into());
self
}
pub fn unsigned(mut self) -> Self {
self.access_key_secret = None;
self
}
pub fn endpoint(mut self, value: impl Into<String>) -> Self {
self.endpoint = value.into();
self
}
pub fn user_agent(mut self, value: impl Into<String>) -> Self {
self.user_agent = value.into();
self
}
pub fn timeout(mut self, value: Duration) -> Self {
self.timeout = value;
self
}
pub fn http_client(mut self, value: reqwest::Client) -> Self {
self.http_client = Some(value);
self
}
pub fn build(self) -> Result<UniClient> {
let access_key_id = self
.access_key_id
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| UniError::Configuration("access key ID is required".to_owned()))?;
let endpoint = validate_endpoint(&self.endpoint)?;
let http = match self.http_client {
Some(client) => client,
None => reqwest::Client::builder()
.timeout(self.timeout)
.redirect(Policy::none())
.build()?,
};
Ok(UniClient {
inner: Arc::new(ClientInner {
access_key_id,
access_key_secret: self
.access_key_secret
.filter(|value| !value.trim().is_empty()),
endpoint,
user_agent: self.user_agent,
http,
}),
})
}
}
pub(crate) fn validate_endpoint(endpoint: &str) -> Result<Url> {
let url = Url::parse(endpoint)
.map_err(|error| UniError::Configuration(format!("invalid endpoint: {error}")))?;
if !matches!(url.scheme(), "http" | "https") {
return Err(UniError::Configuration(
"endpoint scheme must be http or https".to_owned(),
));
}
if url.query().is_some() || url.fragment().is_some() {
return Err(UniError::Configuration(
"endpoint must not contain a query string or fragment".to_owned(),
));
}
Ok(url)
}
pub(crate) fn decode_response<T: DeserializeOwned>(
status: StatusCode,
headers: HeaderMap,
body: String,
) -> Result<UniResponse<T>> {
let request_id = headers
.get(REQUEST_ID_HEADER)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
let raw_body: Value = match serde_json::from_str(&body) {
Ok(body) => body,
Err(_) if !status.is_success() => {
return Err(UniError::Http {
status,
message: status.canonical_reason().unwrap_or("HTTP error").to_owned(),
request_id,
body: Some(Value::String(body)),
});
}
Err(error) => {
return Err(UniError::InvalidResponse {
status,
request_id,
message: format!("response was not valid JSON: {error}"),
body,
});
}
};
let object = raw_body
.as_object()
.ok_or_else(|| UniError::InvalidResponse {
status,
request_id: request_id.clone(),
message: "response body was not a JSON object".to_owned(),
body: body.clone(),
})?;
let code = object.get("code").and_then(value_as_string);
let message = object
.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_owned();
if let Some(code) = code.as_deref() {
if code != "0" {
return Err(UniError::Api {
code: code.to_owned(),
message,
status,
request_id,
body: Some(raw_body),
});
}
}
if !status.is_success() {
return Err(UniError::Http {
status,
message: if message.is_empty() {
status.canonical_reason().unwrap_or("HTTP error").to_owned()
} else {
message
},
request_id,
body: Some(raw_body),
});
}
let code = code.ok_or_else(|| UniError::InvalidResponse {
status,
request_id: request_id.clone(),
message: "response did not contain a code".to_owned(),
body: body.clone(),
})?;
let data = object
.get("data")
.filter(|value| !value.is_null())
.map(|value| serde_json::from_value(value.clone()))
.transpose()
.map_err(|error| UniError::InvalidResponse {
status,
request_id: request_id.clone(),
message: format!("response data did not match the expected type: {error}"),
body: body.clone(),
})?;
Ok(UniResponse {
status,
code,
message,
data,
request_id,
headers,
raw_body,
})
}
fn value_as_string(value: &Value) -> Option<String> {
match value {
Value::String(value) => Some(value.clone()),
Value::Number(value) => Some(value.to_string()),
_ => None,
}
}