longbridge/
config.rs

1use std::{
2    collections::HashMap,
3    fmt::{self, Display},
4    path::{Path, PathBuf},
5    str::FromStr,
6    sync::Arc,
7};
8
9pub(crate) use http::{HeaderValue, Request, header};
10use longbridge_httpcli::{HttpClient, HttpClientConfig, is_cn};
11use longbridge_oauth::OAuth;
12use num_enum::IntoPrimitive;
13use tokio_tungstenite::tungstenite::client::IntoClientRequest;
14use tracing::{Level, Subscriber, subscriber::NoSubscriber};
15use tracing_appender::rolling::{RollingFileAppender, Rotation};
16use tracing_subscriber::{filter::Targets, layer::SubscriberExt};
17
18use crate::error::Result;
19
20const DEFAULT_QUOTE_WS_URL: &str = "wss://openapi-quote.longbridge.com/v2";
21const DEFAULT_TRADE_WS_URL: &str = "wss://openapi-trade.longbridge.com/v2";
22const DEFAULT_QUOTE_WS_URL_CN: &str = "wss://openapi-quote.longportapp.cn/v2";
23const DEFAULT_TRADE_WS_URL_CN: &str = "wss://openapi-trade.longportapp.cn/v2";
24
25/// Language identifier
26#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, IntoPrimitive)]
27#[allow(non_camel_case_types)]
28#[repr(i32)]
29pub enum Language {
30    /// zh-CN
31    ZH_CN = 0,
32    /// zh-HK
33    ZH_HK = 2,
34    /// en
35    #[default]
36    EN = 1,
37}
38
39impl Language {
40    pub(crate) fn as_str(&self) -> &'static str {
41        match self {
42            Language::ZH_CN => "zh-CN",
43            Language::ZH_HK => "zh-HK",
44            Language::EN => "en",
45        }
46    }
47}
48
49impl FromStr for Language {
50    type Err = ();
51
52    fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
53        match s {
54            "zh-CN" => Ok(Language::ZH_CN),
55            "zh-HK" => Ok(Language::ZH_HK),
56            "en" => Ok(Language::EN),
57            _ => Err(()),
58        }
59    }
60}
61
62impl Display for Language {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.write_str(self.as_str())
65    }
66}
67
68/// Push mode for candlestick
69#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
70pub enum PushCandlestickMode {
71    /// Realtime mode
72    #[default]
73    Realtime,
74    /// Confirmed mode
75    Confirmed,
76}
77
78/// Internal authentication mode (not part of the public API)
79pub(crate) enum AuthMode {
80    /// Legacy API Key mode (HMAC-SHA256 signed requests)
81    ApiKey {
82        app_key: String,
83        app_secret: String,
84        access_token: String,
85    },
86    /// OAuth 2.0 mode
87    OAuth(OAuth),
88}
89
90impl Clone for AuthMode {
91    fn clone(&self) -> Self {
92        match self {
93            AuthMode::ApiKey {
94                app_key,
95                app_secret,
96                access_token,
97            } => AuthMode::ApiKey {
98                app_key: app_key.clone(),
99                app_secret: app_secret.clone(),
100                access_token: access_token.clone(),
101            },
102            AuthMode::OAuth(oauth) => AuthMode::OAuth(oauth.clone()),
103        }
104    }
105}
106
107impl fmt::Debug for AuthMode {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        match self {
110            AuthMode::ApiKey { app_key, .. } => {
111                f.debug_struct("ApiKey").field("app_key", app_key).finish()
112            }
113            AuthMode::OAuth(_) => f.debug_struct("OAuth").finish(),
114        }
115    }
116}
117
118/// Configuration options for Longbridge SDK
119#[derive(Debug, Clone)]
120pub struct Config {
121    pub(crate) auth: AuthMode,
122    pub(crate) http_url: Option<String>,
123    pub(crate) quote_ws_url: Option<String>,
124    pub(crate) trade_ws_url: Option<String>,
125    pub(crate) enable_overnight: Option<bool>,
126    pub(crate) push_candlestick_mode: Option<PushCandlestickMode>,
127    pub(crate) enable_print_quote_packages: bool,
128    pub(crate) language: Language,
129    pub(crate) log_path: Option<PathBuf>,
130}
131
132/// Reads an env var by trying `LONGBRIDGE_<suffix>` first, then falling back
133/// to `LONGPORT_<suffix>`.  Returns `None` if neither is set.
134fn env_var(suffix: &str) -> Option<String> {
135    std::env::var(format!("LONGBRIDGE_{suffix}"))
136        .ok()
137        .or_else(|| std::env::var(format!("LONGPORT_{suffix}")).ok())
138}
139
140/// Like [`env_var`] but returns an error if the variable is not set.
141fn env_var_required(suffix: &str) -> Result<String> {
142    env_var(suffix).ok_or_else(|| {
143        longbridge_httpcli::HttpClientError::MissingEnvVar {
144            name: format!("LONGBRIDGE_{suffix}"),
145        }
146        .into()
147    })
148}
149
150/// Non-credential environment variables shared by `from_apikey` and
151/// `from_oauth`.  Callers must have already invoked `dotenv::dotenv()`.
152struct ConfigExtras {
153    http_url: Option<String>,
154    quote_ws_url: Option<String>,
155    trade_ws_url: Option<String>,
156    language: Language,
157    enable_overnight: Option<bool>,
158    push_candlestick_mode: Option<PushCandlestickMode>,
159    enable_print_quote_packages: bool,
160    log_path: Option<PathBuf>,
161}
162
163impl ConfigExtras {
164    fn from_env() -> Self {
165        let language = env_var("LANGUAGE")
166            .and_then(|v| v.parse::<Language>().ok())
167            .unwrap_or(Language::EN);
168        let enable_overnight = env_var("ENABLE_OVERNIGHT").map(|v| v == "true");
169        let push_candlestick_mode = env_var("PUSH_CANDLESTICK_MODE").map(|v| match v.as_str() {
170            "confirmed" => PushCandlestickMode::Confirmed,
171            _ => PushCandlestickMode::Realtime,
172        });
173        let enable_print_quote_packages =
174            env_var("PRINT_QUOTE_PACKAGES").as_deref().unwrap_or("true") == "true";
175        Self {
176            http_url: env_var("HTTP_URL"),
177            quote_ws_url: env_var("QUOTE_WS_URL"),
178            trade_ws_url: env_var("TRADE_WS_URL"),
179            language,
180            enable_overnight,
181            push_candlestick_mode,
182            enable_print_quote_packages,
183            log_path: env_var("LOG_PATH").map(PathBuf::from),
184        }
185    }
186}
187
188impl Config {
189    /// Create a new `Config` using API Key authentication.
190    ///
191    /// All optional environment variables (`LONGBRIDGE_HTTP_URL`,
192    /// `LONGBRIDGE_LANGUAGE`, `LONGBRIDGE_QUOTE_WS_URL`,
193    /// `LONGBRIDGE_TRADE_WS_URL`, `LONGBRIDGE_ENABLE_OVERNIGHT`,
194    /// `LONGBRIDGE_PUSH_CANDLESTICK_MODE`,
195    /// `LONGBRIDGE_PRINT_QUOTE_PACKAGES`, `LONGBRIDGE_LOG_PATH`) are read from
196    /// the environment (or `.env` file) and applied automatically if set.
197    ///
198    /// For OAuth 2.0, use [`Config::from_oauth`] together with
199    /// [`longbridge::oauth::OAuthBuilder`] instead.
200    pub fn from_apikey(
201        app_key: impl Into<String>,
202        app_secret: impl Into<String>,
203        access_token: impl Into<String>,
204    ) -> Self {
205        let _ = dotenv::dotenv();
206        let extras = ConfigExtras::from_env();
207        Self {
208            auth: AuthMode::ApiKey {
209                app_key: app_key.into(),
210                app_secret: app_secret.into(),
211                access_token: access_token.into(),
212            },
213            http_url: extras.http_url,
214            quote_ws_url: extras.quote_ws_url,
215            trade_ws_url: extras.trade_ws_url,
216            language: extras.language,
217            enable_overnight: extras.enable_overnight,
218            push_candlestick_mode: extras.push_candlestick_mode,
219            enable_print_quote_packages: extras.enable_print_quote_packages,
220            log_path: extras.log_path,
221        }
222    }
223
224    /// Create a new `Config` for OAuth 2.0 authentication.
225    ///
226    /// All optional environment variables (`LONGBRIDGE_HTTP_URL`,
227    /// `LONGBRIDGE_LANGUAGE`, `LONGBRIDGE_QUOTE_WS_URL`,
228    /// `LONGBRIDGE_TRADE_WS_URL`, `LONGBRIDGE_ENABLE_OVERNIGHT`,
229    /// `LONGBRIDGE_PUSH_CANDLESTICK_MODE`,
230    /// `LONGBRIDGE_PRINT_QUOTE_PACKAGES`, `LONGBRIDGE_LOG_PATH`) are read from
231    /// the environment (or `.env` file) and applied automatically if set.
232    ///
233    /// # Arguments
234    ///
235    /// * `oauth` - An [`OAuth`] client obtained from
236    ///   [`longbridge::oauth::OAuthBuilder`].
237    ///
238    /// # Example
239    ///
240    /// ```rust,no_run
241    /// use std::sync::Arc;
242    ///
243    /// use longbridge::{Config, oauth::OAuthBuilder};
244    ///
245    /// #[tokio::main]
246    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
247    ///     let oauth = OAuthBuilder::new("your-client-id")
248    ///         .build(|url| println!("Visit: {url}"))
249    ///         .await?;
250    ///     let config = Arc::new(Config::from_oauth(oauth));
251    ///
252    ///     let (ctx, receiver) = longbridge::quote::QuoteContext::try_new(config).await?;
253    ///     Ok(())
254    /// }
255    /// ```
256    pub fn from_oauth(oauth: OAuth) -> Self {
257        let _ = dotenv::dotenv();
258        let extras = ConfigExtras::from_env();
259        Self {
260            auth: AuthMode::OAuth(oauth),
261            http_url: extras.http_url,
262            quote_ws_url: extras.quote_ws_url,
263            trade_ws_url: extras.trade_ws_url,
264            language: extras.language,
265            enable_overnight: extras.enable_overnight,
266            push_candlestick_mode: extras.push_candlestick_mode,
267            enable_print_quote_packages: extras.enable_print_quote_packages,
268            log_path: extras.log_path,
269        }
270    }
271
272    /// Create a new `Config` from environment variables (API Key
273    /// authentication).
274    ///
275    /// It first loads the environment variables from the `.env` file in the
276    /// current directory.
277    ///
278    /// # Variables
279    ///
280    /// - `LONGBRIDGE_APP_KEY` - App key
281    /// - `LONGBRIDGE_APP_SECRET` - App secret
282    /// - `LONGBRIDGE_ACCESS_TOKEN` - Access token
283    /// - `LONGBRIDGE_LANGUAGE` - Language identifier, `zh-CN`, `zh-HK` or `en`
284    ///   (Default: `en`)
285    /// - `LONGBRIDGE_HTTP_URL` - HTTP endpoint url (Default: `https://openapi.longbridge.com`)
286    /// - `LONGBRIDGE_QUOTE_WS_URL` - Quote websocket endpoint url (Default:
287    ///   `wss://openapi-quote.longbridge.com/v2`)
288    /// - `LONGBRIDGE_TRADE_WS_URL` - Trade websocket endpoint url (Default:
289    ///   `wss://openapi-trade.longbridge.com/v2`)
290    /// - `LONGBRIDGE_ENABLE_OVERNIGHT` - Enable overnight quote, `true` or
291    ///   `false` (Default: `false`)
292    /// - `LONGBRIDGE_PUSH_CANDLESTICK_MODE` - `realtime` or `confirmed`
293    ///   (Default: `realtime`)
294    /// - `LONGBRIDGE_PRINT_QUOTE_PACKAGES` - Print quote packages when
295    ///   connected, `true` or `false` (Default: `true`)
296    /// - `LONGBRIDGE_LOG_PATH` - Set the path of the log files (Default: `no
297    ///   logs`)
298    ///
299    /// For OAuth 2.0 authentication use [`from_oauth`](Config::from_oauth)
300    /// together with [`OAuthBuilder`](longbridge_oauth::OAuthBuilder).
301    pub fn from_apikey_env() -> Result<Self> {
302        let _ = dotenv::dotenv();
303
304        let app_key = env_var_required("APP_KEY")?;
305        let app_secret = env_var_required("APP_SECRET")?;
306        let access_token = env_var_required("ACCESS_TOKEN")?;
307        let extras = ConfigExtras::from_env();
308
309        Ok(Config {
310            auth: AuthMode::ApiKey {
311                app_key,
312                app_secret,
313                access_token,
314            },
315            http_url: extras.http_url,
316            quote_ws_url: extras.quote_ws_url,
317            trade_ws_url: extras.trade_ws_url,
318            language: extras.language,
319            enable_overnight: extras.enable_overnight,
320            push_candlestick_mode: extras.push_candlestick_mode,
321            enable_print_quote_packages: extras.enable_print_quote_packages,
322            log_path: extras.log_path,
323        })
324    }
325
326    /// Specifies the url of the OpenAPI server.
327    ///
328    /// Default: `https://openapi.longbridge.com`
329    ///
330    /// NOTE: Usually you don't need to change it.
331    #[must_use]
332    pub fn http_url(mut self, url: impl Into<String>) -> Self {
333        self.http_url = Some(url.into());
334        self
335    }
336
337    /// Specifies the url of the OpenAPI quote websocket server.
338    ///
339    /// Default: `wss://openapi-quote.longbridge.com`
340    ///
341    /// NOTE: Usually you don't need to change it.
342    #[must_use]
343    pub fn quote_ws_url(self, url: impl Into<String>) -> Self {
344        Self {
345            quote_ws_url: Some(url.into()),
346            ..self
347        }
348    }
349
350    /// Specifies the url of the OpenAPI trade websocket server.
351    ///
352    /// Default: `wss://openapi-trade.longbridge.com/v2`
353    ///
354    /// NOTE: Usually you don't need to change it.
355    #[must_use]
356    pub fn trade_ws_url(self, url: impl Into<String>) -> Self {
357        Self {
358            trade_ws_url: Some(url.into()),
359            ..self
360        }
361    }
362
363    /// Specifies the language
364    ///
365    /// Default: `Language::EN`
366    pub fn language(self, language: Language) -> Self {
367        Self { language, ..self }
368    }
369
370    /// Enable overnight quote
371    ///
372    /// Default: `false`
373    pub fn enable_overnight(self) -> Self {
374        Self {
375            enable_overnight: Some(true),
376            ..self
377        }
378    }
379
380    /// Specifies the push candlestick mode
381    ///
382    /// Default: `PushCandlestickMode::Realtime`
383    pub fn push_candlestick_mode(self, mode: PushCandlestickMode) -> Self {
384        Self {
385            push_candlestick_mode: Some(mode),
386            ..self
387        }
388    }
389
390    /// Disable printing the opened quote packages when connected to the server.
391    pub fn dont_print_quote_packages(self) -> Self {
392        Self {
393            enable_print_quote_packages: false,
394            ..self
395        }
396    }
397
398    /// Create metadata for auth/reconnect request
399    pub fn create_metadata(&self) -> HashMap<String, String> {
400        let mut metadata = HashMap::new();
401        metadata.insert("accept-language".to_string(), self.language.to_string());
402        if self.enable_overnight.unwrap_or_default() {
403            metadata.insert("need_over_night_quote".to_string(), "true".to_string());
404        }
405        metadata
406    }
407
408    #[inline]
409    pub(crate) fn create_http_client(&self) -> HttpClient {
410        let mut config = match &self.auth {
411            AuthMode::ApiKey {
412                app_key,
413                app_secret,
414                access_token,
415            } => HttpClientConfig::from_apikey(app_key, app_secret, access_token),
416            AuthMode::OAuth(oauth) => HttpClientConfig::from_oauth(oauth.clone()),
417        };
418        if let Some(url) = &self.http_url {
419            config = config.http_url(url.clone());
420        }
421
422        HttpClient::new(config).header(header::ACCEPT_LANGUAGE, self.language.as_str())
423    }
424
425    fn create_ws_request(&self, url: &str) -> tokio_tungstenite::tungstenite::Result<Request<()>> {
426        let mut request = url.into_client_request()?;
427        request.headers_mut().append(
428            header::ACCEPT_LANGUAGE,
429            HeaderValue::from_str(self.language.as_str()).unwrap(),
430        );
431        Ok(request)
432    }
433
434    pub(crate) async fn create_quote_ws_request(
435        &self,
436    ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
437        match self.quote_ws_url.as_deref() {
438            Some(url) => (url, self.create_ws_request(url)),
439            None => {
440                let url = if is_cn().await {
441                    DEFAULT_QUOTE_WS_URL_CN
442                } else {
443                    DEFAULT_QUOTE_WS_URL
444                };
445                (url, self.create_ws_request(url))
446            }
447        }
448    }
449
450    pub(crate) async fn create_trade_ws_request(
451        &self,
452    ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
453        match self.trade_ws_url.as_deref() {
454            Some(url) => (url, self.create_ws_request(url)),
455            None => {
456                let url = if is_cn().await {
457                    DEFAULT_TRADE_WS_URL_CN
458                } else {
459                    DEFAULT_TRADE_WS_URL
460                };
461                (url, self.create_ws_request(url))
462            }
463        }
464    }
465
466    /// Specifies the path of the log file
467    ///
468    /// Default: `None`
469    pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
470        self.log_path = Some(path.into());
471        self
472    }
473
474    /// Set the HTTP endpoint URL in place.
475    pub fn set_http_url(&mut self, url: impl Into<String>) {
476        self.http_url = Some(url.into());
477    }
478
479    /// Set the quote websocket endpoint URL in place.
480    pub fn set_quote_ws_url(&mut self, url: impl Into<String>) {
481        self.quote_ws_url = Some(url.into());
482    }
483
484    /// Set the trade websocket endpoint URL in place.
485    pub fn set_trade_ws_url(&mut self, url: impl Into<String>) {
486        self.trade_ws_url = Some(url.into());
487    }
488
489    /// Set the language in place.
490    pub fn set_language(&mut self, language: Language) {
491        self.language = language;
492    }
493
494    /// Enable overnight quote in place.
495    pub fn set_enable_overnight(&mut self) {
496        self.enable_overnight = Some(true);
497    }
498
499    /// Set the push candlestick mode in place.
500    pub fn set_push_candlestick_mode(&mut self, mode: PushCandlestickMode) {
501        self.push_candlestick_mode = Some(mode);
502    }
503
504    /// Disable printing quote packages in place.
505    pub fn set_dont_print_quote_packages(&mut self) {
506        self.enable_print_quote_packages = false;
507    }
508
509    /// Set the log path in place.
510    pub fn set_log_path(&mut self, path: impl Into<PathBuf>) {
511        self.log_path = Some(path.into());
512    }
513
514    pub(crate) fn create_log_subscriber(
515        &self,
516        path: impl AsRef<Path>,
517    ) -> Arc<dyn Subscriber + Send + Sync> {
518        fn internal_create_log_subscriber(
519            config: &Config,
520            path: impl AsRef<Path>,
521        ) -> Option<Arc<dyn Subscriber + Send + Sync>> {
522            let log_path = config.log_path.as_ref()?;
523            let appender = RollingFileAppender::builder()
524                .rotation(Rotation::DAILY)
525                .filename_suffix("log")
526                .build(log_path.join(path))
527                .ok()?;
528            Some(Arc::new(
529                tracing_subscriber::fmt()
530                    .with_writer(appender)
531                    .with_ansi(false)
532                    .finish()
533                    .with(Targets::new().with_targets([("longbridge", Level::INFO)])),
534            ))
535        }
536
537        internal_create_log_subscriber(self, path).unwrap_or_else(|| Arc::new(NoSubscriber::new()))
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544
545    #[test]
546    fn test_config_from_apikey() {
547        let config = Config::from_apikey("app-key", "app-secret", "token");
548        assert_eq!(config.language, Language::EN);
549        match &config.auth {
550            AuthMode::ApiKey {
551                app_key,
552                app_secret,
553                access_token,
554            } => {
555                assert_eq!(app_key, "app-key");
556                assert_eq!(app_secret, "app-secret");
557                assert_eq!(access_token, "token");
558            }
559            _ => panic!("Expected ApiKey auth mode"),
560        }
561    }
562
563    #[test]
564    fn test_config_default_values() {
565        let config = Config::from_apikey("key", "secret", "token");
566
567        assert_eq!(config.language, Language::EN);
568        assert_eq!(config.quote_ws_url, None);
569        assert_eq!(config.trade_ws_url, None);
570        assert_eq!(config.enable_overnight, None);
571        assert_eq!(config.push_candlestick_mode, None);
572        assert!(config.enable_print_quote_packages);
573        assert_eq!(config.log_path, None);
574    }
575}