Skip to main content

longport/
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 longport_httpcli::{HttpClient, HttpClientConfig, Json, Method, is_cn};
11use longport_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.longportapp.com/v2";
23const DEFAULT_TRADE_WS_URL: &str = "wss://openapi-trade.longportapp.com/v2";
24const DEFAULT_QUOTE_WS_URL_CN: &str = "wss://openapi-quote.longport.cn/v2";
25const DEFAULT_TRADE_WS_URL_CN: &str = "wss://openapi-trade.longport.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 Longport 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
136fn env_var(suffix: &str) -> Option<String> {
137    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        longport_httpcli::HttpClientError::MissingEnvVar {
144            name: format!("LONGPORT_{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 (`LONGPORT_HTTP_URL`,
192    /// `LONGPORT_LANGUAGE`, `LONGPORT_QUOTE_WS_URL`,
193    /// `LONGPORT_TRADE_WS_URL`, `LONGPORT_ENABLE_OVERNIGHT`,
194    /// `LONGPORT_PUSH_CANDLESTICK_MODE`,
195    /// `LONGPORT_PRINT_QUOTE_PACKAGES`, `LONGPORT_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    /// [`longport::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            custom_headers: Default::default(),
222        }
223    }
224
225    /// Create a new `Config` for OAuth 2.0 authentication.
226    ///
227    /// All optional environment variables (`LONGPORT_HTTP_URL`,
228    /// `LONGPORT_LANGUAGE`, `LONGPORT_QUOTE_WS_URL`,
229    /// `LONGPORT_TRADE_WS_URL`, `LONGPORT_ENABLE_OVERNIGHT`,
230    /// `LONGPORT_PUSH_CANDLESTICK_MODE`,
231    /// `LONGPORT_PRINT_QUOTE_PACKAGES`, `LONGPORT_LOG_PATH`) are read from
232    /// the environment (or `.env` file) and applied automatically if set.
233    ///
234    /// # Arguments
235    ///
236    /// * `oauth` - An [`OAuth`] client obtained from
237    ///   [`longport::oauth::OAuthBuilder`].
238    ///
239    /// # Example
240    ///
241    /// ```rust,no_run
242    /// use std::sync::Arc;
243    ///
244    /// use longport::{Config, oauth::OAuthBuilder};
245    ///
246    /// #[tokio::main]
247    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
248    ///     let oauth = OAuthBuilder::new("your-client-id")
249    ///         .build(|url| println!("Visit: {url}"))
250    ///         .await?;
251    ///     let config = Arc::new(Config::from_oauth(oauth));
252    ///
253    ///     let (ctx, receiver) = longport::quote::QuoteContext::new(config);
254    ///     Ok(())
255    /// }
256    /// ```
257    pub fn from_oauth(oauth: OAuth) -> Self {
258        let _ = dotenv::dotenv();
259        let extras = ConfigExtras::from_env();
260        Self {
261            auth: AuthMode::OAuth(oauth),
262            http_url: extras.http_url,
263            quote_ws_url: extras.quote_ws_url,
264            trade_ws_url: extras.trade_ws_url,
265            language: extras.language,
266            enable_overnight: extras.enable_overnight,
267            push_candlestick_mode: extras.push_candlestick_mode,
268            enable_print_quote_packages: extras.enable_print_quote_packages,
269            log_path: extras.log_path,
270            custom_headers: Default::default(),
271        }
272    }
273
274    /// Create a new `Config` from environment variables (API Key
275    /// authentication).
276    ///
277    /// It first loads the environment variables from the `.env` file in the
278    /// current directory.
279    ///
280    /// # Variables
281    ///
282    /// - `LONGPORT_APP_KEY` - App key
283    /// - `LONGPORT_APP_SECRET` - App secret
284    /// - `LONGPORT_ACCESS_TOKEN` - Access token
285    /// - `LONGPORT_LANGUAGE` - Language identifier, `zh-CN`, `zh-HK` or `en`
286    ///   (Default: `en`)
287    /// - `LONGPORT_HTTP_URL` - HTTP endpoint url (Default: `https://openapi.longportapp.com`)
288    /// - `LONGPORT_QUOTE_WS_URL` - Quote websocket endpoint url (Default:
289    ///   `wss://openapi-quote.longportapp.com/v2`)
290    /// - `LONGPORT_TRADE_WS_URL` - Trade websocket endpoint url (Default:
291    ///   `wss://openapi-trade.longportapp.com/v2`)
292    /// - `LONGPORT_ENABLE_OVERNIGHT` - Enable overnight quote, `true` or
293    ///   `false` (Default: `false`)
294    /// - `LONGPORT_PUSH_CANDLESTICK_MODE` - `realtime` or `confirmed` (Default:
295    ///   `realtime`)
296    /// - `LONGPORT_PRINT_QUOTE_PACKAGES` - Print quote packages when connected,
297    ///   `true` or `false` (Default: `true`)
298    /// - `LONGPORT_LOG_PATH` - Set the path of the log files (Default: `no
299    ///   logs`)
300    ///
301    /// For OAuth 2.0 authentication use [`from_oauth`](Config::from_oauth)
302    /// together with [`OAuthBuilder`](longport_oauth::OAuthBuilder).
303    pub fn from_apikey_env() -> Result<Self> {
304        let _ = dotenv::dotenv();
305
306        let app_key = env_var_required("APP_KEY")?;
307        let app_secret = env_var_required("APP_SECRET")?;
308        let access_token = env_var_required("ACCESS_TOKEN")?;
309        let extras = ConfigExtras::from_env();
310
311        Ok(Config {
312            auth: AuthMode::ApiKey {
313                app_key,
314                app_secret,
315                access_token,
316            },
317            http_url: extras.http_url,
318            quote_ws_url: extras.quote_ws_url,
319            trade_ws_url: extras.trade_ws_url,
320            language: extras.language,
321            enable_overnight: extras.enable_overnight,
322            push_candlestick_mode: extras.push_candlestick_mode,
323            enable_print_quote_packages: extras.enable_print_quote_packages,
324            log_path: extras.log_path,
325            custom_headers: Default::default(),
326        })
327    }
328
329    /// Specifies the url of the OpenAPI server.
330    ///
331    /// Default: `https://openapi.longportapp.com`
332    ///
333    /// NOTE: Usually you don't need to change it.
334    #[must_use]
335    pub fn http_url(mut self, url: impl Into<String>) -> Self {
336        self.http_url = Some(url.into());
337        self
338    }
339
340    /// Specifies the url of the OpenAPI quote websocket server.
341    ///
342    /// Default: `wss://openapi-quote.longportapp.com`
343    ///
344    /// NOTE: Usually you don't need to change it.
345    #[must_use]
346    pub fn quote_ws_url(self, url: impl Into<String>) -> Self {
347        Self {
348            quote_ws_url: Some(url.into()),
349            ..self
350        }
351    }
352
353    /// Specifies the url of the OpenAPI trade websocket server.
354    ///
355    /// Default: `wss://openapi-trade.longportapp.com/v2`
356    ///
357    /// NOTE: Usually you don't need to change it.
358    #[must_use]
359    pub fn trade_ws_url(self, url: impl Into<String>) -> Self {
360        Self {
361            trade_ws_url: Some(url.into()),
362            ..self
363        }
364    }
365
366    /// Specifies the language
367    ///
368    /// Default: `Language::EN`
369    pub fn language(self, language: Language) -> Self {
370        Self { language, ..self }
371    }
372
373    /// Enable overnight quote
374    ///
375    /// Default: `false`
376    pub fn enable_overnight(self) -> Self {
377        Self {
378            enable_overnight: Some(true),
379            ..self
380        }
381    }
382
383    /// Specifies the push candlestick mode
384    ///
385    /// Default: `PushCandlestickMode::Realtime`
386    pub fn push_candlestick_mode(self, mode: PushCandlestickMode) -> Self {
387        Self {
388            push_candlestick_mode: Some(mode),
389            ..self
390        }
391    }
392
393    /// Disable printing the opened quote packages when connected to the server.
394    pub fn dont_print_quote_packages(self) -> Self {
395        Self {
396            enable_print_quote_packages: false,
397            ..self
398        }
399    }
400
401    /// Create metadata for auth/reconnect request
402    pub fn create_metadata(&self) -> HashMap<String, String> {
403        let mut metadata = HashMap::new();
404        metadata.insert("accept-language".to_string(), self.language.to_string());
405        if self.enable_overnight.unwrap_or_default() {
406            metadata.insert("need_over_night_quote".to_string(), "true".to_string());
407        }
408        metadata
409    }
410
411    #[inline]
412    pub(crate) fn create_http_client(&self) -> HttpClient {
413        let mut config = match &self.auth {
414            AuthMode::ApiKey {
415                app_key,
416                app_secret,
417                access_token,
418            } => HttpClientConfig::from_apikey(app_key, app_secret, access_token),
419            AuthMode::OAuth(oauth) => HttpClientConfig::from_oauth(oauth.clone()),
420        };
421        if let Some(url) = &self.http_url {
422            config = config.http_url(url.clone());
423        }
424
425        let mut client =
426            HttpClient::new(config).header(header::ACCEPT_LANGUAGE, self.language.as_str());
427        for (key, value) in &self.custom_headers {
428            client = client.header(key.as_str(), value.as_str());
429        }
430        client
431    }
432
433    /// Gets a new `access_token`
434    ///
435    /// This method is only available when using **Legacy API Key**
436    /// authentication (i.e. [`Config::from_apikey`]). It is not supported
437    /// for OAuth 2.0 mode.
438    ///
439    /// `expired_at` - The expiration time of the access token, defaults to `90`
440    /// days.
441    ///
442    /// Reference: <https://open.longportapp.com/en/docs/refresh-token-api>
443    pub async fn refresh_access_token(&self, expired_at: Option<OffsetDateTime>) -> Result<String> {
444        #[derive(Debug, Serialize)]
445        struct Request {
446            expired_at: String,
447        }
448
449        #[derive(Debug, Deserialize)]
450        struct Response {
451            token: String,
452        }
453
454        let request = Request {
455            expired_at: expired_at
456                .unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::days(90))
457                .format(&time::format_description::well_known::Rfc3339)
458                .unwrap(),
459        };
460
461        let new_token = self
462            .create_http_client()
463            .request(Method::GET, "/v1/token/refresh")
464            .query_params(request)
465            .response::<Json<Response>>()
466            .send()
467            .await?
468            .0
469            .token;
470        Ok(new_token)
471    }
472
473    /// Gets a new `access_token`, and also replaces the `access_token` in
474    /// `Config`.
475    ///
476    /// This method is only available when using **Legacy API Key**
477    /// authentication (i.e. [`Config::from_apikey`]). It is not supported
478    /// for OAuth 2.0 mode.
479    ///
480    /// `expired_at` - The expiration time of the access token, defaults to `90`
481    /// days.
482    ///
483    /// Reference: <https://open.longportapp.com/en/docs/refresh-token-api>
484    #[cfg(feature = "blocking")]
485    #[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
486    pub fn refresh_access_token_blocking(
487        &self,
488        expired_at: Option<OffsetDateTime>,
489    ) -> Result<String> {
490        tokio::runtime::Builder::new_current_thread()
491            .enable_all()
492            .build()
493            .expect("create tokio runtime")
494            .block_on(self.refresh_access_token(expired_at))
495    }
496
497    fn create_ws_request(&self, url: &str) -> tokio_tungstenite::tungstenite::Result<Request<()>> {
498        let mut request = url.into_client_request()?;
499        request.headers_mut().append(
500            header::ACCEPT_LANGUAGE,
501            HeaderValue::from_str(self.language.as_str()).unwrap(),
502        );
503        for (key, value) in &self.custom_headers {
504            if let (Ok(name), Ok(val)) = (
505                HeaderName::from_bytes(key.as_bytes()),
506                HeaderValue::from_str(value),
507            ) {
508                request.headers_mut().append(name, val);
509            }
510        }
511        Ok(request)
512    }
513
514    pub(crate) async fn create_quote_ws_request(
515        &self,
516    ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
517        match self.quote_ws_url.as_deref() {
518            Some(url) => (url, self.create_ws_request(url)),
519            None => {
520                let url = if is_cn().await {
521                    DEFAULT_QUOTE_WS_URL_CN
522                } else {
523                    DEFAULT_QUOTE_WS_URL
524                };
525                (url, self.create_ws_request(url))
526            }
527        }
528    }
529
530    pub(crate) async fn create_trade_ws_request(
531        &self,
532    ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
533        match self.trade_ws_url.as_deref() {
534            Some(url) => (url, self.create_ws_request(url)),
535            None => {
536                let url = if is_cn().await {
537                    DEFAULT_TRADE_WS_URL_CN
538                } else {
539                    DEFAULT_TRADE_WS_URL
540                };
541                (url, self.create_ws_request(url))
542            }
543        }
544    }
545
546    /// Specifies the path of the log file
547    ///
548    /// Default: `None`
549    pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
550        self.log_path = Some(path.into());
551        self
552    }
553
554    /// Add a custom header to every HTTP request and WebSocket upgrade request.
555    #[must_use]
556    pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
557        self.custom_headers.insert(key.into(), value.into());
558        self
559    }
560
561    /// Set the HTTP endpoint URL in place.
562    pub fn set_http_url(&mut self, url: impl Into<String>) {
563        self.http_url = Some(url.into());
564    }
565
566    /// Set the quote websocket endpoint URL in place.
567    pub fn set_quote_ws_url(&mut self, url: impl Into<String>) {
568        self.quote_ws_url = Some(url.into());
569    }
570
571    /// Set the trade websocket endpoint URL in place.
572    pub fn set_trade_ws_url(&mut self, url: impl Into<String>) {
573        self.trade_ws_url = Some(url.into());
574    }
575
576    /// Set the language in place.
577    pub fn set_language(&mut self, language: Language) {
578        self.language = language;
579    }
580
581    /// Enable overnight quote in place.
582    pub fn set_enable_overnight(&mut self) {
583        self.enable_overnight = Some(true);
584    }
585
586    /// Set the push candlestick mode in place.
587    pub fn set_push_candlestick_mode(&mut self, mode: PushCandlestickMode) {
588        self.push_candlestick_mode = Some(mode);
589    }
590
591    /// Disable printing quote packages in place.
592    pub fn set_dont_print_quote_packages(&mut self) {
593        self.enable_print_quote_packages = false;
594    }
595
596    /// Set the log path in place.
597    pub fn set_log_path(&mut self, path: impl Into<PathBuf>) {
598        self.log_path = Some(path.into());
599    }
600
601    pub(crate) fn create_log_subscriber(
602        &self,
603        path: impl AsRef<Path>,
604    ) -> Arc<dyn Subscriber + Send + Sync> {
605        fn internal_create_log_subscriber(
606            config: &Config,
607            path: impl AsRef<Path>,
608        ) -> Option<Arc<dyn Subscriber + Send + Sync>> {
609            let log_path = config.log_path.as_ref()?;
610            let appender = RollingFileAppender::builder()
611                .rotation(Rotation::DAILY)
612                .filename_suffix("log")
613                .build(log_path.join(path))
614                .ok()?;
615            Some(Arc::new(
616                tracing_subscriber::fmt()
617                    .with_writer(appender)
618                    .with_ansi(false)
619                    .finish()
620                    .with(Targets::new().with_targets([("longport", Level::INFO)])),
621            ))
622        }
623
624        internal_create_log_subscriber(self, path).unwrap_or_else(|| Arc::new(NoSubscriber::new()))
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn test_config_from_apikey() {
634        let config = Config::from_apikey("app-key", "app-secret", "token");
635        assert_eq!(config.language, Language::EN);
636        match &config.auth {
637            AuthMode::ApiKey {
638                app_key,
639                app_secret,
640                access_token,
641            } => {
642                assert_eq!(app_key, "app-key");
643                assert_eq!(app_secret, "app-secret");
644                assert_eq!(access_token, "token");
645            }
646            _ => panic!("Expected ApiKey auth mode"),
647        }
648    }
649
650    #[test]
651    fn test_config_default_values() {
652        let config = Config::from_apikey("key", "secret", "token");
653
654        // Fields not controlled by environment variables
655        assert_eq!(config.enable_overnight, None);
656        assert_eq!(config.push_candlestick_mode, None);
657        assert!(config.enable_print_quote_packages);
658    }
659}