use std::time::Duration;
use reqwest::header::CONTENT_TYPE;
use serde_json::Map;
use url::Url;
use crate::{
error::{BuilderError, WhmcsError},
models::WhmcsRawResponse,
};
pub struct WhmcsClient {
client: reqwest::Client,
url: Url,
api_identifier: String,
api_secret: String,
timeout: u64,
}
impl WhmcsClient {
pub async fn request<P, T>(&self, action: &str, params: P) -> Result<T, WhmcsError>
where
P: serde::Serialize,
T: serde::de::DeserializeOwned,
{
let mut request_body =
serde_json::to_value(params).map_err(WhmcsError::SerializationError)?;
if request_body.is_null() {
request_body = serde_json::Value::Object(Map::new());
}
if let Some(obj) = request_body.as_object_mut() {
obj.insert("action".to_string(), action.to_lowercase().into());
obj.insert("identifier".to_string(), self.api_identifier.clone().into());
obj.insert("secret".to_string(), self.api_secret.clone().into());
obj.insert("responsetype".to_string(), "json".into());
}
let response_text = self
.client
.post(self.url.as_str())
.header(CONTENT_TYPE, "application/x-www-form-urlencoded")
.form(&request_body)
.timeout(Duration::from_secs(self.timeout))
.send()
.await
.map_err(WhmcsError::RequestError)?
.text()
.await
.map_err(WhmcsError::RequestError)?;
match serde_json::from_str::<WhmcsRawResponse<T>>(&response_text)
.map_err(WhmcsError::SerializationError)?
{
WhmcsRawResponse::Success(data) => Ok(data),
WhmcsRawResponse::Error { message } => Err(WhmcsError::ApiError(message)),
}
}
}
pub struct WhmcsBuilder {
url: Option<String>,
api_identifier: Option<String>,
api_secret: Option<String>,
timeout: Option<u64>,
}
impl Default for WhmcsBuilder {
fn default() -> Self {
Self::new()
}
}
impl WhmcsBuilder {
#[must_use]
pub const fn new() -> Self {
Self {
url: None,
api_identifier: None,
api_secret: None,
timeout: None,
}
}
#[must_use]
pub fn url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
#[must_use]
pub fn api_identifier(mut self, api_identifier: impl Into<String>) -> Self {
self.api_identifier = Some(api_identifier.into());
self
}
#[must_use]
pub fn api_secret(mut self, api_secret: impl Into<String>) -> Self {
self.api_secret = Some(api_secret.into());
self
}
#[must_use]
pub fn timeout(mut self, timeout: impl Into<u64>) -> Self {
self.timeout = Some(timeout.into());
self
}
pub fn build(self) -> Result<WhmcsClient, BuilderError> {
let input_url = self
.url
.or_else(|| std::env::var("WHMCS_URL").ok())
.ok_or(BuilderError::UrlNotSet)?
.parse::<Url>()
.map_err(BuilderError::UrlParseError)?;
let mut url = input_url.clone();
if let Some(mut path_segments) = input_url.path_segments() {
let endpoint = path_segments.next_back().unwrap_or_default();
if endpoint.is_empty() {
url.set_path("/includes/api.php");
} else if endpoint != "api.php" {
url.set_path(&format!("{}/includes/api.php", input_url.path()));
}
}
let api_identifier = self
.api_identifier
.or_else(|| std::env::var("WHMCS_API_IDENTIFIER").ok())
.ok_or(BuilderError::ApiIdentifierNotSet)?;
let api_secret = self
.api_secret
.or_else(|| std::env::var("WHMCS_API_SECRET").ok())
.ok_or(BuilderError::ApiSecretNotSet)?;
let timeout = self.timeout.unwrap_or_else(|| {
std::env::var("WHMCS_TIMEOUT")
.unwrap_or_else(|_| "30".to_string())
.parse()
.unwrap_or(30)
});
Ok(WhmcsClient {
client: reqwest::Client::new(),
url,
api_identifier,
api_secret,
timeout,
})
}
}
#[cfg(test)]
mod tests {
use temp_env::with_vars;
use super::{WhmcsBuilder, WhmcsClient};
use crate::error::BuilderError;
#[test]
fn build_client_with_env_vars() {
with_vars(
[
("WHMCS_URL", Some("https://example.com/includes/api.php")),
("WHMCS_API_IDENTIFIER", Some("id")),
("WHMCS_API_SECRET", Some("secret")),
],
|| assert!(WhmcsBuilder::new().build().is_ok()),
);
}
#[test]
fn build_succeeds_with_explicit_credentials() {
let client: WhmcsClient = WhmcsBuilder::new()
.url("https://example.com/includes/api.php")
.api_identifier("id")
.api_secret("secret")
.build()
.unwrap();
let _ = client;
}
#[test]
fn build_fails_url_not_set_when_env_missing() {
with_vars(
[
("WHMCS_URL", None::<&str>),
("WHMCS_API_IDENTIFIER", None::<&str>),
("WHMCS_API_SECRET", None::<&str>),
],
|| match WhmcsBuilder::new().build() {
Err(e) => assert!(matches!(e, BuilderError::UrlNotSet)),
Ok(_) => panic!("expected build to fail"),
},
);
}
#[test]
fn build_fails_api_identifier_not_set() {
with_vars(
[("WHMCS_API_IDENTIFIER", None::<&str>)],
|| match WhmcsBuilder::new()
.url("https://example.com/includes/api.php")
.api_secret("secret")
.build()
{
Err(e) => assert!(matches!(e, BuilderError::ApiIdentifierNotSet)),
Ok(_) => panic!("expected build to fail"),
},
);
}
#[test]
fn build_fails_api_secret_not_set() {
with_vars(
[("WHMCS_API_SECRET", None::<&str>)],
|| match WhmcsBuilder::new()
.url("https://example.com/includes/api.php")
.api_identifier("id")
.build()
{
Err(e) => assert!(matches!(e, BuilderError::ApiSecretNotSet)),
Ok(_) => panic!("expected build to fail"),
},
);
}
#[test]
fn build_fails_on_invalid_url() {
with_vars(
[
("WHMCS_URL", None::<&str>),
("WHMCS_API_IDENTIFIER", None::<&str>),
("WHMCS_API_SECRET", None::<&str>),
],
|| match WhmcsBuilder::new()
.url(":::not-a-valid-url")
.api_identifier("id")
.api_secret("secret")
.build()
{
Err(e) => assert!(matches!(e, BuilderError::UrlParseError(_))),
Ok(_) => panic!("expected build to fail"),
},
);
}
}