1use 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#[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#[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#[derive(Debug, Clone)]
64pub struct ApiClientConfig {
65 pub base_url: String,
67
68 pub max_retries: u32,
70
71 pub initial_backoff_ms: u64,
73
74 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
89pub struct ApiClient {
95 client: Client,
96 pub(crate) token: Option<String>,
97 config: ApiClientConfig,
98}
99
100impl ApiClient {
101 pub fn new() -> Self {
103 Self::with_config(ApiClientConfig::default())
104 }
105
106 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 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 #[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 pub fn base_url(&self) -> &str {
148 &self.config.base_url
149 }
150
151 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 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 self.client
186 .post(&url)
187 .bearer_auth(token)
188 .json(¶ms)
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 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 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 if response.status() == StatusCode::TOO_MANY_REQUESTS {
228 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 tokio::time::sleep(Duration::from_secs(retry_after)).await;
237 attempt += 1;
238 continue;
239 }
240
241 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 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 request = request.header("Authorization", format!("Bearer {}", token));
266
267 if !query_params.is_empty() {
269 request = request.query(query_params);
270 }
271
272 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 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) }
300
301 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 let backoff = base * 2_u64.pow(attempt);
308 let backoff = backoff.min(max);
309
310 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#[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 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 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}