Skip to main content

slack_rs/api/
client.rs

1//! HTTP client for Slack API calls
2//!
3//! This module provides a configurable HTTP client with:
4//! - Configurable base URL (for testing with mock servers)
5//! - Retry logic with exponential backoff
6//! - Rate limit handling (429 + Retry-After)
7//! - Support for both wrapper commands and generic API calls
8
9use reqwest::{Client, Method, Response, StatusCode};
10use serde_json::Value;
11use std::collections::HashMap;
12use std::time::Duration;
13use thiserror::Error;
14
15use super::guidance::format_error_guidance;
16use super::types::{ApiMethod, ApiResponse};
17
18/// API client errors (for wrapper commands)
19#[derive(Error, Debug)]
20pub enum ApiError {
21    #[error("HTTP request failed: {0}")]
22    RequestFailed(#[from] reqwest::Error),
23
24    #[error("JSON serialization failed: {0}")]
25    JsonError(#[from] serde_json::Error),
26
27    #[error("Slack API error: {0}")]
28    SlackError(String),
29
30    #[allow(dead_code)]
31    #[error("Missing required parameter: {0}")]
32    MissingParameter(String),
33
34    #[error("Write operation denied. Set SLACKCLI_ALLOW_WRITE=true to enable write operations")]
35    WriteNotAllowed,
36
37    #[error("Destructive operation cancelled")]
38    OperationCancelled,
39
40    #[error("Non-interactive mode error: {0}")]
41    NonInteractiveError(String),
42}
43
44/// API client errors (for generic API calls)
45#[derive(Debug, Error)]
46pub enum ApiClientError {
47    #[error("HTTP request failed: {0}")]
48    RequestFailed(#[from] reqwest::Error),
49
50    #[error("Rate limit exceeded, retry after {0} seconds")]
51    RateLimitExceeded(u64),
52
53    #[error("API error: {0}")]
54    ApiError(String),
55
56    #[error("Invalid response: {0}")]
57    InvalidResponse(String),
58}
59
60pub type Result<T> = std::result::Result<T, ApiClientError>;
61
62/// Configuration for the API client
63#[derive(Debug, Clone)]
64pub struct ApiClientConfig {
65    /// Base URL for API calls (default: https://slack.com/api)
66    pub base_url: String,
67
68    /// Maximum number of retry attempts
69    pub max_retries: u32,
70
71    /// Initial backoff duration in milliseconds
72    pub initial_backoff_ms: u64,
73
74    /// Maximum backoff duration in milliseconds
75    pub max_backoff_ms: u64,
76}
77
78impl Default for ApiClientConfig {
79    fn default() -> Self {
80        Self {
81            base_url: "https://slack.com/api".to_string(),
82            max_retries: 3,
83            initial_backoff_ms: 1000,
84            max_backoff_ms: 32000,
85        }
86    }
87}
88
89/// Slack API client
90///
91/// Supports both:
92/// - Wrapper commands via `call_method()` with `ApiMethod` enum
93/// - Generic API calls via `call()` with arbitrary endpoints
94pub struct ApiClient {
95    client: Client,
96    pub(crate) token: Option<String>,
97    config: ApiClientConfig,
98}
99
100impl ApiClient {
101    /// Create a new API client with default configuration (for generic API calls)
102    pub fn new() -> Self {
103        Self::with_config(ApiClientConfig::default())
104    }
105
106    /// Create a new API client with a token (for wrapper commands)
107    pub fn with_token(token: String) -> Self {
108        Self {
109            client: Client::builder()
110                .timeout(Duration::from_secs(30))
111                .build()
112                .expect("Failed to create HTTP client"),
113            token: Some(token),
114            config: ApiClientConfig::default(),
115        }
116    }
117
118    /// Create a new API client with custom configuration
119    pub fn with_config(config: ApiClientConfig) -> Self {
120        let client = Client::builder()
121            .timeout(Duration::from_secs(30))
122            .build()
123            .expect("Failed to create HTTP client");
124
125        Self {
126            client,
127            token: None,
128            config,
129        }
130    }
131
132    /// Create a new API client with custom base URL (for testing)
133    #[doc(hidden)]
134    #[allow(dead_code)]
135    pub fn new_with_base_url(token: String, base_url: String) -> Self {
136        Self {
137            client: Client::new(),
138            token: Some(token),
139            config: ApiClientConfig {
140                base_url,
141                ..Default::default()
142            },
143        }
144    }
145
146    /// Get the base URL
147    pub fn base_url(&self) -> &str {
148        &self.config.base_url
149    }
150
151    /// Call a Slack API method using the ApiMethod enum (for wrapper commands)
152    pub async fn call_method(
153        &self,
154        method: ApiMethod,
155        params: HashMap<String, Value>,
156    ) -> std::result::Result<ApiResponse, ApiError> {
157        let token = self
158            .token
159            .as_ref()
160            .ok_or_else(|| ApiError::SlackError("No token configured".to_string()))?;
161
162        let url = format!("{}/{}", self.config.base_url, method.as_str());
163
164        let response = if method.uses_get_method() {
165            // Use GET request with query parameters
166            let mut query_params = vec![];
167            for (key, value) in params {
168                let value_str = match value {
169                    Value::String(s) => s,
170                    Value::Number(n) => n.to_string(),
171                    Value::Bool(b) => b.to_string(),
172                    _ => serde_json::to_string(&value).unwrap_or_default(),
173                };
174                query_params.push((key, value_str));
175            }
176
177            self.client
178                .get(&url)
179                .bearer_auth(token)
180                .query(&query_params)
181                .send()
182                .await?
183        } else {
184            // Use POST request with JSON body
185            self.client
186                .post(&url)
187                .bearer_auth(token)
188                .json(&params)
189                .send()
190                .await?
191        };
192
193        let response_json: ApiResponse = response.json().await?;
194
195        if !response_json.ok {
196            let error_code = response_json.error.as_deref().unwrap_or("Unknown error");
197
198            // Display error guidance if available
199            if let Some(guidance) = format_error_guidance(error_code) {
200                eprintln!("{}", guidance);
201            }
202
203            return Err(ApiError::SlackError(error_code.to_string()));
204        }
205
206        Ok(response_json)
207    }
208
209    /// Make an API call with automatic retry logic (for generic API calls)
210    pub async fn call(
211        &self,
212        method: Method,
213        endpoint: &str,
214        token: &str,
215        body: RequestBody,
216        query_params: Vec<(String, String)>,
217    ) -> Result<Response> {
218        let url = format!("{}/{}", self.config.base_url, endpoint);
219        let mut attempt = 0;
220
221        loop {
222            let response = self
223                .execute_request(&url, &method, token, &body, &query_params)
224                .await?;
225
226            // Check for rate limiting
227            if response.status() == StatusCode::TOO_MANY_REQUESTS {
228                // Extract Retry-After header
229                let retry_after = self.extract_retry_after(&response);
230
231                if attempt >= self.config.max_retries {
232                    return Err(ApiClientError::RateLimitExceeded(retry_after));
233                }
234
235                // Wait for the specified duration
236                tokio::time::sleep(Duration::from_secs(retry_after)).await;
237                attempt += 1;
238                continue;
239            }
240
241            // For other errors, apply exponential backoff
242            if !response.status().is_success() && attempt < self.config.max_retries {
243                let backoff = self.calculate_backoff(attempt);
244                tokio::time::sleep(backoff).await;
245                attempt += 1;
246                continue;
247            }
248
249            return Ok(response);
250        }
251    }
252
253    /// Execute a single HTTP request
254    async fn execute_request(
255        &self,
256        url: &str,
257        method: &Method,
258        token: &str,
259        body: &RequestBody,
260        query_params: &[(String, String)],
261    ) -> Result<Response> {
262        let mut request = self.client.request(method.clone(), url);
263
264        // Add authorization header
265        request = request.header("Authorization", format!("Bearer {}", token));
266
267        // Add query parameters
268        if !query_params.is_empty() {
269            request = request.query(query_params);
270        }
271
272        // Add body based on type
273        match body {
274            RequestBody::Form(params) => {
275                request = request
276                    .header("Content-Type", "application/x-www-form-urlencoded")
277                    .form(params);
278            }
279            RequestBody::Json(json) => {
280                request = request
281                    .header("Content-Type", "application/json")
282                    .json(json);
283            }
284            RequestBody::None => {}
285        }
286
287        let response = request.send().await?;
288        Ok(response)
289    }
290
291    /// Extract Retry-After header value
292    fn extract_retry_after(&self, response: &Response) -> u64 {
293        response
294            .headers()
295            .get("Retry-After")
296            .and_then(|v| v.to_str().ok())
297            .and_then(|s| s.parse::<u64>().ok())
298            .unwrap_or(60) // Default to 60 seconds if not specified
299    }
300
301    /// Calculate exponential backoff with jitter
302    fn calculate_backoff(&self, attempt: u32) -> Duration {
303        let base = self.config.initial_backoff_ms;
304        let max = self.config.max_backoff_ms;
305
306        // Exponential backoff: base * 2^attempt
307        let backoff = base * 2_u64.pow(attempt);
308        let backoff = backoff.min(max);
309
310        // Add jitter (±25%)
311        let jitter = (backoff as f64 * 0.25) as u64;
312        let jitter = rand::random::<u64>() % (jitter * 2 + 1);
313        let backoff = backoff
314            .saturating_sub(jitter / 2)
315            .saturating_add(jitter / 2);
316
317        Duration::from_millis(backoff)
318    }
319}
320
321impl Default for ApiClient {
322    fn default() -> Self {
323        Self::new()
324    }
325}
326
327/// Request body type
328#[derive(Debug, Clone)]
329pub enum RequestBody {
330    Form(Vec<(String, String)>),
331    Json(Value),
332    None,
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn test_api_method_as_str() {
341        assert_eq!(ApiMethod::SearchMessages.as_str(), "search.messages");
342        assert_eq!(ApiMethod::ConversationsList.as_str(), "conversations.list");
343        assert_eq!(
344            ApiMethod::ConversationsHistory.as_str(),
345            "conversations.history"
346        );
347        assert_eq!(
348            ApiMethod::ConversationsReplies.as_str(),
349            "conversations.replies"
350        );
351        assert_eq!(ApiMethod::UsersInfo.as_str(), "users.info");
352        assert_eq!(ApiMethod::ChatPostMessage.as_str(), "chat.postMessage");
353        assert_eq!(ApiMethod::ChatUpdate.as_str(), "chat.update");
354        assert_eq!(ApiMethod::ChatDelete.as_str(), "chat.delete");
355        assert_eq!(ApiMethod::ReactionsAdd.as_str(), "reactions.add");
356        assert_eq!(ApiMethod::ReactionsRemove.as_str(), "reactions.remove");
357    }
358
359    #[test]
360    fn test_api_method_is_write() {
361        assert!(!ApiMethod::SearchMessages.is_write());
362        assert!(!ApiMethod::ConversationsList.is_write());
363        assert!(!ApiMethod::ConversationsHistory.is_write());
364        assert!(!ApiMethod::UsersInfo.is_write());
365        assert!(ApiMethod::ChatPostMessage.is_write());
366        assert!(ApiMethod::ChatUpdate.is_write());
367        assert!(ApiMethod::ChatDelete.is_write());
368        assert!(ApiMethod::ReactionsAdd.is_write());
369        assert!(ApiMethod::ReactionsRemove.is_write());
370    }
371
372    #[test]
373    fn test_api_method_is_destructive() {
374        assert!(!ApiMethod::SearchMessages.is_destructive());
375        assert!(!ApiMethod::ConversationsList.is_destructive());
376        assert!(!ApiMethod::ConversationsHistory.is_destructive());
377        assert!(!ApiMethod::UsersInfo.is_destructive());
378        assert!(!ApiMethod::ChatPostMessage.is_destructive());
379        assert!(ApiMethod::ChatUpdate.is_destructive());
380        assert!(ApiMethod::ChatDelete.is_destructive());
381        assert!(!ApiMethod::ReactionsAdd.is_destructive());
382        assert!(ApiMethod::ReactionsRemove.is_destructive());
383    }
384
385    #[test]
386    fn test_api_method_uses_get() {
387        // GET methods
388        assert!(ApiMethod::SearchMessages.uses_get_method());
389        assert!(ApiMethod::ConversationsList.uses_get_method());
390        assert!(ApiMethod::ConversationsHistory.uses_get_method());
391        assert!(ApiMethod::ConversationsReplies.uses_get_method());
392        assert!(ApiMethod::UsersInfo.uses_get_method());
393        assert!(ApiMethod::UsersList.uses_get_method());
394
395        // POST methods
396        assert!(!ApiMethod::ChatPostMessage.uses_get_method());
397        assert!(!ApiMethod::ChatUpdate.uses_get_method());
398        assert!(!ApiMethod::ChatDelete.uses_get_method());
399        assert!(!ApiMethod::ReactionsAdd.uses_get_method());
400        assert!(!ApiMethod::ReactionsRemove.uses_get_method());
401    }
402
403    #[test]
404    fn test_api_client_config_default() {
405        let config = ApiClientConfig::default();
406        assert_eq!(config.base_url, "https://slack.com/api");
407        assert_eq!(config.max_retries, 3);
408        assert_eq!(config.initial_backoff_ms, 1000);
409        assert_eq!(config.max_backoff_ms, 32000);
410    }
411
412    #[test]
413    fn test_api_client_creation() {
414        let client = ApiClient::new();
415        assert_eq!(client.base_url(), "https://slack.com/api");
416    }
417
418    #[test]
419    fn test_api_client_custom_config() {
420        let config = ApiClientConfig {
421            base_url: "https://test.example.com".to_string(),
422            max_retries: 5,
423            initial_backoff_ms: 500,
424            max_backoff_ms: 10000,
425        };
426
427        let client = ApiClient::with_config(config.clone());
428        assert_eq!(client.base_url(), "https://test.example.com");
429        assert_eq!(client.config.max_retries, 5);
430    }
431}