reevit 0.1.0

Official Rust SDK for the Reevit payments API
Documentation
use std::fmt;
use std::time::Duration;

use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
use reqwest::{Method, RequestBuilder, StatusCode};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use url::Url;

use crate::error::{ApiError, Error, Result};
use crate::resources::{
    CheckoutSessions, Connections, Customers, Fraud, Invoices, PaymentLinks, Payments,
    RoutingRules, Subscriptions, Webhooks,
};
use crate::RequestOptions;

const DEFAULT_BASE_URL: &str = "https://api.reevit.io/";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const CLIENT_NAME: &str = "@reevit/rust";

/// An async client for the Reevit API.
#[derive(Clone)]
pub struct Client {
    pub(crate) inner: reqwest::Client,
    base_url: Url,
    api_key: String,
    org_id: String,
    timeout: Duration,
}

impl fmt::Debug for Client {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("Client")
            .field("base_url", &self.base_url)
            .field("api_key", &"[REDACTED]")
            .field("org_id", &self.org_id)
            .finish_non_exhaustive()
    }
}

impl Client {
    /// Starts configuring a Reevit client.
    pub fn builder(api_key: impl Into<String>, org_id: impl Into<String>) -> ClientBuilder {
        ClientBuilder::new(api_key, org_id)
    }

    /// Returns the payments module.
    #[must_use]
    pub fn payments(&self) -> Payments {
        Payments::new(self.clone())
    }

    /// Returns the checkout sessions module.
    #[must_use]
    pub fn checkout_sessions(&self) -> CheckoutSessions {
        CheckoutSessions::new(self.clone())
    }

    /// Returns the connections module.
    #[must_use]
    pub fn connections(&self) -> Connections {
        Connections::new(self.clone())
    }

    /// Returns the subscriptions module.
    #[must_use]
    pub fn subscriptions(&self) -> Subscriptions {
        Subscriptions::new(self.clone())
    }

    /// Returns the fraud policy module.
    #[must_use]
    pub fn fraud(&self) -> Fraud {
        Fraud::new(self.clone())
    }

    /// Returns the customers module.
    #[must_use]
    pub fn customers(&self) -> Customers {
        Customers::new(self.clone())
    }

    /// Returns the payment links module.
    #[must_use]
    pub fn payment_links(&self) -> PaymentLinks {
        PaymentLinks::new(self.clone())
    }

    /// Returns the webhooks module.
    #[must_use]
    pub fn webhooks(&self) -> Webhooks {
        Webhooks::new(self.clone())
    }

    /// Returns the routing rules module.
    #[must_use]
    pub fn routing_rules(&self) -> RoutingRules {
        RoutingRules::new(self.clone())
    }

    /// Returns the invoices module.
    #[must_use]
    pub fn invoices(&self) -> Invoices {
        Invoices::new(self.clone())
    }

    pub(crate) fn request(&self, method: Method, path: &str) -> Result<RequestBuilder> {
        let url = self
            .base_url
            .join(path.trim_start_matches('/'))
            .map_err(|error| Error::Configuration(format!("invalid request path: {error}")))?;

        Ok(self
            .inner
            .request(method, url)
            .timeout(self.timeout)
            .header(
                USER_AGENT,
                concat!("reevit-rust/", env!("CARGO_PKG_VERSION")),
            )
            .header("X-Reevit-Key", &self.api_key)
            .header("X-Org-Id", &self.org_id)
            .header("X-Reevit-Client", CLIENT_NAME)
            .header("X-Reevit-Client-Version", env!("CARGO_PKG_VERSION")))
    }

    pub(crate) async fn send<T>(
        &self,
        mut request: RequestBuilder,
        options: RequestOptions,
    ) -> Result<T>
    where
        T: DeserializeOwned,
    {
        if let Some(key) = options.idempotency_key {
            request = request.header("Idempotency-Key", key);
        }

        let response = request.send().await?;
        let status = response.status();
        let headers = response.headers().clone();
        let body = response.bytes().await?;

        if !status.is_success() {
            return Err(decode_api_error(status, &headers, &body).into());
        }

        let body: &[u8] = if body.is_empty() { b"null" } else { &body };
        serde_json::from_slice(body).map_err(|source| Error::Decode { status, source })
    }

    pub(crate) async fn send_collection<T>(
        &self,
        request: RequestBuilder,
        key: &str,
    ) -> Result<Vec<T>>
    where
        T: DeserializeOwned,
    {
        let value: serde_json::Value = self.send(request, RequestOptions::default()).await?;
        let collection = match value {
            serde_json::Value::Array(_) => value,
            serde_json::Value::Object(mut object) => object
                .remove(key)
                .ok_or_else(|| Error::UnexpectedResponse(format!("missing `{key}` collection")))?,
            _ => {
                return Err(Error::UnexpectedResponse(format!(
                    "expected an array or an object containing `{key}`"
                )))
            }
        };
        serde_json::from_value(collection)
            .map_err(|error| Error::UnexpectedResponse(error.to_string()))
    }

    pub(crate) async fn post_empty<T>(&self, path: &str, options: RequestOptions) -> Result<T>
    where
        T: DeserializeOwned,
    {
        let request = self
            .request(Method::POST, path)?
            .json(&serde_json::json!({}));
        self.send(request, options).await
    }
}

/// Builder for [`Client`].
pub struct ClientBuilder {
    api_key: String,
    org_id: String,
    base_url: String,
    timeout: Duration,
    http_client: Option<reqwest::Client>,
}

impl ClientBuilder {
    fn new(api_key: impl Into<String>, org_id: impl Into<String>) -> Self {
        Self {
            api_key: api_key.into(),
            org_id: org_id.into(),
            base_url: DEFAULT_BASE_URL.to_owned(),
            timeout: DEFAULT_TIMEOUT,
            http_client: None,
        }
    }

    /// Overrides the default Reevit API base URL.
    #[must_use]
    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
        self.base_url = base_url.into();
        self
    }

    /// Overrides the default 30-second request timeout.
    #[must_use]
    pub fn timeout(mut self, timeout: Duration) -> Self {
        self.timeout = timeout;
        self
    }

    /// Supplies a preconfigured HTTP client.
    #[must_use]
    pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
        self.http_client = Some(http_client);
        self
    }

    /// Validates the configuration and builds the client.
    pub fn build(self) -> Result<Client> {
        if self.api_key.trim().is_empty() {
            return Err(Error::Configuration("api key is required".into()));
        }
        if self.org_id.trim().is_empty() {
            return Err(Error::Configuration("organization id is required".into()));
        }
        HeaderValue::from_str(&self.api_key).map_err(|_| {
            Error::Configuration("api key contains invalid header characters".into())
        })?;
        HeaderValue::from_str(&self.org_id).map_err(|_| {
            Error::Configuration("organization id contains invalid header characters".into())
        })?;

        let mut base_url = Url::parse(&self.base_url)
            .map_err(|error| Error::Configuration(format!("invalid base URL: {error}")))?;
        if base_url.cannot_be_a_base() {
            return Err(Error::Configuration(
                "base URL cannot be used to resolve request paths".into(),
            ));
        }
        if !matches!(base_url.scheme(), "http" | "https") {
            return Err(Error::Configuration(
                "base URL must use the http or https scheme".into(),
            ));
        }
        if !base_url.path().ends_with('/') {
            let path = format!("{}/", base_url.path());
            base_url.set_path(&path);
        }

        let inner = match self.http_client {
            Some(client) => client,
            None => reqwest::Client::builder().timeout(self.timeout).build()?,
        };

        Ok(Client {
            inner,
            base_url,
            api_key: self.api_key,
            org_id: self.org_id,
            timeout: self.timeout,
        })
    }
}

#[derive(Deserialize)]
struct ApiErrorPayload {
    code: Option<String>,
    message: Option<String>,
    details: Option<serde_json::Value>,
}

fn decode_api_error(status: StatusCode, headers: &HeaderMap, body: &[u8]) -> ApiError {
    let payload = serde_json::from_slice::<ApiErrorPayload>(body).ok();
    let message = payload
        .as_ref()
        .and_then(|value| value.message.clone())
        .or_else(|| String::from_utf8(body.to_vec()).ok())
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| {
            status
                .canonical_reason()
                .unwrap_or("request failed")
                .to_owned()
        });
    let request_id = headers
        .get("x-request-id")
        .or_else(|| headers.get("x-reevit-request-id"))
        .and_then(|value| value.to_str().ok())
        .map(str::to_owned);

    ApiError {
        status,
        code: payload.as_ref().and_then(|value| value.code.clone()),
        message,
        details: payload.and_then(|value| value.details),
        request_id,
    }
}