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
41#[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#[derive(Debug, Clone)]
61pub struct ApiClientConfig {
62 pub base_url: String,
64
65 pub max_retries: u32,
67
68 pub initial_backoff_ms: u64,
70
71 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
86pub struct ApiClient {
92 client: Client,
93 pub(crate) token: Option<String>,
94 config: ApiClientConfig,
95}
96
97impl ApiClient {
98 pub fn new() -> Self {
100 Self::with_config(ApiClientConfig::default())
101 }
102
103 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 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 #[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 pub fn base_url(&self) -> &str {
145 &self.config.base_url
146 }
147
148 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 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 self.client
183 .post(&url)
184 .bearer_auth(token)
185 .json(¶ms)
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 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 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 if response.status() == StatusCode::TOO_MANY_REQUESTS {
222 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 tokio::time::sleep(Duration::from_secs(retry_after)).await;
231 attempt += 1;
232 continue;
233 }
234
235 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 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 request = request.header("Authorization", format!("Bearer {}", token));
259
260 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 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) }
288
289 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 let backoff = base * 2_u64.pow(attempt);
296 let backoff = backoff.min(max);
297
298 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#[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 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 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}