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