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;
pub const API_VERSION_HEADER: &str = "Snippe-Version";
pub const IDEMPOTENCY_KEY_HEADER: &str = "Idempotency-Key";
#[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 {
pub fn new(api_key: impl Into<String>) -> crate::Result<Self> {
Self::builder().api_key(api_key).build()
}
pub fn builder() -> ClientBuilder {
ClientBuilder::default()
}
pub fn payments(&self) -> Payments<'_> {
Payments::new(self)
}
pub fn sessions(&self) -> Sessions<'_> {
Sessions::new(self)
}
pub fn payouts(&self) -> Payouts<'_> {
Payouts::new(self)
}
pub fn base_url(&self) -> &str {
&self.inner.base_url
}
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()
}
}
#[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 {
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn environment(mut self, environment: Environment) -> Self {
self.base_url = Some(environment.base_url().to_string());
self
}
pub fn api_version(mut self, version: impl Into<String>) -> Self {
self.api_version = version.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn user_agent_suffix(mut self, suffix: impl Into<String>) -> Self {
self.user_agent_suffix = Some(suffix.into());
self
}
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,
}),
})
}
}
#[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| {
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
}
}
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");
}
}