Skip to main content

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