1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::time::Duration;
5
6use chrono::{DateTime, Utc};
7use tokio::sync::RwLock;
8
9use crate::constants::{BASE_API_URL, DEFAULT_HTTP_TIMEOUT, VERSION};
10use crate::error;
11use crate::http::{HttpClient, RetryConfig};
12use crate::rate_limit::{RateLimitStatus, RateLimiter, RateLimiterConfig};
13
14pub const VALID_SCOPES: &[&str] = &[
20 "threads_basic",
21 "threads_content_publish",
22 "threads_manage_insights",
23 "threads_manage_replies",
24 "threads_read_replies",
25 "threads_manage_mentions",
26 "threads_keyword_search",
27 "threads_delete",
28 "threads_location_tagging",
29 "threads_profile_discovery",
30];
31
32#[derive(Debug, Clone)]
34pub struct Config {
35 pub client_id: String,
37 pub client_secret: String,
39 pub redirect_uri: String,
41 pub scopes: Vec<String>,
43 pub http_timeout: Duration,
45 pub retry_config: RetryConfig,
47 pub base_url: String,
49 pub user_agent: String,
51 pub debug: bool,
53}
54
55impl Config {
56 pub fn new(
58 client_id: impl Into<String>,
59 client_secret: impl Into<String>,
60 redirect_uri: impl Into<String>,
61 ) -> Self {
62 Self {
63 client_id: client_id.into(),
64 client_secret: client_secret.into(),
65 redirect_uri: redirect_uri.into(),
66 scopes: vec![
67 "threads_basic".into(),
68 "threads_content_publish".into(),
69 "threads_manage_replies".into(),
70 "threads_manage_insights".into(),
71 "threads_read_replies".into(),
72 "threads_manage_mentions".into(),
73 "threads_keyword_search".into(),
74 "threads_delete".into(),
75 "threads_location_tagging".into(),
76 "threads_profile_discovery".into(),
77 ],
78 http_timeout: DEFAULT_HTTP_TIMEOUT,
79 retry_config: RetryConfig::default(),
80 base_url: BASE_API_URL.to_owned(),
81 user_agent: format!("threads-rs/{}", VERSION),
82 debug: false,
83 }
84 }
85
86 pub fn from_env() -> crate::Result<Self> {
90 let client_id = std::env::var("THREADS_CLIENT_ID").map_err(|_| {
91 error::new_validation_error(
92 0,
93 "THREADS_CLIENT_ID environment variable is required",
94 "",
95 "client_id",
96 )
97 })?;
98
99 let client_secret = std::env::var("THREADS_CLIENT_SECRET").map_err(|_| {
100 error::new_validation_error(
101 0,
102 "THREADS_CLIENT_SECRET environment variable is required",
103 "",
104 "client_secret",
105 )
106 })?;
107
108 let redirect_uri = std::env::var("THREADS_REDIRECT_URI").map_err(|_| {
109 error::new_validation_error(
110 0,
111 "THREADS_REDIRECT_URI environment variable is required",
112 "",
113 "redirect_uri",
114 )
115 })?;
116
117 let mut config = Self::new(client_id, client_secret, redirect_uri);
118
119 if let Ok(scopes) = std::env::var("THREADS_SCOPES") {
120 config.scopes = scopes.split(',').map(|s| s.trim().to_owned()).collect();
121 }
122
123 if let Ok(timeout) = std::env::var("THREADS_HTTP_TIMEOUT") {
124 if let Ok(secs) = timeout.parse::<u64>() {
125 config.http_timeout = Duration::from_secs(secs);
126 }
127 }
128
129 if let Ok(base_url) = std::env::var("THREADS_BASE_URL") {
130 config.base_url = base_url;
131 }
132
133 if let Ok(ua) = std::env::var("THREADS_USER_AGENT") {
134 config.user_agent = ua;
135 }
136
137 if let Ok(debug) = std::env::var("THREADS_DEBUG") {
138 config.debug = debug.parse().unwrap_or(false);
139 }
140
141 if let Ok(retries) = std::env::var("THREADS_MAX_RETRIES") {
142 if let Ok(n) = retries.parse::<u32>() {
143 config.retry_config.max_retries = n;
144 }
145 }
146
147 Ok(config)
148 }
149
150 pub fn set_defaults(&mut self) {
152 if self.scopes.is_empty() {
153 self.scopes = vec![
154 "threads_basic".into(),
155 "threads_content_publish".into(),
156 "threads_manage_insights".into(),
157 "threads_manage_replies".into(),
158 "threads_read_replies".into(),
159 ];
160 }
161 if self.http_timeout.is_zero() {
162 self.http_timeout = DEFAULT_HTTP_TIMEOUT;
163 }
164 if self.base_url.is_empty() {
165 self.base_url = BASE_API_URL.to_owned();
166 }
167 if self.user_agent.is_empty() {
168 self.user_agent = format!("threads-rs/{}", VERSION);
169 }
170 }
171
172 pub fn validate(&self) -> crate::Result<()> {
174 if self.client_id.is_empty() {
175 return Err(error::new_validation_error(
176 0,
177 "ClientID is required",
178 "",
179 "client_id",
180 ));
181 }
182 if self.client_secret.is_empty() {
183 return Err(error::new_validation_error(
184 0,
185 "ClientSecret is required",
186 "",
187 "client_secret",
188 ));
189 }
190 if self.redirect_uri.is_empty() {
191 return Err(error::new_validation_error(
192 0,
193 "RedirectURI is required",
194 "",
195 "redirect_uri",
196 ));
197 }
198 if !self.redirect_uri.starts_with("http://") && !self.redirect_uri.starts_with("https://") {
199 return Err(error::new_validation_error(
200 0,
201 "RedirectURI must be a valid HTTP or HTTPS URL",
202 "",
203 "redirect_uri",
204 ));
205 }
206 if self.scopes.is_empty() {
207 return Err(error::new_validation_error(
208 0,
209 "At least one scope is required",
210 "",
211 "scopes",
212 ));
213 }
214 for scope in &self.scopes {
215 if !VALID_SCOPES.contains(&scope.as_str()) {
216 return Err(error::new_validation_error(
217 0,
218 &format!("Invalid scope: {}", scope),
219 "",
220 "scopes",
221 ));
222 }
223 }
224 if self.http_timeout.is_zero() {
225 return Err(error::new_validation_error(
226 0,
227 "HTTPTimeout must be positive",
228 "",
229 "http_timeout",
230 ));
231 }
232 if !self.base_url.starts_with("http://") && !self.base_url.starts_with("https://") {
233 return Err(error::new_validation_error(
234 0,
235 "BaseURL must be a valid HTTP or HTTPS URL",
236 "",
237 "base_url",
238 ));
239 }
240 Ok(())
241 }
242}
243
244#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
250pub struct TokenInfo {
251 pub access_token: String,
253 pub token_type: String,
255 pub expires_at: DateTime<Utc>,
257 pub user_id: String,
259 pub created_at: DateTime<Utc>,
261}
262
263pub trait TokenStorage: Send + Sync {
268 fn store(
270 &self,
271 token: &TokenInfo,
272 ) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + '_>>;
273 fn load(&self) -> Pin<Box<dyn Future<Output = crate::Result<TokenInfo>> + Send + '_>>;
275 fn delete(&self) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + '_>>;
277}
278
279pub struct MemoryTokenStorage {
281 token: std::sync::Mutex<Option<TokenInfo>>,
282}
283
284impl MemoryTokenStorage {
285 pub fn new() -> Self {
287 Self {
288 token: std::sync::Mutex::new(None),
289 }
290 }
291}
292
293impl Default for MemoryTokenStorage {
294 fn default() -> Self {
295 Self::new()
296 }
297}
298
299impl TokenStorage for MemoryTokenStorage {
300 fn store(
301 &self,
302 token: &TokenInfo,
303 ) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + '_>> {
304 let token = token.clone();
305 Box::pin(async move {
306 let mut guard = self.token.lock().map_err(|_| {
307 error::new_authentication_error(500, "Token storage lock poisoned", "")
308 })?;
309 *guard = Some(token);
310 Ok(())
311 })
312 }
313
314 fn load(&self) -> Pin<Box<dyn Future<Output = crate::Result<TokenInfo>> + Send + '_>> {
315 Box::pin(async move {
316 let guard = self.token.lock().map_err(|_| {
317 error::new_authentication_error(500, "Token storage lock poisoned", "")
318 })?;
319 guard.clone().ok_or_else(|| {
320 error::new_authentication_error(
321 401,
322 "No token stored",
323 "Token not found in memory storage",
324 )
325 })
326 })
327 }
328
329 fn delete(&self) -> Pin<Box<dyn Future<Output = crate::Result<()>> + Send + '_>> {
330 Box::pin(async move {
331 let mut guard = self.token.lock().map_err(|_| {
332 error::new_authentication_error(500, "Token storage lock poisoned", "")
333 })?;
334 *guard = None;
335 Ok(())
336 })
337 }
338}
339
340struct TokenState {
346 access_token: String,
347 token_info: Option<TokenInfo>,
348}
349
350pub struct Client {
352 config: Config,
353 pub(crate) http_client: HttpClient,
354 rate_limiter: Option<Arc<RateLimiter>>,
355 pub(crate) token_storage: Box<dyn TokenStorage>,
356 token_state: RwLock<TokenState>,
357}
358
359impl Client {
360 pub async fn new(mut config: Config) -> crate::Result<Self> {
362 config.set_defaults();
363 config.validate()?;
364
365 let rate_limiter = Arc::new(RateLimiter::new(&RateLimiterConfig::default()));
366
367 let http_client = HttpClient::new(
368 config.http_timeout,
369 config.retry_config.clone(),
370 Some(Arc::clone(&rate_limiter)),
371 Some(&config.base_url),
372 Some(&config.user_agent),
373 )?;
374
375 let token_storage: Box<dyn TokenStorage> = Box::new(MemoryTokenStorage::new());
376
377 let (access_token, token_info) = if let Ok(info) = token_storage.load().await {
379 let at = info.access_token.clone();
380 (at, Some(info))
381 } else {
382 (String::new(), None)
383 };
384
385 Ok(Self {
386 config,
387 http_client,
388 rate_limiter: Some(rate_limiter),
389 token_storage,
390 token_state: RwLock::new(TokenState {
391 access_token,
392 token_info,
393 }),
394 })
395 }
396
397 pub async fn with_token_storage(
399 mut config: Config,
400 token_storage: Box<dyn TokenStorage>,
401 ) -> crate::Result<Self> {
402 config.set_defaults();
403 config.validate()?;
404
405 let rate_limiter = Arc::new(RateLimiter::new(&RateLimiterConfig::default()));
406
407 let http_client = HttpClient::new(
408 config.http_timeout,
409 config.retry_config.clone(),
410 Some(Arc::clone(&rate_limiter)),
411 Some(&config.base_url),
412 Some(&config.user_agent),
413 )?;
414
415 let (access_token, token_info) = if let Ok(info) = token_storage.load().await {
416 let at = info.access_token.clone();
417 (at, Some(info))
418 } else {
419 (String::new(), None)
420 };
421
422 Ok(Self {
423 config,
424 http_client,
425 rate_limiter: Some(rate_limiter),
426 token_storage,
427 token_state: RwLock::new(TokenState {
428 access_token,
429 token_info,
430 }),
431 })
432 }
433
434 pub async fn with_token(mut config: Config, access_token: &str) -> crate::Result<Self> {
440 config.set_defaults();
441 config.validate()?;
442
443 let rate_limiter = Arc::new(RateLimiter::new(&RateLimiterConfig::default()));
444
445 let http_client = HttpClient::new(
446 config.http_timeout,
447 config.retry_config.clone(),
448 Some(Arc::clone(&rate_limiter)),
449 Some(&config.base_url),
450 Some(&config.user_agent),
451 )?;
452
453 let token_storage: Box<dyn TokenStorage> = Box::new(MemoryTokenStorage::new());
454
455 let temp_info = TokenInfo {
457 access_token: access_token.to_owned(),
458 token_type: "bearer".into(),
459 expires_at: Utc::now() + chrono::Duration::hours(1),
460 user_id: String::new(),
461 created_at: Utc::now(),
462 };
463
464 let client = Self {
465 config,
466 http_client,
467 rate_limiter: Some(rate_limiter),
468 token_storage,
469 token_state: RwLock::new(TokenState {
470 access_token: access_token.to_owned(),
471 token_info: Some(temp_info),
472 }),
473 };
474
475 let debug_resp = client.debug_token(access_token).await?;
477 client
478 .set_token_from_debug_info(access_token, &debug_resp)
479 .await?;
480
481 Ok(client)
482 }
483
484 pub async fn from_env() -> crate::Result<Self> {
486 let config = Config::from_env()?;
487 Self::new(config).await
488 }
489
490 pub async fn set_token_info(&self, token_info: TokenInfo) -> crate::Result<()> {
494 self.token_storage.store(&token_info).await?;
495 let mut state = self.token_state.write().await;
496 state.access_token = token_info.access_token.clone();
497 state.token_info = Some(token_info);
498 Ok(())
499 }
500
501 pub async fn get_token_info(&self) -> Option<TokenInfo> {
503 self.token_state.read().await.token_info.clone()
504 }
505
506 pub async fn is_authenticated(&self) -> bool {
508 let state = self.token_state.read().await;
509 !state.access_token.is_empty() && state.token_info.is_some()
510 }
511
512 pub async fn is_token_expired(&self) -> bool {
514 let state = self.token_state.read().await;
515 match &state.token_info {
516 Some(info) => Utc::now() > info.expires_at,
517 None => true,
518 }
519 }
520
521 pub async fn is_token_expiring_soon(&self, within: Duration) -> bool {
523 let state = self.token_state.read().await;
524 match &state.token_info {
525 Some(info) => {
526 let threshold = Utc::now()
527 + chrono::Duration::from_std(within).unwrap_or(chrono::Duration::zero());
528 threshold > info.expires_at
529 }
530 None => true,
531 }
532 }
533
534 pub async fn clear_token(&self) -> crate::Result<()> {
536 self.token_storage.delete().await?;
537 let mut state = self.token_state.write().await;
538 state.access_token.clear();
539 state.token_info = None;
540 Ok(())
541 }
542
543 pub async fn access_token(&self) -> String {
545 self.token_state.read().await.access_token.clone()
546 }
547
548 pub(crate) async fn user_id(&self) -> String {
550 self.token_state
551 .read()
552 .await
553 .token_info
554 .as_ref()
555 .map(|t| t.user_id.clone())
556 .unwrap_or_default()
557 }
558
559 pub fn config(&self) -> &Config {
563 &self.config
564 }
565
566 pub async fn update_config(self, mut new_config: Config) -> crate::Result<Client> {
569 new_config.set_defaults();
570 new_config.validate()?;
571
572 let rate_limiter = Arc::new(RateLimiter::new(&RateLimiterConfig::default()));
573
574 let http_client = HttpClient::new(
575 new_config.http_timeout,
576 new_config.retry_config.clone(),
577 Some(Arc::clone(&rate_limiter)),
578 Some(&new_config.base_url),
579 Some(&new_config.user_agent),
580 )?;
581
582 let state = self.token_state.read().await;
583 let access_token = state.access_token.clone();
584 let token_info = state.token_info.clone();
585 drop(state);
586
587 Ok(Client {
588 config: new_config,
589 http_client,
590 rate_limiter: Some(rate_limiter),
591 token_storage: self.token_storage,
592 token_state: RwLock::new(TokenState {
593 access_token,
594 token_info,
595 }),
596 })
597 }
598
599 pub async fn rate_limit_status(&self) -> Option<RateLimitStatus> {
603 if let Some(ref rl) = self.rate_limiter {
604 Some(rl.get_status().await)
605 } else {
606 None
607 }
608 }
609
610 pub async fn is_near_rate_limit(&self, threshold: f64) -> bool {
612 if let Some(ref rl) = self.rate_limiter {
613 rl.is_near_limit(threshold).await
614 } else {
615 false
616 }
617 }
618
619 pub async fn is_rate_limited(&self) -> bool {
621 if let Some(ref rl) = self.rate_limiter {
622 rl.is_rate_limited().await
623 } else {
624 false
625 }
626 }
627
628 pub async fn disable_rate_limiting(&self) {
630 if let Some(ref rl) = self.rate_limiter {
631 rl.disable().await;
632 }
633 }
634
635 pub async fn enable_rate_limiting(&self) {
637 if let Some(ref rl) = self.rate_limiter {
638 rl.enable().await;
639 }
640 }
641
642 pub async fn wait_for_rate_limit(&self) -> crate::Result<()> {
644 if let Some(ref rl) = self.rate_limiter {
645 if rl.should_wait().await {
646 rl.wait().await?;
647 }
648 }
649 Ok(())
650 }
651}
652
653#[cfg(test)]
658mod tests {
659 use super::*;
660
661 fn test_config() -> Config {
662 Config::new(
663 "test-client-id",
664 "test-secret",
665 "https://example.com/callback",
666 )
667 }
668
669 #[test]
670 fn test_config_new_defaults() {
671 let cfg = test_config();
672 assert_eq!(cfg.client_id, "test-client-id");
673 assert_eq!(cfg.base_url, BASE_API_URL);
674 assert_eq!(cfg.http_timeout, DEFAULT_HTTP_TIMEOUT);
675 assert!(!cfg.scopes.is_empty());
676 }
677
678 #[test]
679 fn test_config_validate_ok() {
680 let cfg = test_config();
681 cfg.validate().unwrap();
682 }
683
684 #[test]
685 fn test_config_validate_empty_client_id() {
686 let cfg = Config::new("", "secret", "https://example.com/cb");
687 assert!(cfg.validate().is_err());
688 }
689
690 #[test]
691 fn test_config_validate_bad_redirect_uri() {
692 let cfg = Config::new("id", "secret", "not-a-url");
693 assert!(cfg.validate().is_err());
694 }
695
696 #[test]
697 fn test_config_validate_invalid_scope() {
698 let mut cfg = test_config();
699 cfg.scopes.push("invalid_scope".into());
700 assert!(cfg.validate().is_err());
701 }
702
703 #[test]
704 fn test_config_validate_empty_scopes() {
705 let mut cfg = test_config();
706 cfg.scopes.clear();
707 assert!(cfg.validate().is_err());
708 }
709
710 #[tokio::test]
711 async fn test_memory_token_storage() {
712 let storage = MemoryTokenStorage::new();
713 assert!(storage.load().await.is_err());
714
715 let token = TokenInfo {
716 access_token: "test-token".into(),
717 token_type: "Bearer".into(),
718 expires_at: Utc::now() + chrono::Duration::hours(1),
719 user_id: "user-1".into(),
720 created_at: Utc::now(),
721 };
722
723 storage.store(&token).await.unwrap();
724 let loaded = storage.load().await.unwrap();
725 assert_eq!(loaded.access_token, "test-token");
726
727 storage.delete().await.unwrap();
728 assert!(storage.load().await.is_err());
729 }
730
731 #[tokio::test]
732 async fn test_client_new() {
733 let client = Client::new(test_config()).await.unwrap();
734 assert!(!client.is_authenticated().await);
735 assert!(client.is_token_expired().await);
736 }
737
738 #[tokio::test]
739 async fn test_client_set_and_get_token() {
740 let client = Client::new(test_config()).await.unwrap();
741 let token = TokenInfo {
742 access_token: "my-token".into(),
743 token_type: "Bearer".into(),
744 expires_at: Utc::now() + chrono::Duration::hours(1),
745 user_id: "u-123".into(),
746 created_at: Utc::now(),
747 };
748
749 client.set_token_info(token).await.unwrap();
750 assert!(client.is_authenticated().await);
751 assert!(!client.is_token_expired().await);
752 assert_eq!(client.access_token().await, "my-token");
753 assert_eq!(client.user_id().await, "u-123");
754 }
755
756 #[tokio::test]
757 async fn test_client_clear_token() {
758 let client = Client::new(test_config()).await.unwrap();
759 let token = TokenInfo {
760 access_token: "tok".into(),
761 token_type: "Bearer".into(),
762 expires_at: Utc::now() + chrono::Duration::hours(1),
763 user_id: "u-1".into(),
764 created_at: Utc::now(),
765 };
766
767 client.set_token_info(token).await.unwrap();
768 assert!(client.is_authenticated().await);
769
770 client.clear_token().await.unwrap();
771 assert!(!client.is_authenticated().await);
772 }
773
774 #[tokio::test]
775 async fn test_client_token_expiring_soon() {
776 let client = Client::new(test_config()).await.unwrap();
777 let token = TokenInfo {
778 access_token: "tok".into(),
779 token_type: "Bearer".into(),
780 expires_at: Utc::now() + chrono::Duration::minutes(30),
781 user_id: "u-1".into(),
782 created_at: Utc::now(),
783 };
784
785 client.set_token_info(token).await.unwrap();
786 assert!(
787 client
788 .is_token_expiring_soon(Duration::from_secs(3600))
789 .await
790 );
791 assert!(!client.is_token_expiring_soon(Duration::from_secs(60)).await);
792 }
793
794 #[tokio::test]
795 async fn test_client_rate_limit_status() {
796 let client = Client::new(test_config()).await.unwrap();
797 let status = client.rate_limit_status().await;
798 assert!(status.is_some());
799 assert_eq!(status.unwrap().limit, 100);
800 }
801
802 #[tokio::test]
806 async fn test_client_update_config() {
807 let client = Client::new(test_config()).await.unwrap();
808 let token = TokenInfo {
809 access_token: "keep-me".into(),
810 token_type: "Bearer".into(),
811 expires_at: Utc::now() + chrono::Duration::hours(1),
812 user_id: "u-1".into(),
813 created_at: Utc::now(),
814 };
815 client.set_token_info(token).await.unwrap();
816
817 let mut new_config = test_config();
818 new_config.debug = true;
819 let new_client = client.update_config(new_config).await.unwrap();
820
821 assert!(new_client.config().debug);
822 assert_eq!(new_client.access_token().await, "keep-me");
823 }
824
825 #[tokio::test]
826 async fn test_client_disable_enable_rate_limiting() {
827 let client = Client::new(test_config()).await.unwrap();
828 assert!(!client.is_rate_limited().await);
829
830 client.disable_rate_limiting().await;
831 client.enable_rate_limiting().await;
835 }
836
837 #[tokio::test]
838 async fn test_client_wait_for_rate_limit() {
839 let client = Client::new(test_config()).await.unwrap();
840 client.wait_for_rate_limit().await.unwrap();
842 }
843}