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";
#[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 {
pub fn builder(api_key: impl Into<String>, org_id: impl Into<String>) -> ClientBuilder {
ClientBuilder::new(api_key, org_id)
}
#[must_use]
pub fn payments(&self) -> Payments {
Payments::new(self.clone())
}
#[must_use]
pub fn checkout_sessions(&self) -> CheckoutSessions {
CheckoutSessions::new(self.clone())
}
#[must_use]
pub fn connections(&self) -> Connections {
Connections::new(self.clone())
}
#[must_use]
pub fn subscriptions(&self) -> Subscriptions {
Subscriptions::new(self.clone())
}
#[must_use]
pub fn fraud(&self) -> Fraud {
Fraud::new(self.clone())
}
#[must_use]
pub fn customers(&self) -> Customers {
Customers::new(self.clone())
}
#[must_use]
pub fn payment_links(&self) -> PaymentLinks {
PaymentLinks::new(self.clone())
}
#[must_use]
pub fn webhooks(&self) -> Webhooks {
Webhooks::new(self.clone())
}
#[must_use]
pub fn routing_rules(&self) -> RoutingRules {
RoutingRules::new(self.clone())
}
#[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
}
}
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,
}
}
#[must_use]
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
#[must_use]
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn http_client(mut self, http_client: reqwest::Client) -> Self {
self.http_client = Some(http_client);
self
}
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,
}
}