1#![allow(deprecated)] use crate::Result;
8use std::time::Duration;
9
10use openlark_core::config::Config as CoreConfig;
11use openlark_core::constants::AppType;
12
13pub(crate) fn is_known_base_url(url: &str) -> bool {
15 let allowed_suffixes = ["feishu.cn", "larksuite.com", "larkoffice.com"];
16 if let Ok(parsed) = url::Url::parse(url)
18 && let Some(host) = parsed.host_str()
19 {
20 return allowed_suffixes
21 .iter()
22 .any(|suffix| host == *suffix || host.ends_with(&format!(".{suffix}")));
23 }
24 false
25}
26
27#[derive(Clone)]
56#[deprecated(
57 since = "0.17.0",
58 note = "Will be merged into openlark_core::config::Config. Use Client::builder() or openlark_core::config::Config directly."
59)]
60pub struct Config {
61 pub app_id: String,
63 pub app_secret: String,
65 pub app_type: AppType,
67 pub enable_token_cache: bool,
69 pub base_url: String,
71 pub allow_custom_base_url: bool,
73 pub timeout: Duration,
75 pub retry_count: u32,
77 pub enable_log: bool,
79 pub headers: std::collections::HashMap<String, String>,
81 pub max_response_size: u64,
83 #[doc(hidden)]
85 pub(crate) core_config: Option<CoreConfig>,
86}
87
88impl std::fmt::Debug for Config {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 f.debug_struct("Config")
91 .field("app_id", &self.app_id)
92 .field("app_secret", &"***")
93 .field("app_type", &self.app_type)
94 .field("enable_token_cache", &self.enable_token_cache)
95 .field("base_url", &self.base_url)
96 .field("timeout", &self.timeout)
97 .field("retry_count", &self.retry_count)
98 .field("enable_log", &self.enable_log)
99 .field("headers", &format!("{} headers", self.headers.len()))
100 .field("max_response_size", &self.max_response_size)
101 .finish()
102 }
103}
104
105impl Default for Config {
106 fn default() -> Self {
107 Self {
108 app_id: String::new(),
109 app_secret: String::new(),
110 app_type: AppType::SelfBuild,
111 enable_token_cache: true,
112 base_url: "https://open.feishu.cn".to_string(),
113 allow_custom_base_url: false,
114 timeout: Duration::from_secs(30),
115 retry_count: 3,
116 enable_log: true,
117 headers: std::collections::HashMap::new(),
118 max_response_size: 100 * 1024 * 1024, core_config: None,
120 }
121 }
122}
123
124impl Config {
125 pub fn new() -> Self {
127 Self::default()
128 }
129
130 pub fn from_env() -> Self {
142 let mut config = Self::default();
143 config.load_from_env();
144 config
145 }
146
147 pub fn load_from_env(&mut self) {
151 for (key, value) in std::env::vars() {
152 self.apply_env_var(&key, &value);
153 }
154 }
155
156 fn apply_env_var(&mut self, key: &str, value: &str) {
157 match key {
158 "OPENLARK_APP_ID" if !value.is_empty() => self.app_id = value.to_string(),
159 "OPENLARK_APP_SECRET" if !value.is_empty() => self.app_secret = value.to_string(),
160 "OPENLARK_APP_TYPE" => {
161 let v = value.trim().to_lowercase();
162 match v.as_str() {
163 "self_build" | "selfbuild" | "self" => self.app_type = AppType::SelfBuild,
164 "marketplace" | "store" => self.app_type = AppType::Marketplace,
165 _ => {}
166 }
167 }
168 "OPENLARK_BASE_URL" if !value.is_empty() => self.base_url = value.to_string(),
169 "OPENLARK_ENABLE_TOKEN_CACHE" => {
170 let s = value.trim().to_lowercase();
171 if !s.is_empty() {
172 self.enable_token_cache = !(s.starts_with('f') || s == "0");
173 }
174 }
175 "OPENLARK_TIMEOUT" => {
176 if let Ok(timeout_secs) = value.parse::<u64>() {
177 self.timeout = Duration::from_secs(timeout_secs);
178 }
179 }
180 "OPENLARK_RETRY_COUNT" => {
181 if let Ok(retry_count) = value.parse::<u32>() {
182 self.retry_count = retry_count;
183 }
184 }
185 "OPENLARK_MAX_RESPONSE_SIZE" => {
186 if let Ok(size) = value.parse::<u64>() {
187 self.max_response_size = size;
188 }
189 }
190 "OPENLARK_ENABLE_LOG" => {
192 self.enable_log = !value.to_lowercase().starts_with('f');
193 }
194 _ => {}
195 }
196 }
197
198 pub fn validate(&self) -> Result<()> {
209 if self.app_id.is_empty() {
210 return Err(crate::error::validation_error("app_id", "app_id不能为空"));
211 }
212
213 if self.app_secret.is_empty() {
214 return Err(crate::error::validation_error(
215 "app_secret",
216 "app_secret不能为空",
217 ));
218 }
219
220 if self.base_url.is_empty() {
221 return Err(crate::error::validation_error(
222 "base_url",
223 "base_url不能为空",
224 ));
225 }
226
227 if !self.base_url.starts_with("http://") && !self.base_url.starts_with("https://") {
229 return Err(crate::error::validation_error(
230 "base_url",
231 "base_url必须以http://或https://开头",
232 ));
233 }
234
235 if !self.allow_custom_base_url && !is_known_base_url(&self.base_url) {
237 tracing::warn!(
238 "base_url '{}' is not a known Feishu/Lark domain.
239 If this is intentional, set allow_custom_base_url(true) in config.",
240 self.base_url
241 );
242 return Err(crate::error::validation_error(
243 "base_url",
244 "base_url 域名不在白名单中,已知域名: *.feishu.cn, *.larksuite.com, *.larkoffice.com。如需使用自定义域名,请设置 allow_custom_base_url(true)",
245 ));
246 }
247
248 if self.timeout.is_zero() {
250 return Err(crate::error::validation_error(
251 "timeout",
252 "timeout必须大于0",
253 ));
254 }
255
256 if self.retry_count > 10 {
258 return Err(crate::error::validation_error(
259 "retry_count",
260 "retry_count不能超过10",
261 ));
262 }
263
264 Ok(())
265 }
266
267 pub fn builder() -> ConfigBuilder {
269 ConfigBuilder::new()
270 }
271
272 pub fn add_header<K, V>(&mut self, key: K, value: V)
274 where
275 K: Into<String>,
276 V: Into<String>,
277 {
278 self.headers.insert(key.into(), value.into());
279 }
280
281 pub fn clear_headers(&mut self) {
283 self.headers.clear();
284 }
285
286 pub fn is_complete(&self) -> bool {
288 !self.app_id.is_empty() && !self.app_secret.is_empty()
289 }
290
291 pub fn summary(&self) -> ConfigSummary {
293 ConfigSummary {
294 app_id: self.app_id.clone(),
295 app_secret_set: !self.app_secret.is_empty(),
296 app_type: self.app_type,
297 enable_token_cache: self.enable_token_cache,
298 base_url: self.base_url.clone(),
299 allow_custom_base_url: self.allow_custom_base_url,
300 timeout: self.timeout,
301 retry_count: self.retry_count,
302 enable_log: self.enable_log,
303 header_count: self.headers.len(),
304 max_response_size: self.max_response_size,
305 }
306 }
307
308 pub fn update_with(&mut self, other: &Config) {
310 if !other.app_id.is_empty() {
311 self.app_id = other.app_id.clone();
312 }
313 if !other.app_secret.is_empty() {
314 self.app_secret = other.app_secret.clone();
315 }
316 if other.app_type != AppType::SelfBuild {
317 self.app_type = other.app_type;
318 }
319 if other.enable_token_cache != self.enable_token_cache {
320 self.enable_token_cache = other.enable_token_cache;
321 }
322 if !other.base_url.is_empty() {
323 self.base_url = other.base_url.clone();
324 }
325 if !other.timeout.is_zero() {
326 self.timeout = other.timeout;
327 }
328 if other.retry_count != 3 {
329 self.retry_count = other.retry_count;
330 }
331 if other.max_response_size != 100 * 1024 * 1024 {
332 self.max_response_size = other.max_response_size;
333 }
334 if other.enable_log != self.enable_log {
335 self.enable_log = other.enable_log;
336 }
337 for (key, value) in &other.headers {
339 self.headers.insert(key.clone(), value.clone());
340 }
341 }
342
343 pub fn build_core_config(&self) -> CoreConfig {
345 CoreConfig::builder()
346 .app_id(self.app_id.clone())
347 .app_secret(self.app_secret.clone())
348 .base_url(self.base_url.clone())
349 .app_type(self.app_type)
350 .enable_token_cache(self.enable_token_cache)
351 .req_timeout(self.timeout)
352 .max_response_size(self.max_response_size)
353 .header(self.headers.clone())
354 .build()
355 }
356
357 #[cfg(feature = "auth")]
359 pub fn build_core_config_with_token_provider(&self) -> CoreConfig {
360 use openlark_auth::AuthTokenProvider;
361 let base_config = self.build_core_config();
362 let provider = AuthTokenProvider::new(base_config.clone());
363 base_config.with_token_provider(provider)
364 }
365
366 pub fn get_or_build_core_config(&self) -> CoreConfig {
368 if let Some(ref core_config) = self.core_config {
369 return core_config.clone();
370 }
371 self.build_core_config()
372 }
373
374 #[cfg(feature = "auth")]
376 pub fn get_or_build_core_config_with_token_provider(&self) -> CoreConfig {
377 if let Some(ref core_config) = self.core_config {
378 return core_config.clone();
379 }
380 self.build_core_config_with_token_provider()
381 }
382}
383
384#[derive(Debug, Clone)]
402pub struct ConfigBuilder {
403 config: Config,
404}
405
406impl ConfigBuilder {
407 pub fn new() -> Self {
409 Self {
410 config: Config::default(),
411 }
412 }
413
414 pub fn app_id<S: Into<String>>(mut self, app_id: S) -> Self {
416 self.config.app_id = app_id.into();
417 self
418 }
419
420 pub fn app_secret<S: Into<String>>(mut self, app_secret: S) -> Self {
422 self.config.app_secret = app_secret.into();
423 self
424 }
425
426 pub fn app_type(mut self, app_type: AppType) -> Self {
428 self.config.app_type = app_type;
429 self
430 }
431
432 pub fn enable_token_cache(mut self, enable: bool) -> Self {
434 self.config.enable_token_cache = enable;
435 self
436 }
437
438 pub fn base_url<S: Into<String>>(mut self, base_url: S) -> Self {
440 self.config.base_url = base_url.into();
441 self
442 }
443
444 pub fn timeout(mut self, timeout: Duration) -> Self {
446 self.config.timeout = timeout;
447 self
448 }
449
450 pub fn retry_count(mut self, count: u32) -> Self {
452 self.config.retry_count = count;
453 self
454 }
455
456 pub fn enable_log(mut self, enable: bool) -> Self {
458 self.config.enable_log = enable;
459 self
460 }
461
462 pub fn max_response_size(mut self, size: u64) -> Self {
464 self.config.max_response_size = size;
465 self
466 }
467
468 pub fn add_header<K, V>(mut self, key: K, value: V) -> Self
470 where
471 K: Into<String>,
472 V: Into<String>,
473 {
474 self.config.add_header(key, value);
475 self
476 }
477
478 pub fn from_env(mut self) -> Self {
480 self.config.load_from_env();
481 self
482 }
483
484 pub fn build(self) -> Result<Config> {
489 self.config.validate()?;
490 Ok(self.config)
491 }
492
493 pub fn build_unvalidated(self) -> Config {
497 self.config
498 }
499}
500
501impl Default for ConfigBuilder {
502 fn default() -> Self {
503 Self::new()
504 }
505}
506
507#[derive(Debug, Clone)]
509pub struct ConfigSummary {
510 pub app_id: String,
512 pub app_secret_set: bool,
514 pub app_type: AppType,
516 pub enable_token_cache: bool,
518 pub base_url: String,
520 pub allow_custom_base_url: bool,
522 pub timeout: Duration,
524 pub retry_count: u32,
526 pub enable_log: bool,
528 pub header_count: usize,
530 pub max_response_size: u64,
532}
533
534impl ConfigSummary {
535 pub fn friendly_description(&self) -> String {
537 format!(
538 "应用ID: {}, 基础URL: {}, 超时: {:?}, 重试: {}, 日志: {}, Headers: {}, 最大响应: {}",
539 self.app_id,
540 self.base_url,
541 self.timeout,
542 self.retry_count,
543 if self.enable_log { "启用" } else { "禁用" },
544 self.header_count,
545 self.max_response_size
546 )
547 }
548}
549
550impl std::fmt::Display for ConfigSummary {
551 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
552 write!(
553 f,
554 "Config {{ app_id: {}, app_secret_set: {}, base_url: {}, timeout: {:?}, retry_count: {}, enable_log: {}, header_count: {}, max_response_size: {} }}",
555 self.app_id,
556 self.app_secret_set,
557 self.base_url,
558 self.timeout,
559 self.retry_count,
560 self.enable_log,
561 self.header_count,
562 self.max_response_size
563 )
564 }
565}
566
567impl From<std::env::Vars> for Config {
569 fn from(env_vars: std::env::Vars) -> Self {
570 let mut config = Config::default();
571 for (key, value) in env_vars {
572 config.apply_env_var(&key, &value);
573 }
574 config
575 }
576}
577
578#[cfg(test)]
579#[allow(unused_imports)]
580mod tests {
581 use super::*;
582 use std::time::Duration;
583
584 #[test]
585 fn test_config_default() {
586 let config = Config::default();
587 assert_eq!(config.app_id, "");
588 assert_eq!(config.app_secret, "");
589 assert_eq!(config.base_url, "https://open.feishu.cn");
590 assert_eq!(config.timeout, Duration::from_secs(30));
591 assert_eq!(config.retry_count, 3);
592 assert!(config.enable_log);
593 assert!(config.headers.is_empty());
594 }
595
596 #[test]
597 fn test_config_builder() {
598 let config = Config::builder()
599 .app_id("test_app_id")
600 .app_secret("test_app_secret")
601 .base_url("https://test.feishu.cn")
602 .timeout(Duration::from_secs(60))
603 .retry_count(5)
604 .enable_log(false)
605 .build();
606
607 assert!(config.is_ok());
608 let config = config.unwrap();
609 assert_eq!(config.app_id, "test_app_id");
610 assert_eq!(config.app_secret, "test_app_secret");
611 assert_eq!(config.base_url, "https://test.feishu.cn");
612 assert_eq!(config.timeout, Duration::from_secs(60));
613 assert_eq!(config.retry_count, 5);
614 assert!(!config.enable_log);
615 }
616
617 #[test]
618 fn test_config_from_env() {
619 crate::test_utils::with_env_vars(
620 &[
621 ("OPENLARK_APP_ID", Some("test_app_id")),
622 ("OPENLARK_APP_SECRET", Some("test_app_secret")),
623 ("OPENLARK_APP_TYPE", Some("marketplace")),
624 ("OPENLARK_BASE_URL", Some("https://test.feishu.cn")),
625 ("OPENLARK_ENABLE_TOKEN_CACHE", Some("false")),
626 ("OPENLARK_TIMEOUT", Some("60")),
627 ("OPENLARK_RETRY_COUNT", Some("5")),
628 ("OPENLARK_ENABLE_LOG", Some("false")),
629 ],
630 || {
631 let config = Config::from_env();
632 assert_eq!(config.app_id, "test_app_id");
633 assert_eq!(config.app_secret, "test_app_secret");
634 assert_eq!(config.app_type, AppType::Marketplace);
635 assert_eq!(config.base_url, "https://test.feishu.cn");
636 assert!(!config.enable_token_cache);
637 assert_eq!(config.timeout, Duration::from_secs(60));
638 assert_eq!(config.retry_count, 5);
639 assert!(!config.enable_log);
640 },
641 );
642 }
643
644 #[test]
645 fn test_config_validation() {
646 let config = Config {
648 app_id: "test_app_id".to_string(),
649 app_secret: "test_app_secret".to_string(),
650 app_type: AppType::SelfBuild,
651 enable_token_cache: true,
652 base_url: "https://open.feishu.cn".to_string(),
653 allow_custom_base_url: false,
654 timeout: Duration::from_secs(30),
655 retry_count: 3,
656 enable_log: true,
657 headers: std::collections::HashMap::new(),
658 max_response_size: 100 * 1024 * 1024,
659 core_config: None,
660 };
661 assert!(config.validate().is_ok());
662
663 let invalid_config = Config {
665 app_id: String::new(),
666 ..config.clone()
667 };
668 assert!(invalid_config.validate().is_err());
669
670 let invalid_config = Config {
672 app_secret: String::new(),
673 ..config
674 };
675 assert!(invalid_config.validate().is_err());
676 }
677
678 #[test]
679 fn test_config_headers() {
680 let mut config = Config::default();
681
682 config.add_header("X-Custom-Header", "custom-value");
684 assert_eq!(config.headers.len(), 1);
685 assert_eq!(
686 config.headers.get("X-Custom-Header"),
687 Some(&"custom-value".to_string())
688 );
689
690 config.clear_headers();
692 assert!(config.headers.is_empty());
693 }
694
695 #[test]
696 fn test_config_update_with() {
697 let mut base_config = Config::default();
698 let update_config = Config {
699 app_id: "updated_app_id".to_string(),
700 app_secret: "updated_app_secret".to_string(),
701 timeout: Duration::from_secs(60),
702 ..Default::default()
703 };
704
705 base_config.update_with(&update_config);
706 assert_eq!(base_config.app_id, "updated_app_id");
707 assert_eq!(base_config.app_secret, "updated_app_secret");
708 assert_eq!(base_config.timeout, Duration::from_secs(60));
709 assert_eq!(base_config.base_url, "https://open.feishu.cn");
711 }
712
713 #[test]
714 fn test_config_summary() {
715 let config = Config {
716 app_id: "test_app_id".to_string(),
717 app_secret: "test_app_secret".to_string(),
718 app_type: AppType::SelfBuild,
719 enable_token_cache: true,
720 base_url: "https://open.feishu.cn".to_string(),
721 allow_custom_base_url: false,
722 timeout: Duration::from_secs(30),
723 retry_count: 3,
724 enable_log: true,
725 headers: std::collections::HashMap::new(),
726 max_response_size: 100 * 1024 * 1024,
727 core_config: None,
728 };
729
730 let summary = config.summary();
731 assert_eq!(summary.app_id, "test_app_id");
732 assert!(summary.app_secret_set);
733 assert_eq!(summary.base_url, "https://open.feishu.cn");
734 assert_eq!(summary.timeout, Duration::from_secs(30));
735 assert_eq!(summary.retry_count, 3);
736 assert!(summary.enable_log);
737 assert_eq!(summary.header_count, 0);
738 }
739
740 #[test]
741 fn test_config_is_complete() {
742 let mut config = Config::default();
743 assert!(!config.is_complete());
744
745 config.app_id = "test_app_id".to_string();
746 assert!(!config.is_complete());
747
748 config.app_secret = "test_app_secret".to_string();
749 assert!(config.is_complete());
750 }
751}
752
753#[test]
754fn test_config_validation_known_base_url() {
755 let known_urls = vec![
757 "https://open.feishu.cn",
758 "https://api.feishu.cn",
759 "https://custom.feishu.cn",
760 "https://open.larksuite.com",
761 "https://api.larksuite.com",
762 "https://custom.larksuite.com",
763 "https://open.larkoffice.com",
764 "https://custom.larkoffice.com",
765 ];
766
767 for url in known_urls {
768 let config = Config {
769 app_id: "test_app_id".to_string(),
770 app_secret: "test_app_secret".to_string(),
771 app_type: AppType::SelfBuild,
772 enable_token_cache: true,
773 base_url: url.to_string(),
774 allow_custom_base_url: false,
775 timeout: Duration::from_secs(30),
776 retry_count: 3,
777 enable_log: true,
778 headers: std::collections::HashMap::new(),
779 max_response_size: 100 * 1024 * 1024,
780 core_config: None,
781 };
782 assert!(config.validate().is_ok(), "URL {url} should be valid");
783 }
784}
785
786#[test]
787fn test_config_validation_unknown_base_url_rejected() {
788 let unknown_urls = vec![
790 "https://evil.com",
791 "https://malicious-site.com",
792 "https://example.com",
793 "https://not-feishu.cn",
794 "https://fake-larksuite.com",
795 ];
796
797 for url in unknown_urls {
798 let config = Config {
799 app_id: "test_app_id".to_string(),
800 app_secret: "test_app_secret".to_string(),
801 app_type: AppType::SelfBuild,
802 enable_token_cache: true,
803 base_url: url.to_string(),
804 allow_custom_base_url: false,
805 timeout: Duration::from_secs(30),
806 retry_count: 3,
807 enable_log: true,
808 headers: std::collections::HashMap::new(),
809 max_response_size: 100 * 1024 * 1024,
810 core_config: None,
811 };
812 assert!(config.validate().is_err(), "URL {url} should be rejected");
813 }
814}
815
816#[test]
817fn test_config_validation_custom_base_url_allowed() {
818 let config = Config {
820 app_id: "test_app_id".to_string(),
821 app_secret: "test_app_secret".to_string(),
822 app_type: AppType::SelfBuild,
823 enable_token_cache: true,
824 base_url: "https://open.feishu.cn".to_string(),
825 allow_custom_base_url: true,
826 timeout: Duration::from_secs(30),
827 retry_count: 3,
828 enable_log: true,
829 headers: std::collections::HashMap::new(),
830 max_response_size: 100 * 1024 * 1024,
831 core_config: None,
832 };
833 assert!(config.validate().is_ok());
834}