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}