uni-sdk 0.3.0

The official Unimatrix SDK for Rust
Documentation
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};

/// Default base URL for Unimatrix API requests.
pub const DEFAULT_ENDPOINT: &str = "https://api.unimtx.com";
/// Signing algorithm used for authenticated requests.
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)]
/// Asynchronous client for the Unimatrix HTTP API.
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 {
    /// Creates a client with explicit credentials.
    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()
    }

    /// Creates a builder pre-populated from `UNIMTX_*` environment variables.
    pub fn builder() -> UniClientBuilder {
        UniClientBuilder::default()
    }

    /// Creates a client using credentials and endpoint from the environment.
    pub fn from_env() -> Result<Self> {
        Self::builder().build()
    }

    /// Returns the SMS message service.
    pub fn messages(&self) -> MessageService<'_> {
        MessageService::new(self)
    }

    /// Returns the one-time passcode service.
    pub fn otp(&self) -> OtpService<'_> {
        OtpService::new(self)
    }

    /// Calls an arbitrary Unimatrix action with a serializable JSON body.
    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()
    }
}

/// Builder for [`UniClient`].
#[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 {
    /// Sets the access key ID included with every request.
    pub fn access_key_id(mut self, value: impl Into<String>) -> Self {
        self.access_key_id = Some(value.into());
        self
    }

    /// Sets the secret used to sign requests.
    pub fn access_key_secret(mut self, value: impl Into<String>) -> Self {
        self.access_key_secret = Some(value.into());
        self
    }

    /// Disables request signing while retaining the access key ID.
    pub fn unsigned(mut self) -> Self {
        self.access_key_secret = None;
        self
    }

    /// Sets the API endpoint.
    pub fn endpoint(mut self, value: impl Into<String>) -> Self {
        self.endpoint = value.into();
        self
    }

    /// Overrides the SDK user agent.
    pub fn user_agent(mut self, value: impl Into<String>) -> Self {
        self.user_agent = value.into();
        self
    }

    /// Sets the timeout applied to the entire HTTP request.
    pub fn timeout(mut self, value: Duration) -> Self {
        self.timeout = value;
        self
    }

    /// Uses an existing reqwest client. Timeout and redirect settings on this
    /// builder do not alter a custom client.
    pub fn http_client(mut self, value: reqwest::Client) -> Self {
        self.http_client = Some(value);
        self
    }

    /// Validates the configuration and creates a client.
    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,
    }
}