longport/
config.rs

1use std::{
2    collections::HashMap,
3    fmt::{self, Display},
4    path::{Path, PathBuf},
5    str::FromStr,
6    sync::Arc,
7};
8
9use http::Method;
10pub(crate) use http::{HeaderValue, Request, header};
11use longport_httpcli::{HttpClient, HttpClientConfig, Json, is_cn};
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.longportapp.cn/v2";
25const DEFAULT_TRADE_WS_URL_CN: &str = "wss://openapi-trade.longportapp.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/// Configuration options for LongPort sdk
81#[derive(Debug, Clone)]
82pub struct Config {
83    pub(crate) http_cli_config: HttpClientConfig,
84    pub(crate) quote_ws_url: Option<String>,
85    pub(crate) trade_ws_url: Option<String>,
86    pub(crate) enable_overnight: Option<bool>,
87    pub(crate) push_candlestick_mode: Option<PushCandlestickMode>,
88    pub(crate) enable_print_quote_packages: bool,
89    pub(crate) language: Language,
90    pub(crate) log_path: Option<PathBuf>,
91}
92
93impl Config {
94    /// Create a new `Config`
95    pub fn new(
96        app_key: impl Into<String>,
97        app_secret: impl Into<String>,
98        access_token: impl Into<String>,
99    ) -> Self {
100        Self {
101            http_cli_config: HttpClientConfig::new(app_key, app_secret, access_token),
102            quote_ws_url: None,
103            trade_ws_url: None,
104            language: Language::EN,
105            enable_overnight: None,
106            push_candlestick_mode: None,
107            enable_print_quote_packages: true,
108            log_path: None,
109        }
110    }
111
112    /// Create a new `Config` from the given environment variables
113    ///
114    /// It first gets the environment variables from the `.env` file in the
115    /// current directory.
116    ///
117    /// # Variables
118    ///
119    /// - `LONGPORT_LANGUAGE` - Language identifier, `zh-CN`, `zh-HK` or `en`
120    ///   (Default: `en`)
121    /// - `LONGPORT_APP_KEY` - App key
122    /// - `LONGPORT_APP_SECRET` - App secret
123    /// - `LONGPORT_ACCESS_TOKEN` - Access token
124    /// - `LONGPORT_HTTP_URL` - HTTP endpoint url (Default: `https://openapi.longportapp.com`)
125    /// - `LONGPORT_QUOTE_WS_URL` - Quote websocket endpoint url (Default:
126    ///   `wss://openapi-quote.longportapp.com/v2`)
127    /// - `LONGPORT_TRADE_WS_URL` - Trade websocket endpoint url (Default:
128    ///   `wss://openapi-trade.longportapp.com/v2`)
129    /// - `LONGPORT_ENABLE_OVERNIGHT` - Enable overnight quote, `true` or
130    ///   `false` (Default: `false`)
131    /// - `LONGPORT_PUSH_CANDLESTICK_MODE` - `realtime` or `confirmed` (Default:
132    ///   `realtime`)
133    /// - `LONGPORT_PRINT_QUOTE_PACKAGES` - Print quote packages when connected,
134    ///   `true` or `false` (Default: `true`)
135    /// - `LONGPORT_LOG_PATH` - Set the path of the log files (Default: `no
136    ///   logs`)
137    pub fn from_env() -> Result<Self> {
138        let _ = dotenv::dotenv();
139
140        let http_cli_config = HttpClientConfig::from_env()?;
141        let language = std::env::var("LONGPORT_LANGUAGE")
142            .ok()
143            .and_then(|value| value.parse::<Language>().ok())
144            .unwrap_or(Language::EN);
145        let quote_ws_url = std::env::var("LONGPORT_QUOTE_WS_URL").ok();
146        let trade_ws_url = std::env::var("LONGPORT_TRADE_WS_URL").ok();
147        let enable_overnight = std::env::var("LONGPORT_ENABLE_OVERNIGHT")
148            .map(|value| value == "true")
149            .ok();
150        let push_candlestick_mode = std::env::var("LONGPORT_PUSH_CANDLESTICK_MODE")
151            .map(|value| match value.as_str() {
152                "confirmed" => PushCandlestickMode::Confirmed,
153                _ => PushCandlestickMode::Realtime,
154            })
155            .ok();
156        let enable_print_quote_packages = std::env::var("LONGPORT_PRINT_QUOTE_PACKAGES")
157            .as_deref()
158            .unwrap_or("true")
159            == "true";
160        let log_path = std::env::var("LONGPORT_LOG_PATH").ok().map(PathBuf::from);
161
162        Ok(Config {
163            http_cli_config,
164            quote_ws_url,
165            trade_ws_url,
166            language,
167            enable_overnight,
168            push_candlestick_mode,
169            enable_print_quote_packages,
170            log_path,
171        })
172    }
173
174    /// Specifies the url of the OpenAPI server.
175    ///
176    /// Default: `https://openapi.longportapp.com`
177    ///
178    /// NOTE: Usually you don't need to change it.
179    #[must_use]
180    pub fn http_url(mut self, url: impl Into<String>) -> Self {
181        self.http_cli_config = self.http_cli_config.http_url(url);
182        self
183    }
184
185    /// Specifies the url of the OpenAPI quote websocket server.
186    ///
187    /// Default: `wss://openapi-quote.longportapp.com`
188    ///
189    /// NOTE: Usually you don't need to change it.
190    #[must_use]
191    pub fn quote_ws_url(self, url: impl Into<String>) -> Self {
192        Self {
193            quote_ws_url: Some(url.into()),
194            ..self
195        }
196    }
197
198    /// Specifies the url of the OpenAPI trade websocket server.
199    ///
200    /// Default: `wss://openapi-trade.longportapp.com/v2`
201    ///
202    /// NOTE: Usually you don't need to change it.
203    #[must_use]
204    pub fn trade_ws_url(self, url: impl Into<String>) -> Self {
205        Self {
206            trade_ws_url: Some(url.into()),
207            ..self
208        }
209    }
210
211    /// Specifies the language
212    ///
213    /// Default: `Language::EN`
214    pub fn language(self, language: Language) -> Self {
215        Self { language, ..self }
216    }
217
218    /// Enable overnight quote
219    ///
220    /// Default: `false`
221    pub fn enable_overnight(self) -> Self {
222        Self {
223            enable_overnight: Some(true),
224            ..self
225        }
226    }
227
228    /// Specifies the push candlestick mode
229    ///
230    /// Default: `PushCandlestickMode::Realtime`
231    pub fn push_candlestick_mode(self, mode: PushCandlestickMode) -> Self {
232        Self {
233            push_candlestick_mode: Some(mode),
234            ..self
235        }
236    }
237
238    /// Disable printing the opened quote packages when connected to the server.
239    pub fn dont_print_quote_packages(self) -> Self {
240        Self {
241            enable_print_quote_packages: false,
242            ..self
243        }
244    }
245
246    /// Create metadata for auth/reconnect request
247    pub fn create_metadata(&self) -> HashMap<String, String> {
248        let mut metadata = HashMap::new();
249        metadata.insert("accept-language".to_string(), self.language.to_string());
250        if self.enable_overnight.unwrap_or_default() {
251            metadata.insert("need_over_night_quote".to_string(), "true".to_string());
252        }
253        metadata
254    }
255
256    #[inline]
257    pub(crate) fn create_http_client(&self) -> HttpClient {
258        HttpClient::new(self.http_cli_config.clone())
259            .header(header::ACCEPT_LANGUAGE, self.language.as_str())
260    }
261
262    fn create_ws_request(&self, url: &str) -> tokio_tungstenite::tungstenite::Result<Request<()>> {
263        let mut request = url.into_client_request()?;
264        request.headers_mut().append(
265            header::ACCEPT_LANGUAGE,
266            HeaderValue::from_str(self.language.as_str()).unwrap(),
267        );
268        Ok(request)
269    }
270
271    pub(crate) async fn create_quote_ws_request(
272        &self,
273    ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
274        match self.quote_ws_url.as_deref() {
275            Some(url) => (url, self.create_ws_request(url)),
276            None => {
277                let url = if is_cn().await {
278                    DEFAULT_QUOTE_WS_URL_CN
279                } else {
280                    DEFAULT_QUOTE_WS_URL
281                };
282                (url, self.create_ws_request(url))
283            }
284        }
285    }
286
287    pub(crate) async fn create_trade_ws_request(
288        &self,
289    ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
290        match self.trade_ws_url.as_deref() {
291            Some(url) => (url, self.create_ws_request(url)),
292            None => {
293                let url = if is_cn().await {
294                    DEFAULT_TRADE_WS_URL_CN
295                } else {
296                    DEFAULT_TRADE_WS_URL
297                };
298                (url, self.create_ws_request(url))
299            }
300        }
301    }
302
303    /// Gets a new `access_token`
304    ///
305    /// `expired_at` - The expiration time of the access token, defaults to `90`
306    /// days.
307    ///
308    /// Reference: <https://open.longportapp.com/en/docs/refresh-token-api>
309    pub async fn refresh_access_token(&self, expired_at: Option<OffsetDateTime>) -> Result<String> {
310        #[derive(Debug, Serialize)]
311        struct Request {
312            expired_at: String,
313        }
314
315        #[derive(Debug, Deserialize)]
316        struct Response {
317            token: String,
318        }
319
320        let request = Request {
321            expired_at: expired_at
322                .unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::days(90))
323                .format(&time::format_description::well_known::Rfc3339)
324                .unwrap(),
325        };
326
327        let new_token = self
328            .create_http_client()
329            .request(Method::GET, "/v1/token/refresh")
330            .query_params(request)
331            .response::<Json<Response>>()
332            .send()
333            .await?
334            .0
335            .token;
336        Ok(new_token)
337    }
338
339    /// Gets a new `access_token`, and also replaces the `access_token` in
340    /// `Config`.
341    ///
342    /// `expired_at` - The expiration time of the access token, defaults to `90`
343    /// days.
344    ///
345    /// Reference: <https://open.longportapp.com/en/docs/refresh-token-api>
346    #[cfg(feature = "blocking")]
347    #[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
348    pub fn refresh_access_token_blocking(
349        &self,
350        expired_at: Option<OffsetDateTime>,
351    ) -> Result<String> {
352        tokio::runtime::Builder::new_current_thread()
353            .enable_all()
354            .build()
355            .expect("create tokio runtime")
356            .block_on(self.refresh_access_token(expired_at))
357    }
358
359    /// Specifies the path of the log file
360    ///
361    /// Default: `None`
362    pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
363        self.log_path = Some(path.into());
364        self
365    }
366
367    pub(crate) fn create_log_subscriber(
368        &self,
369        path: impl AsRef<Path>,
370    ) -> Arc<dyn Subscriber + Send + Sync> {
371        fn internal_create_log_subscriber(
372            config: &Config,
373            path: impl AsRef<Path>,
374        ) -> Option<Arc<dyn Subscriber + Send + Sync>> {
375            let log_path = config.log_path.as_ref()?;
376            let appender = RollingFileAppender::builder()
377                .rotation(Rotation::DAILY)
378                .filename_suffix("log")
379                .build(log_path.join(path))
380                .ok()?;
381            Some(Arc::new(
382                tracing_subscriber::fmt()
383                    .with_writer(appender)
384                    .with_ansi(false)
385                    .finish()
386                    .with(Targets::new().with_targets([("longport", Level::INFO)])),
387            ))
388        }
389
390        internal_create_log_subscriber(self, path).unwrap_or_else(|| Arc::new(NoSubscriber::new()))
391    }
392}