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