use std::fmt;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Product {
Tokenized,
Checkout,
AuthCapture,
Subscriptions,
}
impl Product {
#[must_use]
pub fn service_subdomain(&self) -> &'static str {
match self {
Self::Tokenized | Self::Subscriptions => "tokenized",
Self::Checkout | Self::AuthCapture => "checkout",
}
}
#[must_use]
pub fn token_path(&self) -> &'static str {
match self {
Self::Checkout | Self::AuthCapture => "checkout/token/grant",
Self::Tokenized | Self::Subscriptions => "tokenized/checkout/token/grant",
}
}
#[must_use]
pub fn token_refresh_path(&self) -> &'static str {
"tokenized/checkout/token/refresh"
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Environment {
Sandbox,
Production,
}
impl Environment {
#[must_use]
pub fn sandbox() -> Self {
Self::Sandbox
}
#[must_use]
pub fn production() -> Self {
Self::Production
}
#[must_use]
pub fn base_url(&self, product: Product) -> String {
let host = match self {
Self::Sandbox => "sandbox.bka.sh",
Self::Production => "pay.bka.sh",
};
format!(
"https://{}.{}/v1.2.0-beta/",
product.service_subdomain(),
host
)
}
}
#[derive(Clone)]
pub struct Config {
pub environment: Environment,
pub app_key: String,
pub app_secret: String,
pub username: String,
pub password: String,
pub timeout: Duration,
pub max_retries: u32,
pub http_client: Option<reqwest::Client>,
pub base_url: Option<String>,
}
impl Config {
#[must_use]
pub fn builder() -> ConfigBuilder {
ConfigBuilder::new()
}
#[must_use]
pub fn sandbox() -> ConfigBuilder {
Self::builder().environment(Environment::Sandbox)
}
#[must_use]
pub fn production() -> ConfigBuilder {
Self::builder().environment(Environment::Production)
}
pub fn validate(&self) -> Result<(), crate::Error> {
if self.app_key.trim().is_empty() {
return Err(crate::Error::Config("app_key is required".into()));
}
if self.app_secret.trim().is_empty() {
return Err(crate::Error::Config("app_secret is required".into()));
}
if self.username.trim().is_empty() {
return Err(crate::Error::Config("username is required".into()));
}
if self.password.trim().is_empty() {
return Err(crate::Error::Config("password is required".into()));
}
if self.timeout.is_zero() {
return Err(crate::Error::Config("timeout must be non-zero".into()));
}
Ok(())
}
}
impl fmt::Debug for Config {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Config")
.field("environment", &self.environment)
.field("app_key", &"***redacted***")
.field("app_secret", &"***redacted***")
.field("username", &"***redacted***")
.field("password", &"***redacted***")
.field("timeout", &self.timeout)
.field("max_retries", &self.max_retries)
.field(
"http_client",
&self.http_client.as_ref().map(|_| "<client>"),
)
.field("base_url", &self.base_url)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct ConfigBuilder {
environment: Option<Environment>,
app_key: Option<String>,
app_secret: Option<String>,
username: Option<String>,
password: Option<String>,
timeout: Duration,
max_retries: u32,
http_client: Option<reqwest::Client>,
base_url: Option<String>,
}
impl Default for ConfigBuilder {
fn default() -> Self {
Self::new()
}
}
impl ConfigBuilder {
#[must_use]
pub fn new() -> Self {
Self {
environment: None,
app_key: None,
app_secret: None,
username: None,
password: None,
timeout: Duration::from_secs(30),
max_retries: 2,
http_client: None,
base_url: None,
}
}
#[must_use]
pub fn environment(mut self, env: Environment) -> Self {
self.environment = Some(env);
self
}
#[must_use]
pub fn app_key(mut self, key: impl Into<String>) -> Self {
self.app_key = Some(key.into());
self
}
#[must_use]
pub fn app_secret(mut self, secret: impl Into<String>) -> Self {
self.app_secret = Some(secret.into());
self
}
#[must_use]
pub fn username(mut self, u: impl Into<String>) -> Self {
self.username = Some(u.into());
self
}
#[must_use]
pub fn password(mut self, p: impl Into<String>) -> Self {
self.password = Some(p.into());
self
}
#[must_use]
pub fn timeout(mut self, t: Duration) -> Self {
self.timeout = t;
self
}
#[must_use]
pub fn max_retries(mut self, n: u32) -> Self {
self.max_retries = n;
self
}
#[must_use]
pub fn http_client(mut self, c: reqwest::Client) -> Self {
self.http_client = Some(c);
self
}
#[must_use]
pub fn with_base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = Some(url.into());
self
}
pub fn build(self) -> Result<Config, crate::Error> {
let environment = self
.environment
.ok_or_else(|| crate::Error::Config("environment is required".into()))?;
let app_key = self
.app_key
.ok_or_else(|| crate::Error::Config("app_key is required".into()))?;
let app_secret = self
.app_secret
.ok_or_else(|| crate::Error::Config("app_secret is required".into()))?;
let username = self
.username
.ok_or_else(|| crate::Error::Config("username is required".into()))?;
let password = self
.password
.ok_or_else(|| crate::Error::Config("password is required".into()))?;
let cfg = Config {
environment,
app_key,
app_secret,
username,
password,
timeout: self.timeout,
max_retries: self.max_retries,
http_client: self.http_client,
base_url: self.base_url,
};
cfg.validate()?;
Ok(cfg)
}
pub async fn build_and_connect(self) -> Result<crate::Bkash, crate::Error> {
let config = self.build()?;
crate::Bkash::new(config).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_config() -> Config {
Config {
environment: Environment::Sandbox,
app_key: "secret-app-key".into(),
app_secret: "super-secret".into(),
username: "secret-user".into(),
password: "secret-pass".into(),
timeout: Duration::from_secs(10),
max_retries: 1,
http_client: None,
base_url: None,
}
}
#[test]
fn environment_base_url_tokenized_sandbox() {
let url = Environment::Sandbox.base_url(Product::Tokenized);
assert_eq!(url, "https://tokenized.sandbox.bka.sh/v1.2.0-beta/");
}
#[test]
fn environment_base_url_tokenized_production() {
let url = Environment::Production.base_url(Product::Tokenized);
assert_eq!(url, "https://tokenized.pay.bka.sh/v1.2.0-beta/");
}
#[test]
fn environment_base_url_checkout_sandbox() {
let url = Environment::Sandbox.base_url(Product::Checkout);
assert_eq!(url, "https://checkout.sandbox.bka.sh/v1.2.0-beta/");
}
#[test]
fn environment_base_url_checkout_production() {
let url = Environment::Production.base_url(Product::Checkout);
assert_eq!(url, "https://checkout.pay.bka.sh/v1.2.0-beta/");
}
#[test]
fn environment_base_url_auth_capture() {
assert!(Environment::Sandbox
.base_url(Product::AuthCapture)
.starts_with("https://checkout.sandbox.bka.sh/"));
}
#[test]
fn environment_base_url_subscriptions() {
assert!(Environment::Sandbox
.base_url(Product::Subscriptions)
.starts_with("https://tokenized.sandbox.bka.sh/"));
}
#[test]
fn config_debug_redacts_credentials() {
let cfg = sample_config();
let s = format!("{cfg:?}");
assert!(!s.contains("secret-app-key"), "app_key leaked: {s}");
assert!(!s.contains("super-secret"), "app_secret leaked: {s}");
assert!(!s.contains("secret-user"), "username leaked: {s}");
assert!(!s.contains("secret-pass"), "password leaked: {s}");
assert!(
s.contains("***redacted***"),
"expected redaction marker: {s}"
);
}
#[test]
fn builder_validates_required_fields() {
let r = Config::builder().build();
assert!(r.is_err());
}
#[test]
fn builder_validates_blank_credentials() {
let r = Config::builder()
.environment(Environment::Sandbox)
.app_key(" ")
.app_secret("x")
.username("x")
.password("x")
.build();
assert!(r.is_err());
}
#[test]
fn builder_produces_valid_config() {
let cfg = Config::builder()
.environment(Environment::Sandbox)
.app_key("k")
.app_secret("s")
.username("u")
.password("p")
.build()
.unwrap();
assert_eq!(cfg.environment, Environment::Sandbox);
assert_eq!(cfg.timeout, Duration::from_secs(30));
assert_eq!(cfg.max_retries, 2);
}
#[test]
fn sandbox_and_production_helpers() {
let cfg = Config::sandbox()
.app_key("k")
.app_secret("s")
.username("u")
.password("p")
.build()
.unwrap();
assert_eq!(cfg.environment, Environment::Sandbox);
let cfg = Config::production()
.app_key("k")
.app_secret("s")
.username("u")
.password("p")
.build()
.unwrap();
assert_eq!(cfg.environment, Environment::Production);
}
#[test]
fn with_base_url_overrides() {
let cfg = Config::sandbox()
.app_key("k")
.app_secret("s")
.username("u")
.password("p")
.with_base_url("https://example.test/")
.build()
.unwrap();
assert_eq!(cfg.base_url.as_deref(), Some("https://example.test/"));
}
}