snippe 0.1.0

Async Rust client for the Snippe payments API (Tanzania) — collections, hosted checkout sessions, disbursements, and verified webhooks.
Documentation
//! HTTP client for the Snippe API.

use std::sync::Arc;
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
use reqwest::Method;
use serde::de::DeserializeOwned;
use serde::Serialize;

use crate::api::{Payments, Payouts, Sessions};
use crate::config::{
    Environment, DEFAULT_API_VERSION, DEFAULT_TIMEOUT, USER_AGENT as SDK_USER_AGENT,
};
use crate::envelope;
use crate::error::Error;
use crate::idempotency::IdempotencyKey;

/// HTTP header used for the API version pin.
pub const API_VERSION_HEADER: &str = "Snippe-Version";

/// HTTP header used for idempotency keys on POST requests.
pub const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key";

/// Async client for the Snippe payments API.
///
/// Cheap to clone — the inner state is reference-counted, so cloning produces
/// a new handle that shares the same connection pool. Construct one client per
/// merchant account at startup and pass it around.
#[derive(Clone)]
pub struct Client {
    inner: Arc<ClientInner>,
}

pub(crate) struct ClientInner {
    pub(crate) http: reqwest::Client,
    pub(crate) base_url: String,
    pub(crate) api_version: String,
}

impl Client {
    /// Construct a client with default settings (production base URL,
    /// 30-second timeout) and the given API key.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Config`] if the API key contains characters that
    /// cannot be embedded in an HTTP header (e.g. a newline).
    pub fn new(api_key: impl Into<String>) -> crate::Result<Self> {
        Self::builder().api_key(api_key).build()
    }

    /// Begin building a client with custom settings.
    pub fn builder() -> ClientBuilder {
        ClientBuilder::default()
    }

    /// Handle for collection (`/v1/payments`) endpoints.
    pub fn payments(&self) -> Payments<'_> {
        Payments::new(self)
    }

    /// Handle for hosted-checkout session (`/api/v1/sessions`) endpoints.
    pub fn sessions(&self) -> Sessions<'_> {
        Sessions::new(self)
    }

    /// Handle for disbursement (`/v1/payouts`) endpoints.
    pub fn payouts(&self) -> Payouts<'_> {
        Payouts::new(self)
    }

    /// Effective base URL the client will hit.
    pub fn base_url(&self) -> &str {
        &self.inner.base_url
    }

    /// API version pinned via the `Snippe-Version` header.
    pub fn api_version(&self) -> &str {
        &self.inner.api_version
    }

    pub(crate) fn request(&self, method: Method, path: &str) -> SnippeRequest {
        SnippeRequest::new(self, method, path)
    }

    pub(crate) fn get(&self, path: &str) -> SnippeRequest {
        self.request(Method::GET, path)
    }

    pub(crate) fn post(&self, path: &str) -> SnippeRequest {
        self.request(Method::POST, path)
    }
}

impl std::fmt::Debug for Client {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Client")
            .field("base_url", &self.inner.base_url)
            .field("api_version", &self.inner.api_version)
            .field("api_key", &"snp_***")
            .finish()
    }
}

/// Builder for a [`Client`].
#[derive(Debug)]
pub struct ClientBuilder {
    api_key: Option<String>,
    base_url: Option<String>,
    api_version: String,
    timeout: Duration,
    user_agent_suffix: Option<String>,
}

impl Default for ClientBuilder {
    fn default() -> Self {
        Self {
            api_key: None,
            base_url: None,
            api_version: DEFAULT_API_VERSION.to_string(),
            timeout: DEFAULT_TIMEOUT,
            user_agent_suffix: None,
        }
    }
}

impl ClientBuilder {
    /// Set the API key (required).
    ///
    /// Snippe API keys begin with `snp_` and carry the scopes you selected
    /// when the key was created in the Dashboard.
    pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
        self.api_key = Some(api_key.into());
        self
    }

    /// Override the base URL.
    ///
    /// Useful for testing against a wiremock server, pointing at staging, or
    /// targeting a sandbox host that differs from the convention.
    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = Some(base_url.into());
        self
    }

    /// Convenience: set the base URL from an [`Environment`].
    pub fn environment(mut self, environment: Environment) -> Self {
        self.base_url = Some(environment.base_url().to_string());
        self
    }

    /// Pin a specific API version. Defaults to [`crate::config::DEFAULT_API_VERSION`].
    pub fn api_version(mut self, version: impl Into<String>) -> Self {
        self.api_version = version.into();
        self
    }

    /// Override the HTTP request timeout. Defaults to 30 seconds.
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Append an application-specific suffix to the `User-Agent` header so
    /// Snippe support can correlate requests from your app.
    ///
    /// E.g. `"acme-checkout/2.3"` produces a User-Agent like
    /// `snippe-rust/0.1.0 acme-checkout/2.3`.
    pub fn user_agent_suffix(mut self, suffix: impl Into<String>) -> Self {
        self.user_agent_suffix = Some(suffix.into());
        self
    }

    /// Build the [`Client`].
    ///
    /// # Errors
    ///
    /// - [`Error::Config`] when no API key was supplied or when the key
    ///   contains characters that can't appear in an HTTP header.
    /// - [`Error::Http`] when reqwest fails to build the underlying client
    ///   (e.g. invalid TLS configuration).
    pub fn build(self) -> crate::Result<Client> {
        let api_key = self
            .api_key
            .ok_or_else(|| Error::Config("api_key is required".into()))?;
        if api_key.is_empty() {
            return Err(Error::Config("api_key is empty".into()));
        }

        let mut headers = HeaderMap::new();
        let auth = HeaderValue::from_str(&format!("Bearer {api_key}"))
            .map_err(|e| Error::Config(format!("invalid api_key: {e}")))?;
        headers.insert(AUTHORIZATION, auth);

        let user_agent = match self.user_agent_suffix.as_deref() {
            Some(s) if !s.is_empty() => format!("{} {}", SDK_USER_AGENT, s),
            _ => SDK_USER_AGENT.to_string(),
        };
        headers.insert(
            USER_AGENT,
            HeaderValue::from_str(&user_agent)
                .map_err(|e| Error::Config(format!("invalid user_agent_suffix: {e}")))?,
        );

        headers.insert(ACCEPT, HeaderValue::from_static("application/json"));

        if !self.api_version.is_empty() {
            let v = HeaderValue::from_str(&self.api_version)
                .map_err(|e| Error::Config(format!("invalid api_version: {e}")))?;
            headers.insert(API_VERSION_HEADER, v);
        }

        let http = reqwest::Client::builder()
            .default_headers(headers)
            .timeout(self.timeout)
            .build()
            .map_err(Error::Http)?;

        let base_url = self
            .base_url
            .unwrap_or_else(|| Environment::Production.base_url().to_string())
            .trim_end_matches('/')
            .to_string();

        Ok(Client {
            inner: Arc::new(ClientInner {
                http,
                base_url,
                api_version: self.api_version,
            }),
        })
    }
}

/// Internal request builder used by API modules.
#[doc(hidden)]
pub(crate) struct SnippeRequest {
    builder: Result<reqwest::RequestBuilder, Error>,
}

impl SnippeRequest {
    fn new(client: &Client, method: Method, path: &str) -> Self {
        let url = format!("{}{}", client.inner.base_url, path);
        let builder = Ok(client.inner.http.request(method, &url));
        Self { builder }
    }

    pub(crate) fn json<B: Serialize + ?Sized>(self, body: &B) -> Self {
        let builder = self.builder.and_then(|b| {
            // Pre-serialise so we surface an Encode error rather than the
            // generic reqwest::Error::Decode that .json() would emit.
            let bytes = serde_json::to_vec(body).map_err(Error::Encode)?;
            Ok(b.body(bytes)
                .header(reqwest::header::CONTENT_TYPE, "application/json"))
        });
        Self { builder }
    }

    pub(crate) fn query<Q: Serialize + ?Sized>(self, q: &Q) -> Self {
        let builder = self.builder.map(|b| b.query(q));
        Self { builder }
    }

    pub(crate) fn idempotency_key(self, key: &IdempotencyKey) -> Self {
        let builder = self.builder.and_then(|b| {
            let v = HeaderValue::from_str(key.as_str())
                .map_err(|e| Error::Config(format!("invalid idempotency key: {e}")))?;
            Ok(b.header(IDEMPOTENCY_KEY_HEADER, v))
        });
        Self { builder }
    }

    pub(crate) async fn send<R: DeserializeOwned>(self) -> crate::Result<R> {
        let response = self.builder?.send().await.map_err(Error::from)?;
        envelope::parse_response(response).await
    }
}

/// Percent-encode a single path segment, leaving unreserved characters intact.
///
/// References returned by Snippe are UUID-shaped, so this is mostly defensive,
/// but it's cheap insurance against future formats that might include slashes
/// or whitespace.
pub(crate) fn encode_path_segment(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for &b in s.as_bytes() {
        if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
            out.push(b as char);
        } else {
            out.push_str(&format!("%{:02X}", b));
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builder_requires_api_key() {
        let err = Client::builder().build().unwrap_err();
        assert!(matches!(err, Error::Config(_)));
    }

    #[test]
    fn builder_rejects_empty_key() {
        let err = Client::builder().api_key("").build().unwrap_err();
        assert!(matches!(err, Error::Config(_)));
    }

    #[test]
    fn defaults_to_production() {
        let c = Client::new("snp_test").unwrap();
        assert_eq!(c.base_url(), "https://api.snippe.sh");
    }

    #[test]
    fn environment_override() {
        let c = Client::builder()
            .api_key("snp_test")
            .environment(Environment::Sandbox)
            .build()
            .unwrap();
        assert_eq!(c.base_url(), "https://sandbox.snippe.sh");
    }

    #[test]
    fn base_url_strips_trailing_slash() {
        let c = Client::builder()
            .api_key("snp_test")
            .base_url("https://example.com/")
            .build()
            .unwrap();
        assert_eq!(c.base_url(), "https://example.com");
    }

    #[test]
    fn debug_does_not_leak_api_key() {
        let c = Client::new("snp_super_secret_key").unwrap();
        let s = format!("{:?}", c);
        assert!(!s.contains("super_secret"));
    }

    #[test]
    fn percent_encode_safe_chars() {
        assert_eq!(
            encode_path_segment("9015c155-9e29-4e8e-8fe6-d5d81553c8e6"),
            "9015c155-9e29-4e8e-8fe6-d5d81553c8e6"
        );
    }

    #[test]
    fn percent_encode_special_chars() {
        assert_eq!(encode_path_segment("a/b c"), "a%2Fb%20c");
    }
}