snippe 0.1.0

Async Rust client for the Snippe payments API (Tanzania) — collections, hosted checkout sessions, disbursements, and verified webhooks.
Documentation
//! Idempotency key validation.
//!
//! The Snippe API enforces a **30-byte maximum** on the `Idempotency-Key`
//! header. Keys longer than 30 bytes don't return a clear validation error —
//! they trigger the cryptic `PAY_001` ("failed to initiate payment") error,
//! which is hard to debug if you don't know to look at the key length first.
//!
//! This module exposes a newtype that enforces the constraint at construction
//! time, so over-long keys can never reach the wire.
//!
//! # Examples
//!
//! ```
//! use snippe::IdempotencyKey;
//!
//! let key = IdempotencyKey::new("ord-12345-att-1")?;
//! assert_eq!(key.as_str(), "ord-12345-att-1");
//!
//! // Length is enforced — this fails before any network call:
//! assert!(IdempotencyKey::new("a".repeat(31)).is_err());
//! # Ok::<(), snippe::idempotency::IdempotencyKeyError>(())
//! ```
//!
//! ## Picking a key
//!
//! - **Per request**, not per resource. The retry-safe semantics work because
//!   the same key always returns the same cached response; if you reuse a key
//!   across logically distinct calls you'll get a `422` idempotency conflict.
//! - **Short**. 30 bytes leaves room for ~15-character prefixes plus a short
//!   ID. Avoid `${userId}-${orderId}-${Date.now()}` patterns — they easily
//!   overshoot.
//! - **Stable across retries** of the same logical operation. Generate the
//!   key once when you first attempt the request, and reuse it on every retry.

/// Maximum byte length the Snippe API accepts on the `Idempotency-Key` header.
pub const MAX_LENGTH: usize = 30;

/// A validated idempotency key — non-empty and ≤ 30 bytes.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct IdempotencyKey(String);

impl IdempotencyKey {
    /// Construct an idempotency key, validating length.
    pub fn new(key: impl Into<String>) -> Result<Self, IdempotencyKeyError> {
        let key = key.into();
        if key.is_empty() {
            return Err(IdempotencyKeyError::Empty);
        }
        if key.len() > MAX_LENGTH {
            return Err(IdempotencyKeyError::TooLong {
                length: key.len(),
                max: MAX_LENGTH,
            });
        }
        Ok(Self(key))
    }

    /// Borrow the raw string.
    pub fn as_str(&self) -> &str {
        &self.0
    }

    /// Consume self and return the inner string.
    pub fn into_inner(self) -> String {
        self.0
    }
}

impl std::fmt::Display for IdempotencyKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

impl AsRef<str> for IdempotencyKey {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl std::str::FromStr for IdempotencyKey {
    type Err = IdempotencyKeyError;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        Self::new(s)
    }
}

/// Reasons construction of an [`IdempotencyKey`] can fail.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum IdempotencyKeyError {
    /// The key was empty.
    #[error("idempotency key cannot be empty")]
    Empty,
    /// The key exceeded the 30-byte API limit.
    #[error("idempotency key is {length} bytes; the Snippe API limit is {max}")]
    TooLong {
        /// Provided length, in bytes.
        length: usize,
        /// Maximum allowed length, in bytes.
        max: usize,
    },
}

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

    #[test]
    fn accepts_short_key() {
        let k = IdempotencyKey::new("ord-1").unwrap();
        assert_eq!(k.as_str(), "ord-1");
    }

    #[test]
    fn accepts_exactly_30_bytes() {
        let k = IdempotencyKey::new("a".repeat(30)).unwrap();
        assert_eq!(k.as_str().len(), 30);
    }

    #[test]
    fn rejects_31_bytes() {
        let err = IdempotencyKey::new("a".repeat(31)).unwrap_err();
        assert_eq!(err, IdempotencyKeyError::TooLong { length: 31, max: 30 });
    }

    #[test]
    fn rejects_empty() {
        assert_eq!(IdempotencyKey::new("").unwrap_err(), IdempotencyKeyError::Empty);
    }

    #[test]
    fn from_str_works() {
        let k: IdempotencyKey = "ord-1".parse().unwrap();
        assert_eq!(k.as_str(), "ord-1");
    }
}