uni-sdk 0.3.0

The official Unimatrix SDK for Rust
Documentation
//! Optional synchronous client, enabled with the `blocking` Cargo feature.

use std::{
    collections::BTreeMap,
    env,
    time::{Duration, SystemTime, UNIX_EPOCH},
};

use rand::RngCore;
use reqwest::{
    blocking::Client as HttpClient,
    header::{ACCEPT, CONTENT_TYPE, USER_AGENT as USER_AGENT_HEADER},
    redirect::Policy,
    Url,
};
use serde::{de::DeserializeOwned, Serialize};

use crate::{
    client::{decode_response, validate_endpoint},
    signer, Result, SendMessageData, SendMessageRequest, SendOtpRequest, UniMessage, UniResponse,
    VerifyOtpData, VerifyOtpRequest, DEFAULT_ENDPOINT, USER_AGENT,
};

const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);

/// Synchronous Unimatrix client.
#[derive(Clone)]
pub struct UniClient {
    access_key_id: String,
    access_key_secret: Option<String>,
    endpoint: Url,
    user_agent: String,
    http: HttpClient,
}

impl UniClient {
    /// Creates a synchronous 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 synchronous client using credentials and endpoint from the environment.
    pub fn from_env() -> Result<Self> {
        Self::builder().build()
    }

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

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

    /// Calls an arbitrary Unimatrix action with a serializable JSON body.
    pub fn request<T, P>(&self, action: &str, data: &P) -> Result<UniResponse<T>>
    where
        T: DeserializeOwned,
        P: Serialize + ?Sized,
    {
        let response = self
            .http
            .post(self.signed_url(action)?)
            .header(USER_AGENT_HEADER, &self.user_agent)
            .header(CONTENT_TYPE, "application/json;charset=utf-8")
            .header(ACCEPT, "application/json")
            .json(data)
            .send()?;
        let status = response.status();
        let headers = response.headers().clone();
        let body = response.text()?;
        decode_response(status, headers, body)
    }

    fn signed_url(&self, action: &str) -> Result<Url> {
        let mut query = BTreeMap::from([
            ("accessKeyId".to_owned(), self.access_key_id.clone()),
            ("action".to_owned(), action.to_owned()),
        ]);
        if let Some(secret) = &self.access_key_secret {
            let timestamp = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .map_err(|error| crate::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.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("blocking::UniClient")
            .field("access_key_id", &self.access_key_id)
            .field("endpoint", &self.endpoint)
            .field("user_agent", &self.user_agent)
            .field("signed", &self.access_key_secret.is_some())
            .finish_non_exhaustive()
    }
}

/// Builder for the synchronous [`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<HttpClient>,
}

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("blocking::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 blocking reqwest client.
    ///
    /// Timeout and redirect settings on this builder do not alter a custom client.
    pub fn http_client(mut self, value: HttpClient) -> Self {
        self.http_client = Some(value);
        self
    }

    /// Validates the configuration and creates a synchronous client.
    pub fn build(self) -> Result<UniClient> {
        let access_key_id = self
            .access_key_id
            .filter(|value| !value.trim().is_empty())
            .ok_or_else(|| {
                crate::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 => HttpClient::builder()
                .timeout(self.timeout)
                .redirect(Policy::none())
                .build()?,
        };
        Ok(UniClient {
            access_key_id,
            access_key_secret: self
                .access_key_secret
                .filter(|value| !value.trim().is_empty()),
            endpoint,
            user_agent: self.user_agent,
            http,
        })
    }
}

#[derive(Clone, Copy)]
/// Synchronous SMS message operations.
pub struct MessageService<'a> {
    client: &'a UniClient,
}

impl MessageService<'_> {
    /// Sends an SMS message.
    pub fn send(&self, params: &SendMessageRequest) -> Result<UniResponse<SendMessageData>> {
        self.client.request("sms.message.send", params)
    }

    /// Sends a custom serializable payload and returns custom response data.
    pub fn send_with<T, P>(&self, params: &P) -> Result<UniResponse<T>>
    where
        T: DeserializeOwned,
        P: Serialize + ?Sized,
    {
        self.client.request("sms.message.send", params)
    }
}

#[derive(Clone, Copy)]
/// Synchronous one-time passcode operations.
pub struct OtpService<'a> {
    client: &'a UniClient,
}

impl OtpService<'_> {
    /// Sends a one-time passcode.
    pub fn send(&self, params: &SendOtpRequest) -> Result<UniResponse<UniMessage>> {
        self.client.request("otp.send", params)
    }

    /// Verifies a one-time passcode.
    pub fn verify(&self, params: &VerifyOtpRequest) -> Result<UniResponse<VerifyOtpData>> {
        self.client.request("otp.verify", params)
    }
}