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#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, IntoPrimitive)]
29#[allow(non_camel_case_types)]
30#[repr(i32)]
31pub enum Language {
32 ZH_CN = 0,
34 ZH_HK = 2,
36 #[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#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
72pub enum PushCandlestickMode {
73 #[default]
75 Realtime,
76 Confirmed,
78}
79
80pub(crate) enum AuthMode {
82 ApiKey {
84 app_key: String,
85 app_secret: String,
86 access_token: String,
87 },
88 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#[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 pub(crate) custom_headers: HashMap<String, String>,
134}
135
136fn 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
144fn 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
154struct 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 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 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 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 #[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 #[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 #[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 pub fn language(self, language: Language) -> Self {
374 Self { language, ..self }
375 }
376
377 pub fn enable_overnight(self) -> Self {
381 Self {
382 enable_overnight: Some(true),
383 ..self
384 }
385 }
386
387 pub fn push_candlestick_mode(self, mode: PushCandlestickMode) -> Self {
391 Self {
392 push_candlestick_mode: Some(mode),
393 ..self
394 }
395 }
396
397 pub fn dont_print_quote_packages(self) -> Self {
399 Self {
400 enable_print_quote_packages: false,
401 ..self
402 }
403 }
404
405 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 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 #[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 pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
554 self.log_path = Some(path.into());
555 self
556 }
557
558 #[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 pub fn set_http_url(&mut self, url: impl Into<String>) {
567 self.http_url = Some(url.into());
568 }
569
570 pub fn set_quote_ws_url(&mut self, url: impl Into<String>) {
572 self.quote_ws_url = Some(url.into());
573 }
574
575 pub fn set_trade_ws_url(&mut self, url: impl Into<String>) {
577 self.trade_ws_url = Some(url.into());
578 }
579
580 pub fn set_language(&mut self, language: Language) {
582 self.language = language;
583 }
584
585 pub fn set_enable_overnight(&mut self) {
587 self.enable_overnight = Some(true);
588 }
589
590 pub fn set_push_candlestick_mode(&mut self, mode: PushCandlestickMode) {
592 self.push_candlestick_mode = Some(mode);
593 }
594
595 pub fn set_dont_print_quote_packages(&mut self) {
597 self.enable_print_quote_packages = false;
598 }
599
600 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 assert_eq!(config.enable_overnight, None);
660 assert_eq!(config.push_candlestick_mode, None);
661 assert!(config.enable_print_quote_packages);
662 }
663}