use eyre::{Context, Result};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use url::Url;
use crate::commands::config::config_pb::{Chain, GetConfigResponse, Token};
#[derive(Debug, Clone)]
pub struct JwtToken {
pub token: String,
pub expires_at: u64,
}
pub struct AspensClient {
pub(crate) stack_url: Url,
pub(crate) env_vars: HashMap<String, String>,
pub(crate) config: Arc<RwLock<Option<GetConfigResponse>>>,
pub(crate) jwt_token: Arc<RwLock<Option<JwtToken>>>,
}
impl AspensClient {
pub fn builder() -> AspensClientBuilder {
AspensClientBuilder::default()
}
pub fn stack_url(&self) -> &Url {
&self.stack_url
}
pub fn get_env(&self, key: &str) -> Option<&String> {
self.env_vars.get(key)
}
pub async fn fetch_config(&self) -> Result<()> {
let config = crate::commands::config::call_get_config(self.stack_url.to_string()).await?;
let mut guard = self.config.write().unwrap();
*guard = Some(config);
Ok(())
}
pub async fn get_config(&self) -> Result<GetConfigResponse> {
{
let guard = self.config.read().unwrap();
if let Some(config) = guard.as_ref() {
return Ok(config.clone());
}
}
self.fetch_config().await?;
let guard = self.config.read().unwrap();
guard
.as_ref()
.cloned()
.ok_or_else(|| eyre::eyre!("Failed to fetch configuration"))
}
pub async fn get_chain_info(&self, network: &str) -> Result<Chain> {
let config = self.get_config().await?;
config.get_chain(network).cloned().ok_or_else(|| {
eyre::eyre!(
"Chain '{}' not found in configuration. Available chains: {}",
network,
config
.config
.as_ref()
.map(|c| c
.chains
.iter()
.map(|ch| ch.network.as_str())
.collect::<Vec<_>>()
.join(", "))
.unwrap_or_default()
)
})
}
pub async fn get_token_info(&self, network: &str, symbol: &str) -> Result<Token> {
let config = self.get_config().await?;
config.get_token(network, symbol).cloned().ok_or_else(|| {
let available_tokens = config
.get_chain(network)
.map(|chain| {
chain
.tokens
.keys()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|| "none".to_string());
eyre::eyre!(
"Token '{}' not found on chain '{}'. Available tokens: {}",
symbol,
network,
available_tokens
)
})
}
pub async fn get_trade_contract_address(&self, network: &str) -> Result<String> {
let chain = self.get_chain_info(network).await?;
chain
.trade_contract
.as_ref()
.map(|tc| tc.address.clone())
.ok_or_else(|| {
eyre::eyre!(
"Trade contract not found for chain '{}'. Please ensure the contract is deployed.",
network
)
})
}
pub fn set_jwt_token(&self, token: String, expires_at: u64) {
let mut guard = self.jwt_token.write().unwrap();
*guard = Some(JwtToken { token, expires_at });
}
pub fn get_jwt_token(&self) -> Option<String> {
let guard = self.jwt_token.read().unwrap();
guard.as_ref().and_then(|jwt| {
if self.is_jwt_valid_internal(jwt) {
Some(jwt.token.clone())
} else {
None
}
})
}
pub fn is_jwt_valid(&self) -> bool {
let guard = self.jwt_token.read().unwrap();
guard
.as_ref()
.map(|jwt| self.is_jwt_valid_internal(jwt))
.unwrap_or(false)
}
fn is_jwt_valid_internal(&self, jwt: &JwtToken) -> bool {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
jwt.expires_at > now + 30
}
pub fn clear_jwt_token(&self) {
let mut guard = self.jwt_token.write().unwrap();
*guard = None;
}
pub fn get_jwt_expiry(&self) -> Option<u64> {
let guard = self.jwt_token.read().unwrap();
guard.as_ref().map(|jwt| jwt.expires_at)
}
}
#[derive(Default)]
pub struct AspensClientBuilder {
stack_url: Option<Url>,
env_file_path: Option<String>,
}
impl AspensClientBuilder {
pub fn with_url(mut self, url: impl Into<String>) -> Result<Self> {
let url_str = url.into();
self.stack_url = Some(Url::parse(&url_str).context("Invalid URL")?);
Ok(self)
}
pub fn with_env_file(mut self, path: impl Into<String>) -> Self {
self.env_file_path = Some(path.into());
self
}
pub fn build(self) -> Result<AspensClient> {
let env_file = self.env_file_path.unwrap_or_else(|| ".env".to_string());
let env_vars = load_env_file(&env_file)?;
let stack_url = self
.stack_url
.or_else(|| {
env_vars
.get("ASPENS_MARKET_STACK_URL")
.and_then(|u| Url::parse(u).ok())
})
.unwrap_or_else(|| Url::parse("http://0.0.0.0:50051").unwrap());
Ok(AspensClient {
stack_url,
env_vars,
config: Arc::new(RwLock::new(None)),
jwt_token: Arc::new(RwLock::new(None)),
})
}
}
fn load_env_file(path: &str) -> Result<HashMap<String, String>> {
use std::fs;
use std::io::{BufRead, BufReader};
let mut env_vars = HashMap::new();
if let Ok(file) = fs::File::open(path) {
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let mut value = value.trim().to_string();
if (value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\''))
{
value = value[1..value.len() - 1].to_string();
}
env_vars.insert(key.clone(), value.clone());
std::env::set_var(&key, &value);
}
}
}
Ok(env_vars)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_builder_with_url() {
let client = AspensClient::builder()
.with_url("http://example.com:8080")
.unwrap()
.build()
.unwrap();
assert_eq!(client.stack_url().as_str(), "http://example.com:8080/");
}
#[test]
fn test_env_file_quote_stripping() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "DOUBLE_QUOTED=\"value1\"").unwrap();
writeln!(file, "SINGLE_QUOTED='value2'").unwrap();
writeln!(file, "UNQUOTED=value3").unwrap();
writeln!(file, "# Comment line").unwrap();
writeln!(file, "EMPTY_VALUE=\"\"").unwrap();
file.flush().unwrap();
let env_vars = load_env_file(file.path().to_str().unwrap()).unwrap();
assert_eq!(env_vars.get("DOUBLE_QUOTED"), Some(&"value1".to_string()));
assert_eq!(env_vars.get("SINGLE_QUOTED"), Some(&"value2".to_string()));
assert_eq!(env_vars.get("UNQUOTED"), Some(&"value3".to_string()));
assert_eq!(env_vars.get("EMPTY_VALUE"), Some(&"".to_string()));
}
}