Skip to main content

composio_sdk/
client.rs

1//! HTTP client for Composio API
2//!
3//! This module provides the main HTTP client for interacting with the Composio API.
4//! It uses the builder pattern for flexible configuration and includes automatic
5//! retry logic for transient failures.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use composio_sdk::client::ComposioClient;
11//! use std::time::Duration;
12//!
13//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
14//! let client = ComposioClient::builder()
15//!     .api_key("your_api_key")
16//!     .timeout(Duration::from_secs(60))
17//!     .max_retries(5)
18//!     .build()?;
19//! # Ok(())
20//! # }
21//! ```
22
23use crate::config::ComposioConfig;
24use crate::error::ComposioError;
25use crate::retry::RetryPolicy;
26use std::time::Duration;
27
28/// Main client for interacting with Composio API
29///
30/// The client manages HTTP connections and configuration for all API requests.
31/// It includes automatic retry logic for transient failures and proper error handling.
32///
33/// # Example
34///
35/// ```no_run
36/// use composio_sdk::client::ComposioClient;
37///
38/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
39/// let client = ComposioClient::builder()
40///     .api_key("your_api_key")
41///     .build()?;
42/// # Ok(())
43/// # }
44/// ```
45#[derive(Debug, Clone)]
46pub struct ComposioClient {
47    http_client: reqwest::Client,
48    config: ComposioConfig,
49}
50
51/// Builder for ComposioClient
52///
53/// Provides a fluent API for configuring the Composio client with custom settings.
54/// All configuration options are optional and will use sensible defaults if not specified.
55///
56/// # Example
57///
58/// ```no_run
59/// use composio_sdk::client::ComposioClient;
60/// use std::time::Duration;
61///
62/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
63/// let client = ComposioClient::builder()
64///     .api_key("your_api_key")
65///     .base_url("https://custom.api.com")
66///     .timeout(Duration::from_secs(60))
67///     .max_retries(5)
68///     .initial_retry_delay(Duration::from_secs(2))
69///     .max_retry_delay(Duration::from_secs(30))
70///     .build()?;
71/// # Ok(())
72/// # }
73/// ```
74#[derive(Debug, Default)]
75pub struct ComposioClientBuilder {
76    api_key: Option<String>,
77    base_url: Option<String>,
78    timeout: Option<Duration>,
79    max_retries: Option<u32>,
80    initial_retry_delay: Option<Duration>,
81    max_retry_delay: Option<Duration>,
82}
83
84impl ComposioClient {
85    /// Create a new client builder
86    ///
87    /// Returns a `ComposioClientBuilder` that can be used to configure and build
88    /// a `ComposioClient` instance.
89    ///
90    /// # Example
91    ///
92    /// ```no_run
93    /// use composio_sdk::client::ComposioClient;
94    ///
95    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
96    /// let client = ComposioClient::builder()
97    ///     .api_key("your_api_key")
98    ///     .build()?;
99    /// # Ok(())
100    /// # }
101    /// ```
102    pub fn builder() -> ComposioClientBuilder {
103        ComposioClientBuilder::default()
104    }
105
106    /// Get a reference to the HTTP client
107    ///
108    /// This is useful for advanced use cases where you need direct access to the
109    /// underlying reqwest client.
110    pub fn http_client(&self) -> &reqwest::Client {
111        &self.http_client
112    }
113
114    /// Get a reference to the configuration
115    ///
116    /// Returns the configuration used by this client.
117    pub fn config(&self) -> &ComposioConfig {
118        &self.config
119    }
120
121    /// Create a new session for a user
122    ///
123    /// Returns a `SessionBuilder` that can be used to configure and create
124    /// a Tool Router session for the specified user.
125    ///
126    /// # Arguments
127    ///
128    /// * `user_id` - User identifier for session isolation
129    ///
130    /// # Example
131    ///
132    /// ```no_run
133    /// use composio_sdk::client::ComposioClient;
134    ///
135    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
136    /// let client = ComposioClient::builder()
137    ///     .api_key("your_api_key")
138    ///     .build()?;
139    ///
140    /// let session = client
141    ///     .create_session("user_123")
142    ///     .toolkits(vec!["github", "gmail"])
143    ///     .send()
144    ///     .await?;
145    /// # Ok(())
146    /// # }
147    /// ```
148    pub fn create_session(&self, user_id: impl Into<String>) -> crate::session::SessionBuilder<'_> {
149        crate::session::SessionBuilder::new(self, user_id.into())
150    }
151
152    /// Get an existing session by ID
153    ///
154    /// Retrieves session details for a previously created Tool Router session.
155    /// This is useful for inspecting session configuration and available tools.
156    ///
157    /// # Arguments
158    ///
159    /// * `session_id` - The session ID to retrieve
160    ///
161    /// # Errors
162    ///
163    /// Returns an error if:
164    /// - Session not found (404)
165    /// - Network error occurs
166    /// - API returns an error response
167    ///
168    /// # Example
169    ///
170    /// ```no_run
171    /// use composio_sdk::client::ComposioClient;
172    ///
173    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
174    /// let client = ComposioClient::builder()
175    ///     .api_key("your_api_key")
176    ///     .build()?;
177    ///
178    /// let session = client.get_session("sess_abc123").await?;
179    /// println!("Session ID: {}", session.session_id());
180    /// # Ok(())
181    /// # }
182    /// ```
183    pub async fn get_session(
184        &self,
185        session_id: impl Into<String>,
186    ) -> Result<crate::session::Session, ComposioError> {
187        let session_id = session_id.into();
188        let url = format!(
189            "{}/tool_router/session/{}",
190            self.config.base_url, session_id
191        );
192
193        // Execute request with retry logic
194        let response = crate::retry::with_retry(&self.config.retry_policy, || async {
195            let response = self
196                .http_client
197                .get(&url)
198                .send()
199                .await
200                .map_err(ComposioError::NetworkError)?;
201
202            // Check for errors
203            if !response.status().is_success() {
204                return Err(ComposioError::from_response(response).await);
205            }
206
207            Ok(response)
208        })
209        .await?;
210
211        // Parse response
212        let session_response: crate::models::SessionResponse = response
213            .json()
214            .await
215            .map_err(ComposioError::NetworkError)?;
216
217        // Convert to Session
218        Ok(crate::session::Session::from_response(
219            self.clone(),
220            session_response,
221        ))
222    }
223}
224
225impl ComposioClientBuilder {
226    /// Set the API key
227    ///
228    /// The API key is required for authenticating with the Composio API.
229    /// You can obtain your API key from the Composio dashboard.
230    ///
231    /// # Arguments
232    ///
233    /// * `key` - The Composio API key (can be `String` or `&str`)
234    ///
235    /// # Example
236    ///
237    /// ```no_run
238    /// use composio_sdk::client::ComposioClient;
239    ///
240    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
241    /// let client = ComposioClient::builder()
242    ///     .api_key("your_api_key")
243    ///     .build()?;
244    /// # Ok(())
245    /// # }
246    /// ```
247    pub fn api_key(mut self, key: impl Into<String>) -> Self {
248        self.api_key = Some(key.into());
249        self
250    }
251
252    /// Set the base URL
253    ///
254    /// Override the default Composio API base URL. This is useful for testing
255    /// or when using a custom Composio deployment.
256    ///
257    /// # Arguments
258    ///
259    /// * `url` - The base URL (must start with http:// or https://)
260    ///
261    /// # Default
262    ///
263    /// `https://backend.composio.dev/api/v3`
264    ///
265    /// # Example
266    ///
267    /// ```no_run
268    /// use composio_sdk::client::ComposioClient;
269    ///
270    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
271    /// let client = ComposioClient::builder()
272    ///     .api_key("your_api_key")
273    ///     .base_url("https://custom.api.com")
274    ///     .build()?;
275    /// # Ok(())
276    /// # }
277    /// ```
278    pub fn base_url(mut self, url: impl Into<String>) -> Self {
279        self.base_url = Some(url.into());
280        self
281    }
282
283    /// Set the request timeout
284    ///
285    /// Configure how long to wait for API requests to complete before timing out.
286    ///
287    /// # Arguments
288    ///
289    /// * `timeout` - The timeout duration
290    ///
291    /// # Default
292    ///
293    /// 30 seconds
294    ///
295    /// # Example
296    ///
297    /// ```no_run
298    /// use composio_sdk::client::ComposioClient;
299    /// use std::time::Duration;
300    ///
301    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
302    /// let client = ComposioClient::builder()
303    ///     .api_key("your_api_key")
304    ///     .timeout(Duration::from_secs(60))
305    ///     .build()?;
306    /// # Ok(())
307    /// # }
308    /// ```
309    pub fn timeout(mut self, timeout: Duration) -> Self {
310        self.timeout = Some(timeout);
311        self
312    }
313
314    /// Set the maximum number of retries
315    ///
316    /// Configure how many times to retry failed requests for transient errors
317    /// (rate limits, server errors, network issues).
318    ///
319    /// # Arguments
320    ///
321    /// * `retries` - Maximum number of retry attempts
322    ///
323    /// # Default
324    ///
325    /// 3 retries
326    ///
327    /// # Example
328    ///
329    /// ```no_run
330    /// use composio_sdk::client::ComposioClient;
331    ///
332    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
333    /// let client = ComposioClient::builder()
334    ///     .api_key("your_api_key")
335    ///     .max_retries(5)
336    ///     .build()?;
337    /// # Ok(())
338    /// # }
339    /// ```
340    pub fn max_retries(mut self, retries: u32) -> Self {
341        self.max_retries = Some(retries);
342        self
343    }
344
345    /// Set the initial retry delay
346    ///
347    /// Configure the delay before the first retry attempt. Subsequent retries
348    /// use exponential backoff based on this initial delay.
349    ///
350    /// # Arguments
351    ///
352    /// * `delay` - Initial delay duration
353    ///
354    /// # Default
355    ///
356    /// 1 second
357    ///
358    /// # Example
359    ///
360    /// ```no_run
361    /// use composio_sdk::client::ComposioClient;
362    /// use std::time::Duration;
363    ///
364    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
365    /// let client = ComposioClient::builder()
366    ///     .api_key("your_api_key")
367    ///     .initial_retry_delay(Duration::from_secs(2))
368    ///     .build()?;
369    /// # Ok(())
370    /// # }
371    /// ```
372    pub fn initial_retry_delay(mut self, delay: Duration) -> Self {
373        self.initial_retry_delay = Some(delay);
374        self
375    }
376
377    /// Set the maximum retry delay
378    ///
379    /// Configure the maximum delay between retry attempts. This caps the
380    /// exponential backoff to prevent excessively long waits.
381    ///
382    /// # Arguments
383    ///
384    /// * `delay` - Maximum delay duration
385    ///
386    /// # Default
387    ///
388    /// 10 seconds
389    ///
390    /// # Example
391    ///
392    /// ```no_run
393    /// use composio_sdk::client::ComposioClient;
394    /// use std::time::Duration;
395    ///
396    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
397    /// let client = ComposioClient::builder()
398    ///     .api_key("your_api_key")
399    ///     .max_retry_delay(Duration::from_secs(30))
400    ///     .build()?;
401    /// # Ok(())
402    /// # }
403    /// ```
404    pub fn max_retry_delay(mut self, delay: Duration) -> Self {
405        self.max_retry_delay = Some(delay);
406        self
407    }
408
409    /// Build the client
410    ///
411    /// Validates the configuration and constructs a `ComposioClient` instance.
412    /// The reqwest HTTP client is configured with the specified timeout and
413    /// default headers (including the API key).
414    ///
415    /// # Errors
416    ///
417    /// Returns an error if:
418    /// - API key is not provided or is empty
419    /// - Base URL is invalid (doesn't start with http:// or https://)
420    /// - HTTP client construction fails
421    ///
422    /// # Example
423    ///
424    /// ```no_run
425    /// use composio_sdk::client::ComposioClient;
426    ///
427    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
428    /// let client = ComposioClient::builder()
429    ///     .api_key("your_api_key")
430    ///     .build()?;
431    /// # Ok(())
432    /// # }
433    /// ```
434    pub fn build(self) -> Result<ComposioClient, ComposioError> {
435        // Require API key
436        let api_key = self.api_key.ok_or_else(|| {
437            ComposioError::InvalidInput("API key is required".to_string())
438        })?;
439
440        // Build configuration with defaults
441        let mut config = ComposioConfig::new(api_key);
442
443        if let Some(base_url) = self.base_url {
444            config.base_url = base_url;
445        }
446
447        if let Some(timeout) = self.timeout {
448            config.timeout = timeout;
449        }
450
451        // Build retry policy
452        let mut retry_policy = RetryPolicy::default();
453        if let Some(max_retries) = self.max_retries {
454            retry_policy.max_retries = max_retries;
455        }
456        if let Some(initial_delay) = self.initial_retry_delay {
457            retry_policy.initial_delay = initial_delay;
458        }
459        if let Some(max_delay) = self.max_retry_delay {
460            retry_policy.max_delay = max_delay;
461        }
462        config.retry_policy = retry_policy;
463
464        // Validate configuration
465        config.validate()?;
466
467        // Build HTTP client with timeout and default headers
468        let mut headers = reqwest::header::HeaderMap::new();
469        headers.insert(
470            "x-api-key",
471            reqwest::header::HeaderValue::from_str(&config.api_key)
472                .map_err(|_| ComposioError::InvalidInput("Invalid API key format".to_string()))?,
473        );
474
475        let http_client = reqwest::Client::builder()
476            .timeout(config.timeout)
477            .default_headers(headers)
478            .build()
479            .map_err(|e| ComposioError::NetworkError(e))?;
480
481        Ok(ComposioClient {
482            http_client,
483            config,
484        })
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn test_builder_with_api_key_only() {
494        let client = ComposioClient::builder()
495            .api_key("test_key")
496            .build()
497            .unwrap();
498
499        assert_eq!(client.config().api_key, "test_key");
500        assert_eq!(
501            client.config().base_url,
502            "https://backend.composio.dev/api/v3"
503        );
504        assert_eq!(client.config().timeout, Duration::from_secs(30));
505        assert_eq!(client.config().retry_policy.max_retries, 3);
506    }
507
508    #[test]
509    fn test_builder_with_all_options() {
510        let client = ComposioClient::builder()
511            .api_key("test_key")
512            .base_url("https://custom.api.com")
513            .timeout(Duration::from_secs(60))
514            .max_retries(5)
515            .initial_retry_delay(Duration::from_secs(2))
516            .max_retry_delay(Duration::from_secs(30))
517            .build()
518            .unwrap();
519
520        assert_eq!(client.config().api_key, "test_key");
521        assert_eq!(client.config().base_url, "https://custom.api.com");
522        assert_eq!(client.config().timeout, Duration::from_secs(60));
523        assert_eq!(client.config().retry_policy.max_retries, 5);
524        assert_eq!(
525            client.config().retry_policy.initial_delay,
526            Duration::from_secs(2)
527        );
528        assert_eq!(
529            client.config().retry_policy.max_delay,
530            Duration::from_secs(30)
531        );
532    }
533
534    #[test]
535    fn test_builder_without_api_key_fails() {
536        let result = ComposioClient::builder().build();
537
538        assert!(result.is_err());
539        match result {
540            Err(ComposioError::InvalidInput(msg)) => {
541                assert_eq!(msg, "API key is required");
542            }
543            _ => panic!("Expected InvalidInput error"),
544        }
545    }
546
547    #[test]
548    fn test_builder_with_empty_api_key_fails() {
549        let result = ComposioClient::builder().api_key("").build();
550
551        assert!(result.is_err());
552        match result {
553            Err(ComposioError::InvalidInput(msg)) => {
554                assert_eq!(msg, "API key cannot be empty");
555            }
556            _ => panic!("Expected InvalidInput error"),
557        }
558    }
559
560    #[test]
561    fn test_builder_with_invalid_base_url_fails() {
562        let result = ComposioClient::builder()
563            .api_key("test_key")
564            .base_url("invalid-url")
565            .build();
566
567        assert!(result.is_err());
568        match result {
569            Err(ComposioError::ConfigError(msg)) => {
570                assert_eq!(msg, "Base URL must start with http:// or https://");
571            }
572            _ => panic!("Expected ConfigError"),
573        }
574    }
575
576    #[test]
577    fn test_builder_accepts_string_api_key() {
578        let client = ComposioClient::builder()
579            .api_key("test_key".to_string())
580            .build()
581            .unwrap();
582
583        assert_eq!(client.config().api_key, "test_key");
584    }
585
586    #[test]
587    fn test_builder_accepts_str_api_key() {
588        let client = ComposioClient::builder()
589            .api_key("test_key")
590            .build()
591            .unwrap();
592
593        assert_eq!(client.config().api_key, "test_key");
594    }
595
596    #[test]
597    fn test_client_is_cloneable() {
598        let client = ComposioClient::builder()
599            .api_key("test_key")
600            .build()
601            .unwrap();
602
603        let cloned = client.clone();
604        assert_eq!(client.config().api_key, cloned.config().api_key);
605    }
606
607    #[test]
608    fn test_client_is_debuggable() {
609        let client = ComposioClient::builder()
610            .api_key("test_key")
611            .build()
612            .unwrap();
613
614        let debug_str = format!("{:?}", client);
615        assert!(debug_str.contains("ComposioClient"));
616    }
617
618    #[test]
619    fn test_builder_is_debuggable() {
620        let builder = ComposioClient::builder().api_key("test_key");
621
622        let debug_str = format!("{:?}", builder);
623        assert!(debug_str.contains("ComposioClientBuilder"));
624    }
625
626    #[test]
627    fn test_http_client_has_correct_timeout() {
628        let client = ComposioClient::builder()
629            .api_key("test_key")
630            .timeout(Duration::from_secs(45))
631            .build()
632            .unwrap();
633
634        assert_eq!(client.config().timeout, Duration::from_secs(45));
635    }
636
637    #[test]
638    fn test_config_accessor() {
639        let client = ComposioClient::builder()
640            .api_key("test_key")
641            .build()
642            .unwrap();
643
644        let config = client.config();
645        assert_eq!(config.api_key, "test_key");
646    }
647
648    #[test]
649    fn test_http_client_accessor() {
650        let client = ComposioClient::builder()
651            .api_key("test_key")
652            .build()
653            .unwrap();
654
655        let _http_client = client.http_client();
656        // Just verify we can access it without panic
657    }
658
659    #[test]
660    fn test_builder_method_chaining() {
661        let client = ComposioClient::builder()
662            .api_key("test_key")
663            .base_url("https://test.com")
664            .timeout(Duration::from_secs(60))
665            .max_retries(5)
666            .initial_retry_delay(Duration::from_secs(2))
667            .max_retry_delay(Duration::from_secs(30))
668            .build()
669            .unwrap();
670
671        assert_eq!(client.config().api_key, "test_key");
672        assert_eq!(client.config().base_url, "https://test.com");
673    }
674
675    #[test]
676    fn test_default_retry_policy() {
677        let client = ComposioClient::builder()
678            .api_key("test_key")
679            .build()
680            .unwrap();
681
682        assert_eq!(client.config().retry_policy.max_retries, 3);
683        assert_eq!(
684            client.config().retry_policy.initial_delay,
685            Duration::from_secs(1)
686        );
687        assert_eq!(
688            client.config().retry_policy.max_delay,
689            Duration::from_secs(10)
690        );
691    }
692
693    #[test]
694    fn test_custom_retry_policy() {
695        let client = ComposioClient::builder()
696            .api_key("test_key")
697            .max_retries(7)
698            .initial_retry_delay(Duration::from_millis(500))
699            .max_retry_delay(Duration::from_secs(20))
700            .build()
701            .unwrap();
702
703        assert_eq!(client.config().retry_policy.max_retries, 7);
704        assert_eq!(
705            client.config().retry_policy.initial_delay,
706            Duration::from_millis(500)
707        );
708        assert_eq!(
709            client.config().retry_policy.max_delay,
710            Duration::from_secs(20)
711        );
712    }
713
714    #[test]
715    fn test_partial_retry_policy_customization() {
716        let client = ComposioClient::builder()
717            .api_key("test_key")
718            .max_retries(5)
719            .build()
720            .unwrap();
721
722        assert_eq!(client.config().retry_policy.max_retries, 5);
723        assert_eq!(
724            client.config().retry_policy.initial_delay,
725            Duration::from_secs(1)
726        );
727        assert_eq!(
728            client.config().retry_policy.max_delay,
729            Duration::from_secs(10)
730        );
731    }
732}