Skip to main content

slack_rs/api/
call.rs

1//! API call handler with metadata attachment
2//!
3//! Executes API calls and enriches responses with execution context:
4//! - Profile name
5//! - Team ID
6//! - User ID
7//! - Method name
8
9use super::args::ApiCallArgs;
10use super::client::{ApiClient, RequestBody};
11use super::guidance::format_error_guidance;
12use reqwest::Method;
13use serde::{Deserialize, Serialize};
14use serde_json::{json, Value};
15use thiserror::Error;
16
17#[derive(Debug, Error)]
18pub enum ApiCallError {
19    #[error("Client error: {0}")]
20    ClientError(#[from] super::client::ApiClientError),
21
22    #[error("Failed to parse response: {0}")]
23    ParseError(String),
24}
25
26pub type Result<T> = std::result::Result<T, ApiCallError>;
27
28/// Execution context for API calls
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct ApiCallContext {
31    pub profile_name: Option<String>,
32    pub team_id: String,
33    pub user_id: String,
34}
35
36/// API call response with metadata
37#[derive(Debug, Serialize, Deserialize)]
38pub struct ApiCallResponse {
39    /// Original API response
40    pub response: Value,
41
42    /// Execution metadata
43    pub meta: ApiCallMeta,
44}
45
46/// Execution metadata
47#[derive(Debug, Serialize, Deserialize)]
48pub struct ApiCallMeta {
49    pub profile_name: Option<String>,
50    pub team_id: String,
51    pub user_id: String,
52    pub method: String,
53    pub command: String,
54    pub token_type: String,
55}
56
57/// Execute an API call with the given arguments, context, token type, and command name
58pub async fn execute_api_call(
59    client: &ApiClient,
60    args: &ApiCallArgs,
61    token: &str,
62    context: &ApiCallContext,
63    token_type: &str,
64    command: &str,
65) -> Result<ApiCallResponse> {
66    // Determine HTTP method
67    let method = if args.use_get {
68        Method::GET
69    } else {
70        Method::POST
71    };
72
73    // Prepare request body and query params
74    let (body, query_params) = if method == Method::GET {
75        // For GET requests, use query params and no body
76        (RequestBody::None, args.to_form())
77    } else if args.use_json {
78        // For POST with JSON, use JSON body and no query params
79        (RequestBody::Json(args.to_json()), vec![])
80    } else {
81        // For POST with form data, use form body and no query params
82        (RequestBody::Form(args.to_form()), vec![])
83    };
84
85    // Make the API call
86    let response = client
87        .call(method, &args.method, token, body, query_params)
88        .await?;
89
90    // Parse response body
91    let response_text = response
92        .text()
93        .await
94        .map_err(|e| ApiCallError::ParseError(e.to_string()))?;
95
96    let response_json: Value = serde_json::from_str(&response_text)
97        .map_err(|e| ApiCallError::ParseError(e.to_string()))?;
98
99    // Construct response with metadata
100    let api_response = ApiCallResponse {
101        response: response_json,
102        meta: ApiCallMeta {
103            profile_name: context.profile_name.clone(),
104            team_id: context.team_id.clone(),
105            user_id: context.user_id.clone(),
106            method: args.method.clone(),
107            command: command.to_string(),
108            token_type: token_type.to_string(),
109        },
110    };
111
112    Ok(api_response)
113}
114
115/// Build error guidance string from an API call response
116///
117/// Returns a guidance string if the response contains a known error code.
118/// Returns `None` for success responses or unknown error codes.
119///
120/// # Arguments
121/// * `response` - The API call response to analyze
122///
123/// # Returns
124/// * `Some(String)` - Formatted guidance message with Error/Cause/Resolution
125/// * `None` - No guidance available (success or unknown error)
126pub fn build_error_guidance(response: &ApiCallResponse) -> Option<String> {
127    // Check if response has an error
128    if let Some(ok) = response.response.get("ok").and_then(|v| v.as_bool()) {
129        if !ok {
130            // Try to get error code from response
131            if let Some(error_code) = response.response.get("error").and_then(|v| v.as_str()) {
132                // Return guidance if available
133                return format_error_guidance(error_code);
134            }
135        }
136    }
137    None
138}
139
140/// Display error guidance to stderr if the response contains a known error
141pub fn display_error_guidance(response: &ApiCallResponse) {
142    if let Some(guidance) = build_error_guidance(response) {
143        eprintln!("{}", guidance);
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::collections::HashMap;
151
152    #[test]
153    fn test_api_call_meta_serialization() {
154        let meta = ApiCallMeta {
155            profile_name: Some("default".to_string()),
156            team_id: "T123ABC".to_string(),
157            user_id: "U456DEF".to_string(),
158            method: "chat.postMessage".to_string(),
159            command: "api call".to_string(),
160            token_type: "bot".to_string(),
161        };
162
163        let json = serde_json::to_string(&meta).unwrap();
164        let deserialized: ApiCallMeta = serde_json::from_str(&json).unwrap();
165
166        assert_eq!(deserialized.profile_name, Some("default".to_string()));
167        assert_eq!(deserialized.team_id, "T123ABC");
168        assert_eq!(deserialized.user_id, "U456DEF");
169        assert_eq!(deserialized.method, "chat.postMessage");
170        assert_eq!(deserialized.command, "api call");
171        assert_eq!(deserialized.token_type, "bot");
172    }
173
174    #[test]
175    fn test_api_call_response_structure() {
176        let response = ApiCallResponse {
177            response: json!({
178                "ok": true,
179                "channel": "C123456",
180                "ts": "1234567890.123456"
181            }),
182            meta: ApiCallMeta {
183                profile_name: Some("work".to_string()),
184                team_id: "T123ABC".to_string(),
185                user_id: "U456DEF".to_string(),
186                method: "chat.postMessage".to_string(),
187                command: "api call".to_string(),
188                token_type: "bot".to_string(),
189            },
190        };
191
192        let json = serde_json::to_value(&response).unwrap();
193
194        assert!(json["response"]["ok"].as_bool().unwrap());
195        assert_eq!(json["meta"]["team_id"], "T123ABC");
196        assert_eq!(json["meta"]["method"], "chat.postMessage");
197        assert_eq!(json["meta"]["command"], "api call");
198        assert_eq!(json["meta"]["token_type"], "bot");
199    }
200
201    #[test]
202    fn test_display_error_guidance_with_known_error() {
203        // Create response with known error code
204        let response = ApiCallResponse {
205            response: json!({
206                "ok": false,
207                "error": "missing_scope"
208            }),
209            meta: ApiCallMeta {
210                profile_name: Some("default".to_string()),
211                team_id: "T123ABC".to_string(),
212                user_id: "U456DEF".to_string(),
213                method: "chat.postMessage".to_string(),
214                command: "api call".to_string(),
215                token_type: "bot".to_string(),
216            },
217        };
218
219        // This should not panic - guidance should be displayed to stderr
220        display_error_guidance(&response);
221    }
222
223    #[test]
224    fn test_display_error_guidance_with_unknown_error() {
225        // Create response with unknown error code
226        let response = ApiCallResponse {
227            response: json!({
228                "ok": false,
229                "error": "unknown_error_code"
230            }),
231            meta: ApiCallMeta {
232                profile_name: Some("default".to_string()),
233                team_id: "T123ABC".to_string(),
234                user_id: "U456DEF".to_string(),
235                method: "chat.postMessage".to_string(),
236                command: "api call".to_string(),
237                token_type: "bot".to_string(),
238            },
239        };
240
241        // This should not panic - no guidance for unknown errors
242        display_error_guidance(&response);
243    }
244
245    #[test]
246    fn test_display_error_guidance_with_success() {
247        // Create successful response
248        let response = ApiCallResponse {
249            response: json!({
250                "ok": true,
251                "channel": "C123456"
252            }),
253            meta: ApiCallMeta {
254                profile_name: Some("default".to_string()),
255                team_id: "T123ABC".to_string(),
256                user_id: "U456DEF".to_string(),
257                method: "chat.postMessage".to_string(),
258                command: "api call".to_string(),
259                token_type: "bot".to_string(),
260            },
261        };
262
263        // This should not display anything (success case)
264        display_error_guidance(&response);
265    }
266
267    #[test]
268    fn test_display_error_guidance_with_not_allowed_token_type() {
269        // Create response with not_allowed_token_type error
270        let response = ApiCallResponse {
271            response: json!({
272                "ok": false,
273                "error": "not_allowed_token_type"
274            }),
275            meta: ApiCallMeta {
276                profile_name: Some("default".to_string()),
277                team_id: "T123ABC".to_string(),
278                user_id: "U456DEF".to_string(),
279                method: "conversations.history".to_string(),
280                command: "api call".to_string(),
281                token_type: "bot".to_string(),
282            },
283        };
284
285        // This should display guidance to stderr
286        display_error_guidance(&response);
287    }
288
289    #[test]
290    fn test_display_error_guidance_with_invalid_auth() {
291        // Create response with invalid_auth error
292        let response = ApiCallResponse {
293            response: json!({
294                "ok": false,
295                "error": "invalid_auth"
296            }),
297            meta: ApiCallMeta {
298                profile_name: Some("default".to_string()),
299                team_id: "T123ABC".to_string(),
300                user_id: "U456DEF".to_string(),
301                method: "auth.test".to_string(),
302                command: "api call".to_string(),
303                token_type: "bot".to_string(),
304            },
305        };
306
307        // This should display guidance to stderr
308        display_error_guidance(&response);
309    }
310
311    // Tests for build_error_guidance pure function
312
313    #[test]
314    fn test_build_error_guidance_missing_scope() {
315        let response = ApiCallResponse {
316            response: json!({
317                "ok": false,
318                "error": "missing_scope"
319            }),
320            meta: ApiCallMeta {
321                profile_name: Some("default".to_string()),
322                team_id: "T123ABC".to_string(),
323                user_id: "U456DEF".to_string(),
324                method: "chat.postMessage".to_string(),
325                command: "api call".to_string(),
326                token_type: "bot".to_string(),
327            },
328        };
329
330        let guidance = build_error_guidance(&response);
331        assert!(guidance.is_some());
332        let guidance = guidance.unwrap();
333        assert!(guidance.contains("Error:"));
334        assert!(guidance.contains("Cause:"));
335        assert!(guidance.contains("Resolution:"));
336        assert!(guidance.contains("missing_scope"));
337    }
338
339    #[test]
340    fn test_build_error_guidance_not_allowed_token_type() {
341        let response = ApiCallResponse {
342            response: json!({
343                "ok": false,
344                "error": "not_allowed_token_type"
345            }),
346            meta: ApiCallMeta {
347                profile_name: Some("default".to_string()),
348                team_id: "T123ABC".to_string(),
349                user_id: "U456DEF".to_string(),
350                method: "conversations.history".to_string(),
351                command: "api call".to_string(),
352                token_type: "bot".to_string(),
353            },
354        };
355
356        let guidance = build_error_guidance(&response);
357        assert!(guidance.is_some());
358        let guidance = guidance.unwrap();
359        assert!(guidance.contains("Error:"));
360        assert!(guidance.contains("Cause:"));
361        assert!(guidance.contains("Resolution:"));
362        assert!(guidance.contains("not_allowed_token_type"));
363    }
364
365    #[test]
366    fn test_build_error_guidance_invalid_auth() {
367        let response = ApiCallResponse {
368            response: json!({
369                "ok": false,
370                "error": "invalid_auth"
371            }),
372            meta: ApiCallMeta {
373                profile_name: Some("default".to_string()),
374                team_id: "T123ABC".to_string(),
375                user_id: "U456DEF".to_string(),
376                method: "auth.test".to_string(),
377                command: "api call".to_string(),
378                token_type: "bot".to_string(),
379            },
380        };
381
382        let guidance = build_error_guidance(&response);
383        assert!(guidance.is_some());
384        let guidance = guidance.unwrap();
385        assert!(guidance.contains("Error:"));
386        assert!(guidance.contains("Cause:"));
387        assert!(guidance.contains("Resolution:"));
388        assert!(guidance.contains("invalid_auth"));
389    }
390
391    #[test]
392    fn test_build_error_guidance_unknown_error() {
393        let response = ApiCallResponse {
394            response: json!({
395                "ok": false,
396                "error": "unknown_error_code"
397            }),
398            meta: ApiCallMeta {
399                profile_name: Some("default".to_string()),
400                team_id: "T123ABC".to_string(),
401                user_id: "U456DEF".to_string(),
402                method: "chat.postMessage".to_string(),
403                command: "api call".to_string(),
404                token_type: "bot".to_string(),
405            },
406        };
407
408        let guidance = build_error_guidance(&response);
409        assert!(guidance.is_none());
410    }
411
412    #[test]
413    fn test_build_error_guidance_success_response() {
414        let response = ApiCallResponse {
415            response: json!({
416                "ok": true,
417                "channel": "C123456",
418                "ts": "1234567890.123456"
419            }),
420            meta: ApiCallMeta {
421                profile_name: Some("default".to_string()),
422                team_id: "T123ABC".to_string(),
423                user_id: "U456DEF".to_string(),
424                method: "chat.postMessage".to_string(),
425                command: "api call".to_string(),
426                token_type: "bot".to_string(),
427            },
428        };
429
430        let guidance = build_error_guidance(&response);
431        assert!(guidance.is_none());
432    }
433
434    #[test]
435    fn test_build_error_guidance_token_revoked() {
436        let response = ApiCallResponse {
437            response: json!({
438                "ok": false,
439                "error": "token_revoked"
440            }),
441            meta: ApiCallMeta {
442                profile_name: Some("default".to_string()),
443                team_id: "T123ABC".to_string(),
444                user_id: "U456DEF".to_string(),
445                method: "auth.test".to_string(),
446                command: "api call".to_string(),
447                token_type: "bot".to_string(),
448            },
449        };
450
451        let guidance = build_error_guidance(&response);
452        assert!(guidance.is_some());
453        let guidance = guidance.unwrap();
454        assert!(guidance.contains("Error:"));
455        assert!(guidance.contains("Cause:"));
456        assert!(guidance.contains("Resolution:"));
457        assert!(guidance.contains("token_revoked"));
458    }
459
460    #[test]
461    fn test_build_error_guidance_no_error_field() {
462        let response = ApiCallResponse {
463            response: json!({
464                "ok": false
465            }),
466            meta: ApiCallMeta {
467                profile_name: Some("default".to_string()),
468                team_id: "T123ABC".to_string(),
469                user_id: "U456DEF".to_string(),
470                method: "chat.postMessage".to_string(),
471                command: "api call".to_string(),
472                token_type: "bot".to_string(),
473            },
474        };
475
476        let guidance = build_error_guidance(&response);
477        assert!(guidance.is_none());
478    }
479
480    #[test]
481    fn test_build_error_guidance_no_ok_field() {
482        let response = ApiCallResponse {
483            response: json!({
484                "error": "missing_scope"
485            }),
486            meta: ApiCallMeta {
487                profile_name: Some("default".to_string()),
488                team_id: "T123ABC".to_string(),
489                user_id: "U456DEF".to_string(),
490                method: "chat.postMessage".to_string(),
491                command: "api call".to_string(),
492                token_type: "bot".to_string(),
493            },
494        };
495
496        let guidance = build_error_guidance(&response);
497        assert!(guidance.is_none());
498    }
499}