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::{
11 DC_REGION_HEADER, DcRegion, HttpClient, HttpClientConfig, Json, Method, is_cn,
12};
13use longbridge_oauth::OAuth;
14use num_enum::IntoPrimitive;
15use serde::{Deserialize, Serialize};
16use time::OffsetDateTime;
17use tokio_tungstenite::tungstenite::client::IntoClientRequest;
18use tracing::{Level, Subscriber, subscriber::NoSubscriber};
19use tracing_appender::rolling::{RollingFileAppender, Rotation};
20use tracing_subscriber::{filter::Targets, layer::SubscriberExt};
21
22use crate::error::Result;
23
24const DEFAULT_QUOTE_WS_URL: &str = "wss://openapi-quote.longbridge.com/v2";
25const DEFAULT_TRADE_WS_URL: &str = "wss://openapi-trade.longbridge.com/v2";
26const DEFAULT_QUOTE_WS_URL_CN: &str = "wss://openapi-quote.longbridge.cn/v2";
27const DEFAULT_TRADE_WS_URL_CN: &str = "wss://openapi-trade.longbridge.cn/v2";
28
29#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, IntoPrimitive)]
31#[allow(non_camel_case_types)]
32#[repr(i32)]
33pub enum Language {
34 ZH_CN = 0,
36 ZH_HK = 2,
38 #[default]
40 EN = 1,
41}
42
43impl Language {
44 pub(crate) fn as_str(&self) -> &'static str {
45 match self {
46 Language::ZH_CN => "zh-CN",
47 Language::ZH_HK => "zh-HK",
48 Language::EN => "en",
49 }
50 }
51}
52
53impl FromStr for Language {
54 type Err = ();
55
56 fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
57 match s {
58 "zh-CN" => Ok(Language::ZH_CN),
59 "zh-HK" => Ok(Language::ZH_HK),
60 "en" => Ok(Language::EN),
61 _ => Err(()),
62 }
63 }
64}
65
66impl Display for Language {
67 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 f.write_str(self.as_str())
69 }
70}
71
72#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
74pub enum PushCandlestickMode {
75 #[default]
77 Realtime,
78 Confirmed,
80}
81
82pub(crate) enum AuthMode {
84 ApiKey {
86 app_key: String,
87 app_secret: String,
88 access_token: String,
89 },
90 OAuth(OAuth),
92}
93
94impl Clone for AuthMode {
95 fn clone(&self) -> Self {
96 match self {
97 AuthMode::ApiKey {
98 app_key,
99 app_secret,
100 access_token,
101 } => AuthMode::ApiKey {
102 app_key: app_key.clone(),
103 app_secret: app_secret.clone(),
104 access_token: access_token.clone(),
105 },
106 AuthMode::OAuth(oauth) => AuthMode::OAuth(oauth.clone()),
107 }
108 }
109}
110
111impl fmt::Debug for AuthMode {
112 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113 match self {
114 AuthMode::ApiKey { app_key, .. } => {
115 f.debug_struct("ApiKey").field("app_key", app_key).finish()
116 }
117 AuthMode::OAuth(_) => f.debug_struct("OAuth").finish(),
118 }
119 }
120}
121
122#[derive(Debug, Clone)]
124pub struct Config {
125 pub(crate) auth: AuthMode,
126 pub(crate) http_url: Option<String>,
127 pub(crate) quote_ws_url: Option<String>,
128 pub(crate) trade_ws_url: Option<String>,
129 pub(crate) enable_overnight: Option<bool>,
130 pub(crate) push_candlestick_mode: Option<PushCandlestickMode>,
131 pub(crate) enable_print_quote_packages: bool,
132 pub(crate) language: Language,
133 pub(crate) log_path: Option<PathBuf>,
134 pub(crate) custom_headers: HashMap<String, String>,
136 pub(crate) enable_papertrading: bool,
137}
138
139fn env_var(suffix: &str) -> Option<String> {
142 std::env::var(format!("LONGBRIDGE_{suffix}"))
143 .ok()
144 .or_else(|| std::env::var(format!("LONGPORT_{suffix}")).ok())
145}
146
147fn env_var_required(suffix: &str) -> Result<String> {
149 env_var(suffix).ok_or_else(|| {
150 longbridge_httpcli::HttpClientError::MissingEnvVar {
151 name: format!("LONGBRIDGE_{suffix}"),
152 }
153 .into()
154 })
155}
156
157struct ConfigExtras {
160 http_url: Option<String>,
161 quote_ws_url: Option<String>,
162 trade_ws_url: Option<String>,
163 language: Language,
164 enable_overnight: Option<bool>,
165 push_candlestick_mode: Option<PushCandlestickMode>,
166 enable_print_quote_packages: bool,
167 log_path: Option<PathBuf>,
168 enable_papertrading: bool,
169}
170
171impl ConfigExtras {
172 fn from_env() -> Self {
173 let language = env_var("LANGUAGE")
174 .and_then(|v| v.parse::<Language>().ok())
175 .unwrap_or(Language::EN);
176 let enable_overnight = env_var("ENABLE_OVERNIGHT").map(|v| v == "true");
177 let push_candlestick_mode = env_var("PUSH_CANDLESTICK_MODE").map(|v| match v.as_str() {
178 "confirmed" => PushCandlestickMode::Confirmed,
179 _ => PushCandlestickMode::Realtime,
180 });
181 let enable_print_quote_packages =
182 env_var("PRINT_QUOTE_PACKAGES").as_deref().unwrap_or("true") == "true";
183 let enable_papertrading = env_var("PAPERTRADING").as_deref() == Some("true");
184 Self {
185 http_url: env_var("HTTP_URL"),
186 quote_ws_url: env_var("QUOTE_WS_URL"),
187 trade_ws_url: env_var("TRADE_WS_URL"),
188 language,
189 enable_overnight,
190 push_candlestick_mode,
191 enable_print_quote_packages,
192 log_path: env_var("LOG_PATH").map(PathBuf::from),
193 enable_papertrading,
194 }
195 }
196}
197
198impl Config {
199 pub fn from_apikey(
211 app_key: impl Into<String>,
212 app_secret: impl Into<String>,
213 access_token: impl Into<String>,
214 ) -> Self {
215 let _ = dotenv::dotenv();
216 let extras = ConfigExtras::from_env();
217 Self {
218 auth: AuthMode::ApiKey {
219 app_key: app_key.into(),
220 app_secret: app_secret.into(),
221 access_token: access_token.into(),
222 },
223 http_url: extras.http_url,
224 quote_ws_url: extras.quote_ws_url,
225 trade_ws_url: extras.trade_ws_url,
226 language: extras.language,
227 enable_overnight: extras.enable_overnight,
228 push_candlestick_mode: extras.push_candlestick_mode,
229 enable_print_quote_packages: extras.enable_print_quote_packages,
230 log_path: extras.log_path,
231 custom_headers: Default::default(),
232 enable_papertrading: extras.enable_papertrading,
233 }
234 }
235
236 pub fn from_oauth(oauth: OAuth) -> Self {
269 let _ = dotenv::dotenv();
270 let extras = ConfigExtras::from_env();
271 Self {
272 auth: AuthMode::OAuth(oauth),
273 http_url: extras.http_url,
274 quote_ws_url: extras.quote_ws_url,
275 trade_ws_url: extras.trade_ws_url,
276 language: extras.language,
277 enable_overnight: extras.enable_overnight,
278 push_candlestick_mode: extras.push_candlestick_mode,
279 enable_print_quote_packages: extras.enable_print_quote_packages,
280 log_path: extras.log_path,
281 custom_headers: Default::default(),
282 enable_papertrading: extras.enable_papertrading,
283 }
284 }
285
286 pub fn from_apikey_env() -> Result<Self> {
316 let _ = dotenv::dotenv();
317
318 let app_key = env_var_required("APP_KEY")?;
319 let app_secret = env_var_required("APP_SECRET")?;
320 let access_token = env_var_required("ACCESS_TOKEN")?;
321 let extras = ConfigExtras::from_env();
322
323 Ok(Config {
324 auth: AuthMode::ApiKey {
325 app_key,
326 app_secret,
327 access_token,
328 },
329 http_url: extras.http_url,
330 quote_ws_url: extras.quote_ws_url,
331 trade_ws_url: extras.trade_ws_url,
332 language: extras.language,
333 enable_overnight: extras.enable_overnight,
334 push_candlestick_mode: extras.push_candlestick_mode,
335 enable_print_quote_packages: extras.enable_print_quote_packages,
336 log_path: extras.log_path,
337 custom_headers: Default::default(),
338 enable_papertrading: extras.enable_papertrading,
339 })
340 }
341
342 #[must_use]
348 pub fn http_url(mut self, url: impl Into<String>) -> Self {
349 self.http_url = Some(url.into());
350 self
351 }
352
353 #[must_use]
359 pub fn quote_ws_url(self, url: impl Into<String>) -> Self {
360 Self {
361 quote_ws_url: Some(url.into()),
362 ..self
363 }
364 }
365
366 #[must_use]
372 pub fn trade_ws_url(self, url: impl Into<String>) -> Self {
373 Self {
374 trade_ws_url: Some(url.into()),
375 ..self
376 }
377 }
378
379 pub fn language(self, language: Language) -> Self {
383 Self { language, ..self }
384 }
385
386 pub fn enable_overnight(self) -> Self {
390 Self {
391 enable_overnight: Some(true),
392 ..self
393 }
394 }
395
396 pub fn push_candlestick_mode(self, mode: PushCandlestickMode) -> Self {
400 Self {
401 push_candlestick_mode: Some(mode),
402 ..self
403 }
404 }
405
406 pub fn dont_print_quote_packages(self) -> Self {
408 Self {
409 enable_print_quote_packages: false,
410 ..self
411 }
412 }
413
414 pub fn enable_papertrading(mut self) -> Self {
427 self.enable_papertrading = true;
428 self
429 }
430
431 pub fn create_metadata(&self) -> HashMap<String, String> {
433 let mut metadata = HashMap::new();
434 metadata.insert("accept-language".to_string(), self.language.to_string());
435 if self.enable_overnight.unwrap_or_default() {
436 metadata.insert("need_over_night_quote".to_string(), "true".to_string());
437 }
438 metadata
439 }
440
441 #[inline]
442 pub(crate) fn create_http_client(&self) -> HttpClient {
443 let mut config = match &self.auth {
444 AuthMode::ApiKey {
445 app_key,
446 app_secret,
447 access_token,
448 } => HttpClientConfig::from_apikey(app_key, app_secret, access_token),
449 AuthMode::OAuth(oauth) => HttpClientConfig::from_oauth(oauth.clone()),
450 };
451 if let Some(url) = &self.http_url {
452 config = config.http_url(url.clone());
453 }
454
455 let mut client =
456 HttpClient::new(config).header(header::ACCEPT_LANGUAGE, self.language.as_str());
457 for (key, value) in &self.custom_headers {
458 client = client.header(key.as_str(), value.as_str());
459 }
460 if self.enable_papertrading {
461 client = client.header("x-papertrading", "true");
462 }
463 client
464 }
465
466 pub async fn refresh_access_token(&self, expired_at: Option<OffsetDateTime>) -> Result<String> {
477 #[derive(Debug, Serialize)]
478 struct Request {
479 expired_at: String,
480 }
481
482 #[derive(Debug, Deserialize)]
483 struct Response {
484 token: String,
485 }
486
487 let request = Request {
488 expired_at: expired_at
489 .unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::days(90))
490 .format(&time::format_description::well_known::Rfc3339)
491 .unwrap(),
492 };
493
494 let new_token = self
495 .create_http_client()
496 .request(Method::GET, "/v1/token/refresh")
497 .query_params(request)
498 .response::<Json<Response>>()
499 .send()
500 .await?
501 .0
502 .token;
503 Ok(new_token)
504 }
505
506 #[cfg(feature = "blocking")]
518 #[cfg_attr(docsrs, doc(cfg(feature = "blocking")))]
519 pub fn refresh_access_token_blocking(
520 &self,
521 expired_at: Option<OffsetDateTime>,
522 ) -> Result<String> {
523 tokio::runtime::Builder::new_current_thread()
524 .enable_all()
525 .build()
526 .expect("create tokio runtime")
527 .block_on(self.refresh_access_token(expired_at))
528 }
529
530 async fn auth_dc_region(&self) -> DcRegion {
534 match &self.auth {
535 AuthMode::ApiKey {
536 app_key,
537 app_secret,
538 access_token,
539 } => DcRegion::from_credentials(&[app_key, access_token, app_secret]),
540 AuthMode::OAuth(oauth) => oauth
541 .access_token()
542 .await
543 .map(|token| DcRegion::from_credential(&token))
544 .unwrap_or(DcRegion::Ap),
545 }
546 }
547
548 fn create_ws_request(
549 &self,
550 url: &str,
551 dc_region: DcRegion,
552 ) -> tokio_tungstenite::tungstenite::Result<Request<()>> {
553 let mut request = url.into_client_request()?;
554 request.headers_mut().append(
555 header::ACCEPT_LANGUAGE,
556 HeaderValue::from_str(self.language.as_str()).unwrap(),
557 );
558 for (key, value) in &self.custom_headers {
559 if let (Ok(name), Ok(val)) = (
560 HeaderName::from_bytes(key.as_bytes()),
561 HeaderValue::from_str(value),
562 ) {
563 request.headers_mut().append(name, val);
564 }
565 }
566 if !self
569 .custom_headers
570 .keys()
571 .any(|key| key.eq_ignore_ascii_case(DC_REGION_HEADER))
572 {
573 request.headers_mut().append(
574 HeaderName::from_static(DC_REGION_HEADER),
575 HeaderValue::from_static(dc_region.as_str()),
576 );
577 }
578 Ok(request)
579 }
580
581 pub(crate) async fn create_quote_ws_request(
582 &self,
583 ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
584 let dc_region = self.auth_dc_region().await;
585 match self.quote_ws_url.as_deref() {
586 Some(url) => (url, self.create_ws_request(url, dc_region)),
587 None => {
588 let url = if is_cn().await {
589 DEFAULT_QUOTE_WS_URL_CN
590 } else {
591 DEFAULT_QUOTE_WS_URL
592 };
593 (url, self.create_ws_request(url, dc_region))
594 }
595 }
596 }
597
598 pub(crate) async fn create_trade_ws_request(
599 &self,
600 ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
601 let dc_region = self.auth_dc_region().await;
602 match self.trade_ws_url.as_deref() {
603 Some(url) => (url, self.create_ws_request(url, dc_region)),
604 None => {
605 let url = if is_cn().await {
606 DEFAULT_TRADE_WS_URL_CN
607 } else {
608 DEFAULT_TRADE_WS_URL
609 };
610 (url, self.create_ws_request(url, dc_region))
611 }
612 }
613 }
614
615 pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
619 self.log_path = Some(path.into());
620 self
621 }
622
623 #[must_use]
625 pub fn header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
626 self.custom_headers.insert(key.into(), value.into());
627 self
628 }
629
630 #[must_use]
637 pub fn dc_region(self, region: DcRegion) -> Self {
638 self.header(DC_REGION_HEADER, region.as_str())
639 }
640
641 pub fn set_http_url(&mut self, url: impl Into<String>) {
643 self.http_url = Some(url.into());
644 }
645
646 pub fn set_quote_ws_url(&mut self, url: impl Into<String>) {
648 self.quote_ws_url = Some(url.into());
649 }
650
651 pub fn set_trade_ws_url(&mut self, url: impl Into<String>) {
653 self.trade_ws_url = Some(url.into());
654 }
655
656 pub fn set_language(&mut self, language: Language) {
658 self.language = language;
659 }
660
661 pub fn set_enable_overnight(&mut self) {
663 self.enable_overnight = Some(true);
664 }
665
666 pub fn set_push_candlestick_mode(&mut self, mode: PushCandlestickMode) {
668 self.push_candlestick_mode = Some(mode);
669 }
670
671 pub fn set_dont_print_quote_packages(&mut self) {
673 self.enable_print_quote_packages = false;
674 }
675
676 pub fn set_enable_papertrading(&mut self) {
680 self.enable_papertrading = true;
681 }
682
683 pub fn set_log_path(&mut self, path: impl Into<PathBuf>) {
685 self.log_path = Some(path.into());
686 }
687
688 pub(crate) fn create_log_subscriber(
689 &self,
690 path: impl AsRef<Path>,
691 ) -> Arc<dyn Subscriber + Send + Sync> {
692 fn internal_create_log_subscriber(
693 config: &Config,
694 path: impl AsRef<Path>,
695 ) -> Option<Arc<dyn Subscriber + Send + Sync>> {
696 let log_path = config.log_path.as_ref()?;
697 let appender = RollingFileAppender::builder()
698 .rotation(Rotation::DAILY)
699 .filename_suffix("log")
700 .build(log_path.join(path))
701 .ok()?;
702 Some(Arc::new(
703 tracing_subscriber::fmt()
704 .with_writer(appender)
705 .with_ansi(false)
706 .finish()
707 .with(Targets::new().with_targets([("longbridge", Level::INFO)])),
708 ))
709 }
710
711 internal_create_log_subscriber(self, path).unwrap_or_else(|| Arc::new(NoSubscriber::new()))
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 #[test]
720 fn test_config_from_apikey() {
721 let config = Config::from_apikey("app-key", "app-secret", "token");
722 assert_eq!(config.language, Language::EN);
723 match &config.auth {
724 AuthMode::ApiKey {
725 app_key,
726 app_secret,
727 access_token,
728 } => {
729 assert_eq!(app_key, "app-key");
730 assert_eq!(app_secret, "app-secret");
731 assert_eq!(access_token, "token");
732 }
733 _ => panic!("Expected ApiKey auth mode"),
734 }
735 }
736
737 #[test]
738 fn test_config_default_values() {
739 let config = Config::from_apikey("key", "secret", "token");
740
741 assert_eq!(config.enable_overnight, None);
743 assert_eq!(config.push_candlestick_mode, None);
744 assert!(config.enable_print_quote_packages);
745 }
746
747 #[test]
748 fn test_enable_papertrading_builder() {
749 let config = Config::from_apikey("key", "secret", "token").enable_papertrading();
750 assert!(config.enable_papertrading);
751 }
752
753 #[test]
754 fn test_enable_papertrading_setter() {
755 let mut config = Config::from_apikey("key", "secret", "token");
756 config.set_enable_papertrading();
757 assert!(config.enable_papertrading);
758 }
759}