bitget_rs 0.1.0

Bitget V2 futures SDK
Documentation
use crate::error::Error;
use std::env;
use std::path::PathBuf;
use std::sync::Once;

pub const DEFAULT_API_URL: &str = "https://api.bitget.com";
pub const DEFAULT_API_TIMEOUT_MS: u64 = 5_000;
pub const DEFAULT_WS_PUBLIC_URL: &str = "wss://ws.bitget.com/v2/ws/public";
pub const DEFAULT_WS_PRIVATE_URL: &str = "wss://ws.bitget.com/v2/ws/private";

static INIT_ENV: Once = Once::new();

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
    pub api_url: String,
    pub api_timeout_ms: u64,
    pub proxy_url: Option<String>,
    pub ws_public_url: String,
    pub ws_private_url: String,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            api_url: DEFAULT_API_URL.to_string(),
            api_timeout_ms: DEFAULT_API_TIMEOUT_MS,
            proxy_url: None,
            ws_public_url: DEFAULT_WS_PUBLIC_URL.to_string(),
            ws_private_url: DEFAULT_WS_PRIVATE_URL.to_string(),
        }
    }
}

impl Config {
    pub fn from_env() -> Self {
        init_env();
        Self::from_lookup(|key| env::var(key).ok())
    }

    pub fn from_lookup<F>(lookup: F) -> Self
    where
        F: Fn(&str) -> Option<String>,
    {
        let mut config = Self::default();
        if let Some(api_url) = env_any_with(&lookup, &["BITGET_API_URL", "bitget_api_url"]) {
            config.api_url = api_url;
        }
        if let Some(ws_public_url) =
            env_any_with(&lookup, &["BITGET_WS_PUBLIC_URL", "bitget_ws_public_url"])
        {
            config.ws_public_url = ws_public_url;
        }
        if let Some(ws_private_url) =
            env_any_with(&lookup, &["BITGET_WS_PRIVATE_URL", "bitget_ws_private_url"])
        {
            config.ws_private_url = ws_private_url;
        }
        if let Some(timeout) =
            env_any_with(&lookup, &["BITGET_API_TIMEOUT_MS", "bitget_api_timeout_ms"])
                .and_then(|value| value.parse::<u64>().ok())
        {
            config.api_timeout_ms = timeout;
        }
        config.proxy_url = env_any_with(
            &lookup,
            &[
                "BITGET_PROXY_URL",
                "bitget_proxy_url",
                "ALL_PROXY",
                "all_proxy",
                "HTTPS_PROXY",
                "https_proxy",
            ],
        )
        .and_then(normalize_proxy_url);

        config
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Credentials {
    pub api_key: String,
    pub api_secret: String,
    pub passphrase: String,
}

impl Credentials {
    pub fn new(
        api_key: impl Into<String>,
        api_secret: impl Into<String>,
        passphrase: impl Into<String>,
    ) -> Self {
        Self {
            api_key: api_key.into(),
            api_secret: api_secret.into(),
            passphrase: passphrase.into(),
        }
    }

    pub fn from_env() -> Result<Self, Error> {
        init_env();

        Self::from_lookup(|key| env::var(key).ok())
    }

    pub fn from_lookup<F>(lookup: F) -> Result<Self, Error>
    where
        F: Fn(&str) -> Option<String>,
    {
        let api_key = env_any_with(&lookup, &["BITGET_API_KEY", "bitget_api_key"])
            .ok_or_else(|| Error::ConfigError("缺少环境变量: BITGET_API_KEY".to_string()))?;
        let api_secret = env_any_with(&lookup, &["BITGET_API_SECRET", "bitget_api_secret"])
            .ok_or_else(|| Error::ConfigError("缺少环境变量: BITGET_API_SECRET".to_string()))?;
        let passphrase = env_any_with(
            &lookup,
            &[
                "BITGET_PASSPHRASE",
                "BITGET_API_PASSPHRASE",
                "bitget_PASSPHRASE",
                "bitget_passphrase",
                "bitget_api_passphrase",
            ],
        )
        .ok_or_else(|| Error::ConfigError("缺少环境变量: BITGET_PASSPHRASE".to_string()))?;

        Ok(Self::new(api_key, api_secret, passphrase))
    }
}

pub fn init_env() {
    INIT_ENV.call_once(|| {
        if dotenv::dotenv().is_ok() {
            return;
        }

        for candidate in env_file_candidates() {
            if candidate.exists() {
                let _ = dotenv::from_path(candidate);
                break;
            }
        }
    });
}

fn env_any_with<F>(lookup: &F, names: &[&str]) -> Option<String>
where
    F: Fn(&str) -> Option<String>,
{
    names.iter().find_map(|name| lookup(name))
}

fn normalize_proxy_url(value: String) -> Option<String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        return None;
    }

    if let Some(rest) = trimmed.strip_prefix("socks5://") {
        return Some(format!("socks5h://{rest}"));
    }

    Some(trimmed.to_string())
}

fn env_file_candidates() -> Vec<PathBuf> {
    let mut candidates = Vec::new();
    if let Ok(mut dir) = env::current_dir() {
        loop {
            candidates.push(dir.join(".env"));
            if !dir.pop() {
                break;
            }
        }
    }
    candidates
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn config_reads_proxy_and_normalizes_socks5() {
        let config = Config::from_lookup(|key| match key {
            "BITGET_PROXY_URL" => Some("socks5://127.0.0.1:7897".to_string()),
            _ => None,
        });

        assert_eq!(
            config.proxy_url,
            Some("socks5h://127.0.0.1:7897".to_string())
        );
    }

    #[test]
    fn credentials_accept_existing_mixed_case_passphrase_name() {
        let credentials = Credentials::from_lookup(|key| match key {
            "bitget_api_key" => Some("key".to_string()),
            "bitget_api_secret" => Some("secret".to_string()),
            "bitget_PASSPHRASE" => Some("pass".to_string()),
            _ => None,
        })
        .unwrap();

        assert_eq!(credentials.passphrase, "pass");
    }
}