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::{HeaderValue, Request, header};
10use longbridge_httpcli::{HttpClient, HttpClientConfig, is_cn};
11use longbridge_oauth::OAuth;
12use num_enum::IntoPrimitive;
13use tokio_tungstenite::tungstenite::client::IntoClientRequest;
14use tracing::{Level, Subscriber, subscriber::NoSubscriber};
15use tracing_appender::rolling::{RollingFileAppender, Rotation};
16use tracing_subscriber::{filter::Targets, layer::SubscriberExt};
17
18use crate::error::Result;
19
20const DEFAULT_QUOTE_WS_URL: &str = "wss://openapi-quote.longbridge.com/v2";
21const DEFAULT_TRADE_WS_URL: &str = "wss://openapi-trade.longbridge.com/v2";
22const DEFAULT_QUOTE_WS_URL_CN: &str = "wss://openapi-quote.longportapp.cn/v2";
23const DEFAULT_TRADE_WS_URL_CN: &str = "wss://openapi-trade.longportapp.cn/v2";
24
25#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, IntoPrimitive)]
27#[allow(non_camel_case_types)]
28#[repr(i32)]
29pub enum Language {
30 ZH_CN = 0,
32 ZH_HK = 2,
34 #[default]
36 EN = 1,
37}
38
39impl Language {
40 pub(crate) fn as_str(&self) -> &'static str {
41 match self {
42 Language::ZH_CN => "zh-CN",
43 Language::ZH_HK => "zh-HK",
44 Language::EN => "en",
45 }
46 }
47}
48
49impl FromStr for Language {
50 type Err = ();
51
52 fn from_str(s: &str) -> ::std::result::Result<Self, Self::Err> {
53 match s {
54 "zh-CN" => Ok(Language::ZH_CN),
55 "zh-HK" => Ok(Language::ZH_HK),
56 "en" => Ok(Language::EN),
57 _ => Err(()),
58 }
59 }
60}
61
62impl Display for Language {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(self.as_str())
65 }
66}
67
68#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
70pub enum PushCandlestickMode {
71 #[default]
73 Realtime,
74 Confirmed,
76}
77
78pub(crate) enum AuthMode {
80 ApiKey {
82 app_key: String,
83 app_secret: String,
84 access_token: String,
85 },
86 OAuth(OAuth),
88}
89
90impl Clone for AuthMode {
91 fn clone(&self) -> Self {
92 match self {
93 AuthMode::ApiKey {
94 app_key,
95 app_secret,
96 access_token,
97 } => AuthMode::ApiKey {
98 app_key: app_key.clone(),
99 app_secret: app_secret.clone(),
100 access_token: access_token.clone(),
101 },
102 AuthMode::OAuth(oauth) => AuthMode::OAuth(oauth.clone()),
103 }
104 }
105}
106
107impl fmt::Debug for AuthMode {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 match self {
110 AuthMode::ApiKey { app_key, .. } => {
111 f.debug_struct("ApiKey").field("app_key", app_key).finish()
112 }
113 AuthMode::OAuth(_) => f.debug_struct("OAuth").finish(),
114 }
115 }
116}
117
118#[derive(Debug, Clone)]
120pub struct Config {
121 pub(crate) auth: AuthMode,
122 pub(crate) http_url: Option<String>,
123 pub(crate) quote_ws_url: Option<String>,
124 pub(crate) trade_ws_url: Option<String>,
125 pub(crate) enable_overnight: Option<bool>,
126 pub(crate) push_candlestick_mode: Option<PushCandlestickMode>,
127 pub(crate) enable_print_quote_packages: bool,
128 pub(crate) language: Language,
129 pub(crate) log_path: Option<PathBuf>,
130}
131
132fn env_var(suffix: &str) -> Option<String> {
135 std::env::var(format!("LONGBRIDGE_{suffix}"))
136 .ok()
137 .or_else(|| std::env::var(format!("LONGPORT_{suffix}")).ok())
138}
139
140fn env_var_required(suffix: &str) -> Result<String> {
142 env_var(suffix).ok_or_else(|| {
143 longbridge_httpcli::HttpClientError::MissingEnvVar {
144 name: format!("LONGBRIDGE_{suffix}"),
145 }
146 .into()
147 })
148}
149
150struct 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 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 }
222 }
223
224 pub fn from_oauth(oauth: OAuth) -> Self {
257 let _ = dotenv::dotenv();
258 let extras = ConfigExtras::from_env();
259 Self {
260 auth: AuthMode::OAuth(oauth),
261 http_url: extras.http_url,
262 quote_ws_url: extras.quote_ws_url,
263 trade_ws_url: extras.trade_ws_url,
264 language: extras.language,
265 enable_overnight: extras.enable_overnight,
266 push_candlestick_mode: extras.push_candlestick_mode,
267 enable_print_quote_packages: extras.enable_print_quote_packages,
268 log_path: extras.log_path,
269 }
270 }
271
272 pub fn from_apikey_env() -> Result<Self> {
302 let _ = dotenv::dotenv();
303
304 let app_key = env_var_required("APP_KEY")?;
305 let app_secret = env_var_required("APP_SECRET")?;
306 let access_token = env_var_required("ACCESS_TOKEN")?;
307 let extras = ConfigExtras::from_env();
308
309 Ok(Config {
310 auth: AuthMode::ApiKey {
311 app_key,
312 app_secret,
313 access_token,
314 },
315 http_url: extras.http_url,
316 quote_ws_url: extras.quote_ws_url,
317 trade_ws_url: extras.trade_ws_url,
318 language: extras.language,
319 enable_overnight: extras.enable_overnight,
320 push_candlestick_mode: extras.push_candlestick_mode,
321 enable_print_quote_packages: extras.enable_print_quote_packages,
322 log_path: extras.log_path,
323 })
324 }
325
326 #[must_use]
332 pub fn http_url(mut self, url: impl Into<String>) -> Self {
333 self.http_url = Some(url.into());
334 self
335 }
336
337 #[must_use]
343 pub fn quote_ws_url(self, url: impl Into<String>) -> Self {
344 Self {
345 quote_ws_url: Some(url.into()),
346 ..self
347 }
348 }
349
350 #[must_use]
356 pub fn trade_ws_url(self, url: impl Into<String>) -> Self {
357 Self {
358 trade_ws_url: Some(url.into()),
359 ..self
360 }
361 }
362
363 pub fn language(self, language: Language) -> Self {
367 Self { language, ..self }
368 }
369
370 pub fn enable_overnight(self) -> Self {
374 Self {
375 enable_overnight: Some(true),
376 ..self
377 }
378 }
379
380 pub fn push_candlestick_mode(self, mode: PushCandlestickMode) -> Self {
384 Self {
385 push_candlestick_mode: Some(mode),
386 ..self
387 }
388 }
389
390 pub fn dont_print_quote_packages(self) -> Self {
392 Self {
393 enable_print_quote_packages: false,
394 ..self
395 }
396 }
397
398 pub fn create_metadata(&self) -> HashMap<String, String> {
400 let mut metadata = HashMap::new();
401 metadata.insert("accept-language".to_string(), self.language.to_string());
402 if self.enable_overnight.unwrap_or_default() {
403 metadata.insert("need_over_night_quote".to_string(), "true".to_string());
404 }
405 metadata
406 }
407
408 #[inline]
409 pub(crate) fn create_http_client(&self) -> HttpClient {
410 let mut config = match &self.auth {
411 AuthMode::ApiKey {
412 app_key,
413 app_secret,
414 access_token,
415 } => HttpClientConfig::from_apikey(app_key, app_secret, access_token),
416 AuthMode::OAuth(oauth) => HttpClientConfig::from_oauth(oauth.clone()),
417 };
418 if let Some(url) = &self.http_url {
419 config = config.http_url(url.clone());
420 }
421
422 HttpClient::new(config).header(header::ACCEPT_LANGUAGE, self.language.as_str())
423 }
424
425 fn create_ws_request(&self, url: &str) -> tokio_tungstenite::tungstenite::Result<Request<()>> {
426 let mut request = url.into_client_request()?;
427 request.headers_mut().append(
428 header::ACCEPT_LANGUAGE,
429 HeaderValue::from_str(self.language.as_str()).unwrap(),
430 );
431 Ok(request)
432 }
433
434 pub(crate) async fn create_quote_ws_request(
435 &self,
436 ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
437 match self.quote_ws_url.as_deref() {
438 Some(url) => (url, self.create_ws_request(url)),
439 None => {
440 let url = if is_cn().await {
441 DEFAULT_QUOTE_WS_URL_CN
442 } else {
443 DEFAULT_QUOTE_WS_URL
444 };
445 (url, self.create_ws_request(url))
446 }
447 }
448 }
449
450 pub(crate) async fn create_trade_ws_request(
451 &self,
452 ) -> (&str, tokio_tungstenite::tungstenite::Result<Request<()>>) {
453 match self.trade_ws_url.as_deref() {
454 Some(url) => (url, self.create_ws_request(url)),
455 None => {
456 let url = if is_cn().await {
457 DEFAULT_TRADE_WS_URL_CN
458 } else {
459 DEFAULT_TRADE_WS_URL
460 };
461 (url, self.create_ws_request(url))
462 }
463 }
464 }
465
466 pub fn log_path(mut self, path: impl Into<PathBuf>) -> Self {
470 self.log_path = Some(path.into());
471 self
472 }
473
474 pub fn set_http_url(&mut self, url: impl Into<String>) {
476 self.http_url = Some(url.into());
477 }
478
479 pub fn set_quote_ws_url(&mut self, url: impl Into<String>) {
481 self.quote_ws_url = Some(url.into());
482 }
483
484 pub fn set_trade_ws_url(&mut self, url: impl Into<String>) {
486 self.trade_ws_url = Some(url.into());
487 }
488
489 pub fn set_language(&mut self, language: Language) {
491 self.language = language;
492 }
493
494 pub fn set_enable_overnight(&mut self) {
496 self.enable_overnight = Some(true);
497 }
498
499 pub fn set_push_candlestick_mode(&mut self, mode: PushCandlestickMode) {
501 self.push_candlestick_mode = Some(mode);
502 }
503
504 pub fn set_dont_print_quote_packages(&mut self) {
506 self.enable_print_quote_packages = false;
507 }
508
509 pub fn set_log_path(&mut self, path: impl Into<PathBuf>) {
511 self.log_path = Some(path.into());
512 }
513
514 pub(crate) fn create_log_subscriber(
515 &self,
516 path: impl AsRef<Path>,
517 ) -> Arc<dyn Subscriber + Send + Sync> {
518 fn internal_create_log_subscriber(
519 config: &Config,
520 path: impl AsRef<Path>,
521 ) -> Option<Arc<dyn Subscriber + Send + Sync>> {
522 let log_path = config.log_path.as_ref()?;
523 let appender = RollingFileAppender::builder()
524 .rotation(Rotation::DAILY)
525 .filename_suffix("log")
526 .build(log_path.join(path))
527 .ok()?;
528 Some(Arc::new(
529 tracing_subscriber::fmt()
530 .with_writer(appender)
531 .with_ansi(false)
532 .finish()
533 .with(Targets::new().with_targets([("longbridge", Level::INFO)])),
534 ))
535 }
536
537 internal_create_log_subscriber(self, path).unwrap_or_else(|| Arc::new(NoSubscriber::new()))
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_config_from_apikey() {
547 let config = Config::from_apikey("app-key", "app-secret", "token");
548 assert_eq!(config.language, Language::EN);
549 match &config.auth {
550 AuthMode::ApiKey {
551 app_key,
552 app_secret,
553 access_token,
554 } => {
555 assert_eq!(app_key, "app-key");
556 assert_eq!(app_secret, "app-secret");
557 assert_eq!(access_token, "token");
558 }
559 _ => panic!("Expected ApiKey auth mode"),
560 }
561 }
562
563 #[test]
564 fn test_config_default_values() {
565 let config = Config::from_apikey("key", "secret", "token");
566
567 assert_eq!(config.language, Language::EN);
568 assert_eq!(config.quote_ws_url, None);
569 assert_eq!(config.trade_ws_url, None);
570 assert_eq!(config.enable_overnight, None);
571 assert_eq!(config.push_candlestick_mode, None);
572 assert!(config.enable_print_quote_packages);
573 assert_eq!(config.log_path, None);
574 }
575}