infobip-sms-sdk 0.1.0

Async Rust SDK for the Infobip SMS API: send messages, manage scheduled bulks, query delivery reports and logs, fetch inbound SMS, and parse webhook payloads.
Documentation
//! HTTP client, builder, and authentication.
//!
//! Most users will only touch [`Client::builder`], [`ClientBuilder`], and
//! [`Auth`] from this module — the rest is implementation detail used by
//! the per-endpoint methods defined elsewhere in the crate.

use std::time::Duration;

use base64::Engine as _;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use url::Url;

use crate::error::Error;

/// Authentication scheme used by the [`Client`].
///
/// Every Infobip SMS endpoint accepts the same four schemes; the API
/// chooses based on which `Authorization` header you send. Use the
/// constructor that matches what your account is provisioned for:
///
/// | Variant | `Authorization` header |
/// |---|---|
/// | [`Auth::ApiKey`] | `App <key>` |
/// | [`Auth::Basic`] | `Basic base64(user:pass)` |
/// | [`Auth::IbssoToken`] | `IBSSO <token>` |
/// | [`Auth::Bearer`] | `Bearer <token>` |
///
/// # Examples
///
/// ```
/// use infobip_sms::Auth;
///
/// let _ = Auth::api_key("YOUR_API_KEY");
/// let _ = Auth::basic("user", "pass");
/// let _ = Auth::ibsso("TOKEN");
/// let _ = Auth::bearer("OAUTH2_TOKEN");
/// ```
#[derive(Debug, Clone)]
pub enum Auth {
    /// Sends `Authorization: App <api-key>`. Recommended for
    /// service-to-service traffic.
    ApiKey(
        /// The raw API key as issued by the Infobip portal.
        String,
    ),
    /// Sends `Authorization: Basic base64(username:password)`.
    Basic {
        /// Account username.
        username: String,
        /// Account password.
        password: String,
    },
    /// Sends `Authorization: IBSSO <token>`. Used for Infobip Single
    /// Sign-On flows.
    IbssoToken(
        /// The IBSSO session token.
        String,
    ),
    /// Sends `Authorization: Bearer <token>`. Used with OAuth2 client
    /// credentials.
    Bearer(
        /// OAuth2 access token.
        String,
    ),
}

impl Auth {
    /// Construct an [`Auth::ApiKey`] from anything string-like.
    pub fn api_key(key: impl Into<String>) -> Self {
        Self::ApiKey(key.into())
    }

    /// Construct an [`Auth::Basic`] from a username and password.
    pub fn basic(username: impl Into<String>, password: impl Into<String>) -> Self {
        Self::Basic {
            username: username.into(),
            password: password.into(),
        }
    }

    /// Construct an [`Auth::IbssoToken`] from anything string-like.
    pub fn ibsso(token: impl Into<String>) -> Self {
        Self::IbssoToken(token.into())
    }

    /// Construct an [`Auth::Bearer`] from anything string-like.
    pub fn bearer(token: impl Into<String>) -> Self {
        Self::Bearer(token.into())
    }

    fn header_value(&self) -> Result<HeaderValue, Error> {
        let raw = match self {
            Auth::ApiKey(k) => format!("App {k}"),
            Auth::Basic { username, password } => {
                let encoded = base64::engine::general_purpose::STANDARD
                    .encode(format!("{username}:{password}"));
                format!("Basic {encoded}")
            }
            Auth::IbssoToken(t) => format!("IBSSO {t}"),
            Auth::Bearer(t) => format!("Bearer {t}"),
        };
        HeaderValue::from_str(&raw)
            .map_err(|e| Error::Config(format!("invalid auth header: {e}")))
    }
}

/// Builder for [`Client`].
///
/// Obtain via [`Client::builder`]. At minimum you need to set
/// [`base_url`](Self::base_url) and [`auth`](Self::auth) before calling
/// [`build`](Self::build).
///
/// # Examples
///
/// ```no_run
/// use std::time::Duration;
/// use infobip_sms::{Auth, Client};
///
/// # fn main() -> Result<(), infobip_sms::Error> {
/// let client = Client::builder()
///     .base_url("https://xxxxx.api.infobip.com")
///     .auth(Auth::api_key("YOUR_API_KEY"))
///     .timeout(Duration::from_secs(30))
///     .user_agent("my-app/1.2.3")
///     .build()?;
/// # let _ = client;
/// # Ok(()) }
/// ```
#[derive(Debug, Default)]
pub struct ClientBuilder {
    base_url: Option<String>,
    auth: Option<Auth>,
    timeout: Option<Duration>,
    user_agent: Option<String>,
    http: Option<reqwest::Client>,
}

impl ClientBuilder {
    /// Sets the base URL for your Infobip account, e.g.
    /// `https://xxxxx.api.infobip.com`.
    ///
    /// You can find this URL in the Infobip portal under your account
    /// settings. **Required.**
    ///
    /// Trailing slashes are accepted; endpoint paths are joined onto
    /// this URL.
    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = Some(base_url.into());
        self
    }

    /// Sets the authentication scheme.
    ///
    /// **Required.** See [`Auth`] for the available schemes.
    pub fn auth(mut self, auth: Auth) -> Self {
        self.auth = Some(auth);
        self
    }

    /// Sets a per-request timeout.
    ///
    /// Defaults to no timeout (the [`reqwest`] default). Ignored if you
    /// also call [`http_client`](Self::http_client).
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = Some(timeout);
        self
    }

    /// Overrides the `User-Agent` header.
    ///
    /// Defaults to `infobip-sms-rust/<crate-version>`. Ignored if you
    /// also call [`http_client`](Self::http_client).
    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
        self.user_agent = Some(user_agent.into());
        self
    }

    /// Provides a pre-configured [`reqwest::Client`].
    ///
    /// Use this when you need finer control than the builder exposes —
    /// for example, to enable a proxy, swap out TLS backends, install
    /// connection-level middleware, or share one HTTP pool across many
    /// SDKs.
    ///
    /// When set, [`timeout`](Self::timeout) and
    /// [`user_agent`](Self::user_agent) are ignored — configure those
    /// on the [`reqwest::Client`] directly.
    pub fn http_client(mut self, http: reqwest::Client) -> Self {
        self.http = Some(http);
        self
    }

    /// Validates the configuration and produces a ready-to-use
    /// [`Client`].
    ///
    /// # Errors
    ///
    /// Returns [`Error::Config`] if `base_url` or `auth` was not set,
    /// if the auth value cannot be encoded as an HTTP header, or if the
    /// HTTP client fails to build. Returns [`Error::Url`] if `base_url`
    /// can't be parsed.
    pub fn build(self) -> Result<Client, Error> {
        let base_url = self
            .base_url
            .ok_or_else(|| Error::Config("base_url is required".into()))?;
        let auth = self
            .auth
            .ok_or_else(|| Error::Config("auth is required".into()))?;

        let base_url = Url::parse(&base_url)?;

        let http = match self.http {
            Some(c) => c,
            None => {
                let mut builder = reqwest::Client::builder().user_agent(
                    self.user_agent
                        .unwrap_or_else(|| format!("infobip-sms-rust/{}", env!("CARGO_PKG_VERSION"))),
                );
                if let Some(t) = self.timeout {
                    builder = builder.timeout(t);
                }
                builder
                    .build()
                    .map_err(|e| Error::Config(format!("failed to build HTTP client: {e}")))?
            }
        };

        let mut default_headers = HeaderMap::new();
        default_headers.insert(AUTHORIZATION, auth.header_value()?);
        default_headers.insert(
            reqwest::header::ACCEPT,
            HeaderValue::from_static("application/json"),
        );

        Ok(Client {
            base_url,
            http,
            default_headers,
        })
    }
}

/// Async Infobip SMS API client.
///
/// `Client` is cheap to clone — it wraps a [`reqwest::Client`]
/// internally, which itself uses `Arc` for connection pool sharing. Hold
/// one shared instance for the lifetime of your service rather than
/// constructing a new client per request.
///
/// # Constructing
///
/// Use [`Client::builder`] and configure the builder, then call
/// [`ClientBuilder::build`].
///
/// # Method index
///
/// Endpoint methods are implemented on `Client` across several internal
/// modules. The full list is in the [crate-level
/// docs](crate#endpoints).
#[derive(Debug, Clone)]
pub struct Client {
    pub(crate) base_url: Url,
    pub(crate) http: reqwest::Client,
    pub(crate) default_headers: HeaderMap,
}

impl Client {
    /// Returns a new [`ClientBuilder`].
    ///
    /// See the builder's documentation for required fields.
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    pub(crate) fn url(&self, path: &str) -> Result<Url, Error> {
        let trimmed = path.trim_start_matches('/');
        Ok(self.base_url.join(trimmed)?)
    }

    pub(crate) fn request(
        &self,
        method: reqwest::Method,
        path: &str,
    ) -> Result<reqwest::RequestBuilder, Error> {
        let url = self.url(path)?;
        Ok(self.http.request(method, url).headers(self.default_headers.clone()))
    }
}