use std::time::Duration;
use crate::error::TigerError;
use crate::model::enums::Language;
use crate::config::config_parser;
use crate::config::domain;
const DEFAULT_TIMEOUT_SECS: u64 = 15;
const DEFAULT_SERVER_URL: &str = "https://openapi.tigerfintech.com/gateway";
const TIGER_PUBLIC_KEY: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDNF3G8SoEcCZh2rshUbayDgLLrj6rKgzNMxDL2HSnKcB0+GPOsndqSv+a4IBu9+I3fyBp5hkyMMG2+AXugd9pMpy6VxJxlNjhX1MYbNTZJUT4nudki4uh+LMOkIBHOceGNXjgB+cXqmlUnjlqha/HgboeHSnSgpM3dKSJQlIOsDwIDAQAB";
const CONFIG_FILE_NAME: &str = "tiger_openapi_config.properties";
const ENV_TIGER_ID: &str = "TIGEROPEN_TIGER_ID";
const ENV_PRIVATE_KEY: &str = "TIGEROPEN_PRIVATE_KEY";
const ENV_ACCOUNT: &str = "TIGEROPEN_ACCOUNT";
const ENV_TOKEN: &str = "TIGEROPEN_TOKEN";
#[derive(Debug, Clone)]
pub struct ClientConfig {
pub tiger_id: String,
pub private_key: String,
pub account: String,
pub license: Option<String>,
pub language: Language,
pub timezone: Option<String>,
pub timeout: Duration,
pub token: Option<String>,
pub token_refresh_duration: Option<Duration>,
pub server_url: String,
pub quote_server_url: String,
pub tiger_public_key: String,
pub device_id: String,
}
pub struct ClientConfigBuilder {
tiger_id: Option<String>,
private_key: Option<String>,
account: Option<String>,
license: Option<String>,
language: Option<Language>,
timezone: Option<String>,
timeout: Option<Duration>,
token: Option<String>,
token_refresh_duration: Option<Duration>,
server_url: Option<String>,
quote_server_url: Option<String>,
enable_dynamic_domain: bool,
tiger_public_key: Option<String>,
device_id: Option<String>,
skip_auto_discover: bool,
}
impl ClientConfig {
pub fn builder() -> ClientConfigBuilder {
ClientConfigBuilder::new()
}
}
impl ClientConfigBuilder {
pub fn new() -> Self {
Self {
tiger_id: None,
private_key: None,
account: None,
license: None,
language: None,
timezone: None,
timeout: None,
token: None,
token_refresh_duration: None,
server_url: None,
quote_server_url: None,
enable_dynamic_domain: true, tiger_public_key: None,
device_id: None,
skip_auto_discover: false,
}
}
pub fn tiger_id(mut self, id: impl Into<String>) -> Self {
self.tiger_id = Some(id.into());
self
}
pub fn private_key(mut self, key: impl Into<String>) -> Self {
self.private_key = Some(key.into());
self
}
pub fn account(mut self, account: impl Into<String>) -> Self {
self.account = Some(account.into());
self
}
pub fn license(mut self, license: impl Into<String>) -> Self {
self.license = Some(license.into());
self
}
pub fn language(mut self, lang: Language) -> Self {
self.language = Some(lang);
self
}
pub fn timezone(mut self, tz: impl Into<String>) -> Self {
self.timezone = Some(tz.into());
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn enable_dynamic_domain(mut self, enable: bool) -> Self {
self.enable_dynamic_domain = enable;
self
}
pub fn token(mut self, token: impl Into<String>) -> Self {
self.token = Some(token.into());
self
}
pub fn token_refresh_duration(mut self, d: Duration) -> Self {
self.token_refresh_duration = Some(d);
self
}
pub fn tiger_public_key(mut self, key: impl Into<String>) -> Self {
self.tiger_public_key = Some(key.into());
self
}
pub fn quote_server_url(mut self, url: impl Into<String>) -> Self {
self.quote_server_url = Some(url.into());
self
}
pub fn device_id(mut self, id: impl Into<String>) -> Self {
self.device_id = Some(id.into());
self
}
pub fn properties_file(mut self, path: &str) -> Self {
self.skip_auto_discover = true; if let Ok(props) = config_parser::parse_properties_file(path) {
self.apply_properties(&props);
}
self
}
fn apply_properties(&mut self, props: &std::collections::HashMap<String, String>) {
if self.tiger_id.is_none() {
if let Some(v) = props.get("tiger_id") {
self.tiger_id = Some(v.clone());
}
}
if self.private_key.is_none() {
if let Some(v) = props.get("private_key") {
self.private_key = Some(v.clone());
} else if let Some(v) = props.get("private_key_pk8") {
self.private_key = Some(v.clone());
} else if let Some(v) = props.get("private_key_pk1") {
self.private_key = Some(v.clone());
}
}
if self.account.is_none() {
if let Some(v) = props.get("account") {
self.account = Some(v.clone());
}
}
if self.license.is_none() {
if let Some(v) = props.get("license") {
self.license = Some(v.clone());
}
}
if self.language.is_none() {
if let Some(v) = props.get("language") {
match v.as_str() {
"zh_CN" => self.language = Some(Language::ZhCn),
"zh_TW" => self.language = Some(Language::ZhTw),
"en_US" => self.language = Some(Language::EnUs),
_ => {}
}
}
}
if self.timezone.is_none() {
if let Some(v) = props.get("timezone") {
self.timezone = Some(v.clone());
}
}
}
fn auto_discover_paths() -> Vec<String> {
let mut paths = Vec::new();
paths.push(format!("./{}", CONFIG_FILE_NAME));
if let Ok(home) = std::env::var("HOME") {
paths.push(format!("{}/.tigeropen/{}", home, CONFIG_FILE_NAME));
}
paths
}
pub fn build(mut self) -> Result<ClientConfig, TigerError> {
if !self.skip_auto_discover && (self.tiger_id.is_none() || self.private_key.is_none()) {
let candidates = Self::auto_discover_paths();
for path in &candidates {
if let Ok(props) = config_parser::parse_properties_file(path) {
self.apply_properties(&props);
break; }
}
}
if let Ok(v) = std::env::var(ENV_TIGER_ID) {
if !v.is_empty() {
self.tiger_id = Some(v);
}
}
if let Ok(v) = std::env::var(ENV_PRIVATE_KEY) {
if !v.is_empty() {
self.private_key = Some(v);
}
}
if let Ok(v) = std::env::var(ENV_ACCOUNT) {
if !v.is_empty() {
self.account = Some(v);
}
}
if self.token.is_none() {
if let Ok(v) = std::env::var(ENV_TOKEN) {
if !v.is_empty() {
self.token = Some(v);
}
}
}
let (server_url, quote_server_url) = if let Some(url) = self.server_url {
let quote_url = self.quote_server_url.unwrap_or_else(|| url.clone());
(url, quote_url)
} else {
let mut resolved_server = String::new();
let mut resolved_quote = String::new();
if self.enable_dynamic_domain {
let domain_conf = domain::query_domains(self.license.as_deref());
if let Some(url) = domain::resolve_dynamic_server_url(&domain_conf, self.license.as_deref()) {
resolved_server = url;
}
if let Some(url) = domain::resolve_dynamic_quote_server_url(&domain_conf, self.license.as_deref()) {
resolved_quote = url;
}
}
let server = if resolved_server.is_empty() {
DEFAULT_SERVER_URL.to_string()
} else {
resolved_server
};
let quote = if let Some(url) = self.quote_server_url {
url
} else if resolved_quote.is_empty() {
server.clone()
} else {
resolved_quote
};
(server, quote)
};
let tiger_id = self.tiger_id.filter(|s| !s.is_empty()).ok_or_else(|| {
TigerError::Config(format!(
"tiger_id is required. Set it via builder().tiger_id(), env var {}, or a properties file",
ENV_TIGER_ID
))
})?;
let private_key = self.private_key.filter(|s| !s.is_empty()).ok_or_else(|| {
TigerError::Config(format!(
"private_key is required. Set it via builder().private_key(), env var {}, or a properties file",
ENV_PRIVATE_KEY
))
})?;
let device_id = self.device_id.unwrap_or_else(detect_device_id);
Ok(ClientConfig {
tiger_id,
private_key,
account: self.account.unwrap_or_default(),
license: self.license,
language: self.language.unwrap_or(Language::ZhCn),
timezone: self.timezone,
timeout: self.timeout.unwrap_or(Duration::from_secs(DEFAULT_TIMEOUT_SECS)),
token: self.token,
token_refresh_duration: self.token_refresh_duration,
server_url,
quote_server_url,
tiger_public_key: self.tiger_public_key.unwrap_or_else(|| TIGER_PUBLIC_KEY.to_string()),
device_id,
})
}
}
fn detect_device_id() -> String {
match mac_address::get_mac_address() {
Ok(Some(ma)) => ma.to_string(),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::sync::Mutex;
static ENV_MUTEX: Mutex<()> = Mutex::new(());
fn lock_env() -> std::sync::MutexGuard<'static, ()> {
ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
}
fn clear_env_vars() {
std::env::remove_var(ENV_TIGER_ID);
std::env::remove_var(ENV_PRIVATE_KEY);
std::env::remove_var(ENV_ACCOUNT);
std::env::remove_var(ENV_TOKEN);
}
#[test]
fn test_builder_basic_fields() {
let _lock = lock_env();
clear_env_vars();
let config = ClientConfig::builder()
.tiger_id("test_id")
.private_key("test_key")
.account("DU123456")
.build()
.unwrap();
assert_eq!(config.tiger_id, "test_id");
assert_eq!(config.private_key, "test_key");
assert_eq!(config.account, "DU123456");
}
#[test]
fn test_builder_defaults() {
let _lock = lock_env();
clear_env_vars();
let config = ClientConfig::builder()
.tiger_id("test_id")
.private_key("test_key")
.build()
.unwrap();
assert_eq!(config.language, Language::ZhCn);
assert_eq!(config.timeout, Duration::from_secs(15));
assert_eq!(config.server_url, DEFAULT_SERVER_URL);
assert_eq!(config.tiger_public_key, TIGER_PUBLIC_KEY);
}
#[test]
fn test_builder_missing_tiger_id() {
let _lock = lock_env();
clear_env_vars();
let result = ClientConfig::builder()
.properties_file("/nonexistent/path/config.properties")
.private_key("test_key")
.build();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), TigerError::Config(_)));
}
#[test]
fn test_builder_missing_private_key() {
let _lock = lock_env();
clear_env_vars();
let result = ClientConfig::builder()
.properties_file("/nonexistent/path/config.properties")
.tiger_id("test_id")
.build();
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), TigerError::Config(_)));
}
#[test]
fn test_env_overrides_builder() {
let _lock = lock_env();
clear_env_vars();
std::env::set_var(ENV_TIGER_ID, "env_tiger_id");
std::env::set_var(ENV_PRIVATE_KEY, "env_private_key");
std::env::set_var(ENV_ACCOUNT, "env_account");
let config = ClientConfig::builder()
.tiger_id("builder_tiger_id")
.private_key("builder_private_key")
.account("builder_account")
.build()
.unwrap();
assert_eq!(config.tiger_id, "env_tiger_id");
assert_eq!(config.private_key, "env_private_key");
assert_eq!(config.account, "env_account");
clear_env_vars();
}
#[test]
fn test_builder_optional_fields() {
let _lock = lock_env();
clear_env_vars();
let config = ClientConfig::builder()
.tiger_id("test_id")
.private_key("test_key")
.license("TBNZ")
.language(Language::EnUs)
.timezone("America/New_York")
.timeout(Duration::from_secs(30))
.token("my_token")
.token_refresh_duration(Duration::from_secs(3600))
.build()
.unwrap();
assert_eq!(config.license, Some("TBNZ".to_string()));
assert_eq!(config.language, Language::EnUs);
assert_eq!(config.timezone, Some("America/New_York".to_string()));
assert_eq!(config.timeout, Duration::from_secs(30));
assert_eq!(config.token, Some("my_token".to_string()));
assert_eq!(config.token_refresh_duration, Some(Duration::from_secs(3600)));
}
#[test]
fn test_builder_from_properties_file() {
let _lock = lock_env();
clear_env_vars();
let dir = std::env::temp_dir();
let path = dir.join("test_rust_client_config.properties");
std::fs::write(
&path,
"tiger_id=file_tiger_id\nprivate_key=file_private_key\naccount=file_account\nlicense=TBHK\n",
).unwrap();
let config = ClientConfig::builder()
.properties_file(path.to_str().unwrap())
.build()
.unwrap();
assert_eq!(config.tiger_id, "file_tiger_id");
assert_eq!(config.private_key, "file_private_key");
assert_eq!(config.account, "file_account");
assert_eq!(config.license, Some("TBHK".to_string()));
std::fs::remove_file(&path).ok();
}
#[test]
fn test_env_only_overrides_when_set() {
let _lock = lock_env();
clear_env_vars();
std::env::set_var(ENV_TIGER_ID, "env_tiger_id");
let config = ClientConfig::builder()
.tiger_id("builder_tiger_id")
.private_key("builder_private_key")
.account("builder_account")
.build()
.unwrap();
assert_eq!(config.tiger_id, "env_tiger_id");
assert_eq!(config.private_key, "builder_private_key");
assert_eq!(config.account, "builder_account");
clear_env_vars();
}
fn non_empty_string() -> impl Strategy<Value = String> {
"[a-zA-Z0-9_]{1,30}"
}
fn valid_timeout_secs() -> impl Strategy<Value = u64> {
1u64..300u64
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn client_config_field_round_trip(
tiger_id in non_empty_string(),
private_key in non_empty_string(),
account in non_empty_string(),
timeout_secs in valid_timeout_secs(),
) {
let _lock = lock_env();
clear_env_vars();
let config = ClientConfig::builder()
.tiger_id(&tiger_id)
.private_key(&private_key)
.account(&account)
.timeout(Duration::from_secs(timeout_secs))
.build()
.unwrap();
prop_assert_eq!(&config.tiger_id, &tiger_id);
prop_assert_eq!(&config.private_key, &private_key);
prop_assert_eq!(&config.account, &account);
prop_assert_eq!(config.timeout, Duration::from_secs(timeout_secs));
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn env_overrides_builder_values(
env_tiger_id in non_empty_string(),
env_private_key in non_empty_string(),
env_account in non_empty_string(),
builder_tiger_id in non_empty_string(),
builder_private_key in non_empty_string(),
builder_account in non_empty_string(),
) {
let _lock = lock_env();
clear_env_vars();
std::env::set_var(ENV_TIGER_ID, &env_tiger_id);
std::env::set_var(ENV_PRIVATE_KEY, &env_private_key);
std::env::set_var(ENV_ACCOUNT, &env_account);
let config = ClientConfig::builder()
.tiger_id(&builder_tiger_id)
.private_key(&builder_private_key)
.account(&builder_account)
.build()
.unwrap();
prop_assert_eq!(&config.tiger_id, &env_tiger_id);
prop_assert_eq!(&config.private_key, &env_private_key);
prop_assert_eq!(&config.account, &env_account);
clear_env_vars();
}
}
}