Skip to main content

busbar_sf_rest/client/
invocable_actions.rs

1use tracing::instrument;
2
3use busbar_sf_client::security::soql;
4
5use crate::error::{Error, ErrorKind, Result};
6use crate::invocable_actions::{
7    InvocableActionCollection, InvocableActionDescribe, InvocableActionRequest,
8    InvocableActionResult, InvocableActionTypeMap,
9};
10
11impl super::SalesforceRestClient {
12    /// List all standard invocable actions.
13    ///
14    /// Returns a collection of all standard actions available in the org.
15    #[instrument(skip(self))]
16    pub async fn list_standard_actions(&self) -> Result<InvocableActionCollection> {
17        self.client
18            .rest_get("actions/standard")
19            .await
20            .map_err(Into::into)
21    }
22
23    /// List custom invocable action type categories.
24    ///
25    /// Returns a map of category names (e.g., "apex", "flow") to their
26    /// sub-resource URLs. Use `list_custom_actions()` to get actual actions
27    /// within a category.
28    #[instrument(skip(self))]
29    pub async fn list_custom_action_types(&self) -> Result<InvocableActionTypeMap> {
30        self.client
31            .rest_get("actions/custom")
32            .await
33            .map_err(Into::into)
34    }
35
36    /// List custom invocable actions of a specific type.
37    ///
38    /// # Arguments
39    /// * `action_type` - The action type category (e.g., "apex", "flow")
40    #[instrument(skip(self))]
41    pub async fn list_custom_actions(
42        &self,
43        action_type: &str,
44    ) -> Result<InvocableActionCollection> {
45        if !soql::is_safe_field_name(action_type) {
46            return Err(Error::new(ErrorKind::Salesforce {
47                error_code: "INVALID_ACTION_TYPE".to_string(),
48                message: "Invalid action type name".to_string(),
49            }));
50        }
51        let path = format!("actions/custom/{}", action_type);
52        self.client.rest_get(&path).await.map_err(Into::into)
53    }
54
55    /// Describe a standard invocable action.
56    #[instrument(skip(self))]
57    pub async fn describe_standard_action(
58        &self,
59        action_name: &str,
60    ) -> Result<InvocableActionDescribe> {
61        if !soql::is_safe_field_name(action_name) {
62            return Err(Error::new(ErrorKind::Salesforce {
63                error_code: "INVALID_ACTION".to_string(),
64                message: "Invalid action name".to_string(),
65            }));
66        }
67        let path = format!("actions/standard/{}", action_name);
68        self.client.rest_get(&path).await.map_err(Into::into)
69    }
70
71    /// Describe a custom invocable action.
72    ///
73    /// Custom actions are organized by type (e.g., "apex", "flow"). Both the
74    /// action type and action name are required.
75    #[instrument(skip(self))]
76    pub async fn describe_custom_action(
77        &self,
78        action_type: &str,
79        action_name: &str,
80    ) -> Result<InvocableActionDescribe> {
81        if !soql::is_safe_field_name(action_type) {
82            return Err(Error::new(ErrorKind::Salesforce {
83                error_code: "INVALID_ACTION_TYPE".to_string(),
84                message: "Invalid action type name".to_string(),
85            }));
86        }
87        if !soql::is_safe_field_name(action_name) {
88            return Err(Error::new(ErrorKind::Salesforce {
89                error_code: "INVALID_ACTION".to_string(),
90                message: "Invalid action name".to_string(),
91            }));
92        }
93        let path = format!("actions/custom/{}/{}", action_type, action_name);
94        self.client.rest_get(&path).await.map_err(Into::into)
95    }
96
97    /// Invoke a standard action.
98    ///
99    /// Returns a vector of results (one per input).
100    #[instrument(skip(self, request))]
101    pub async fn invoke_standard_action(
102        &self,
103        action_name: &str,
104        request: &InvocableActionRequest,
105    ) -> Result<Vec<InvocableActionResult>> {
106        if !soql::is_safe_field_name(action_name) {
107            return Err(Error::new(ErrorKind::Salesforce {
108                error_code: "INVALID_ACTION".to_string(),
109                message: "Invalid action name".to_string(),
110            }));
111        }
112        let path = format!("actions/standard/{}", action_name);
113        self.client
114            .rest_post(&path, request)
115            .await
116            .map_err(Into::into)
117    }
118
119    /// Invoke a custom action.
120    ///
121    /// Custom actions are organized by type. Both the action type and action
122    /// name are required.
123    ///
124    /// Returns a vector of results (one per input).
125    #[instrument(skip(self, request))]
126    pub async fn invoke_custom_action(
127        &self,
128        action_type: &str,
129        action_name: &str,
130        request: &InvocableActionRequest,
131    ) -> Result<Vec<InvocableActionResult>> {
132        if !soql::is_safe_field_name(action_type) {
133            return Err(Error::new(ErrorKind::Salesforce {
134                error_code: "INVALID_ACTION_TYPE".to_string(),
135                message: "Invalid action type name".to_string(),
136            }));
137        }
138        if !soql::is_safe_field_name(action_name) {
139            return Err(Error::new(ErrorKind::Salesforce {
140                error_code: "INVALID_ACTION".to_string(),
141                message: "Invalid action name".to_string(),
142            }));
143        }
144        let path = format!("actions/custom/{}/{}", action_type, action_name);
145        self.client
146            .rest_post(&path, request)
147            .await
148            .map_err(Into::into)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::super::SalesforceRestClient;
155
156    #[tokio::test]
157    async fn test_describe_standard_action_invalid_name() {
158        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
159        let result = client.describe_standard_action("Bad'; DROP--").await;
160        assert!(result.is_err());
161        assert!(result.unwrap_err().to_string().contains("INVALID_ACTION"));
162    }
163
164    #[tokio::test]
165    async fn test_describe_custom_action_invalid_type() {
166        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
167        let result = client
168            .describe_custom_action("Bad'; DROP--", "myAction")
169            .await;
170        assert!(result.is_err());
171        assert!(result
172            .unwrap_err()
173            .to_string()
174            .contains("INVALID_ACTION_TYPE"));
175    }
176
177    #[tokio::test]
178    async fn test_describe_custom_action_invalid_name() {
179        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
180        let result = client.describe_custom_action("apex", "Bad'; DROP--").await;
181        assert!(result.is_err());
182        assert!(result.unwrap_err().to_string().contains("INVALID_ACTION"));
183    }
184
185    #[tokio::test]
186    async fn test_invoke_standard_action_invalid_name() {
187        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
188        let request = crate::invocable_actions::InvocableActionRequest {
189            inputs: vec![serde_json::json!({"text": "hello"})],
190        };
191        let result = client
192            .invoke_standard_action("Bad'; DROP--", &request)
193            .await;
194        assert!(result.is_err());
195        assert!(result.unwrap_err().to_string().contains("INVALID_ACTION"));
196    }
197
198    #[tokio::test]
199    async fn test_invoke_custom_action_invalid_type() {
200        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
201        let request = crate::invocable_actions::InvocableActionRequest {
202            inputs: vec![serde_json::json!({"text": "hello"})],
203        };
204        let result = client
205            .invoke_custom_action("Bad'; DROP--", "myAction", &request)
206            .await;
207        assert!(result.is_err());
208        assert!(result
209            .unwrap_err()
210            .to_string()
211            .contains("INVALID_ACTION_TYPE"));
212    }
213
214    #[tokio::test]
215    async fn test_list_standard_actions_wiremock() {
216        use wiremock::matchers::{method, path_regex};
217        use wiremock::{Mock, MockServer, ResponseTemplate};
218
219        let mock_server = MockServer::start().await;
220
221        let body = serde_json::json!({
222            "actions": [
223                {"name": "chatterPost", "label": "Post to Chatter", "type": "CHATTERPOST"},
224                {"name": "emailSimple", "label": "Send Email", "type": "EMAILSIMPLE"}
225            ]
226        });
227
228        Mock::given(method("GET"))
229            .and(path_regex(".*/actions/standard$"))
230            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
231            .mount(&mock_server)
232            .await;
233
234        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
235        let result = client
236            .list_standard_actions()
237            .await
238            .expect("list_standard_actions should succeed");
239        assert_eq!(result.actions.len(), 2);
240        assert_eq!(result.actions[0].name, "chatterPost");
241    }
242
243    #[tokio::test]
244    async fn test_list_custom_action_types_wiremock() {
245        use wiremock::matchers::{method, path_regex};
246        use wiremock::{Mock, MockServer, ResponseTemplate};
247
248        let mock_server = MockServer::start().await;
249
250        let body = serde_json::json!({
251            "apex": "/services/data/v62.0/actions/custom/apex",
252            "flow": "/services/data/v62.0/actions/custom/flow"
253        });
254
255        Mock::given(method("GET"))
256            .and(path_regex(".*/actions/custom$"))
257            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
258            .mount(&mock_server)
259            .await;
260
261        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
262        let result = client
263            .list_custom_action_types()
264            .await
265            .expect("list_custom_action_types should succeed");
266        assert_eq!(result.len(), 2);
267        assert!(result.contains_key("flow"));
268    }
269
270    #[tokio::test]
271    async fn test_list_custom_actions_wiremock() {
272        use wiremock::matchers::{method, path_regex};
273        use wiremock::{Mock, MockServer, ResponseTemplate};
274
275        let mock_server = MockServer::start().await;
276
277        let body = serde_json::json!({
278            "actions": [{
279                "name": "myCustomAction",
280                "label": "My Custom Action",
281                "type": "APEX"
282            }]
283        });
284
285        Mock::given(method("GET"))
286            .and(path_regex(".*/actions/custom/apex$"))
287            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
288            .mount(&mock_server)
289            .await;
290
291        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
292        let result = client
293            .list_custom_actions("apex")
294            .await
295            .expect("list_custom_actions should succeed");
296        assert_eq!(result.actions.len(), 1);
297    }
298
299    #[tokio::test]
300    async fn test_list_custom_actions_invalid_type() {
301        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
302        let result = client.list_custom_actions("Bad'; DROP--").await;
303        assert!(result.is_err());
304        assert!(result
305            .unwrap_err()
306            .to_string()
307            .contains("INVALID_ACTION_TYPE"));
308    }
309
310    #[tokio::test]
311    async fn test_describe_standard_action_wiremock() {
312        use wiremock::matchers::{method, path_regex};
313        use wiremock::{Mock, MockServer, ResponseTemplate};
314
315        let mock_server = MockServer::start().await;
316
317        let body = serde_json::json!({
318            "name": "chatterPost",
319            "label": "Post to Chatter",
320            "type": "APEX",
321            "inputs": [{
322                "name": "text",
323                "label": "Post Text",
324                "type": "STRING",
325                "required": true,
326                "description": "The text to post"
327            }],
328            "outputs": []
329        });
330
331        Mock::given(method("GET"))
332            .and(path_regex(".*/actions/standard/chatterPost$"))
333            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
334            .mount(&mock_server)
335            .await;
336
337        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
338        let result = client
339            .describe_standard_action("chatterPost")
340            .await
341            .expect("describe_standard_action should succeed");
342        assert_eq!(result.name, "chatterPost");
343        assert_eq!(result.inputs.len(), 1);
344    }
345
346    #[tokio::test]
347    async fn test_describe_custom_action_wiremock() {
348        use wiremock::matchers::{method, path_regex};
349        use wiremock::{Mock, MockServer, ResponseTemplate};
350
351        let mock_server = MockServer::start().await;
352
353        let body = serde_json::json!({
354            "name": "myCustomAction",
355            "label": "My Custom Action",
356            "type": "APEX",
357            "inputs": [],
358            "outputs": []
359        });
360
361        Mock::given(method("GET"))
362            .and(path_regex(".*/actions/custom/apex/myCustomAction$"))
363            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
364            .mount(&mock_server)
365            .await;
366
367        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
368        let result = client
369            .describe_custom_action("apex", "myCustomAction")
370            .await
371            .expect("describe_custom_action should succeed");
372        assert_eq!(result.name, "myCustomAction");
373    }
374
375    #[tokio::test]
376    async fn test_invoke_standard_action_wiremock() {
377        use wiremock::matchers::{method, path_regex};
378        use wiremock::{Mock, MockServer, ResponseTemplate};
379
380        let mock_server = MockServer::start().await;
381
382        let body = serde_json::json!([{
383            "actionName": "chatterPost",
384            "errors": [],
385            "isSuccess": true,
386            "outputValues": {"feedItemId": "0D5xx0000000001"}
387        }]);
388
389        Mock::given(method("POST"))
390            .and(path_regex(".*/actions/standard/chatterPost$"))
391            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
392            .mount(&mock_server)
393            .await;
394
395        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
396        let request = crate::invocable_actions::InvocableActionRequest {
397            inputs: vec![serde_json::json!({"text": "Hello World"})],
398        };
399        let result = client
400            .invoke_standard_action("chatterPost", &request)
401            .await
402            .expect("invoke_standard_action should succeed");
403        assert_eq!(result.len(), 1);
404        assert!(result[0].is_success);
405    }
406
407    #[tokio::test]
408    async fn test_invoke_custom_action_wiremock() {
409        use wiremock::matchers::{method, path_regex};
410        use wiremock::{Mock, MockServer, ResponseTemplate};
411
412        let mock_server = MockServer::start().await;
413
414        let body = serde_json::json!([{
415            "actionName": "myAction",
416            "errors": [],
417            "isSuccess": true,
418            "outputValues": null
419        }]);
420
421        Mock::given(method("POST"))
422            .and(path_regex(".*/actions/custom/apex/myAction$"))
423            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
424            .mount(&mock_server)
425            .await;
426
427        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
428        let request = crate::invocable_actions::InvocableActionRequest {
429            inputs: vec![serde_json::json!({"param": "value"})],
430        };
431        let result = client
432            .invoke_custom_action("apex", "myAction", &request)
433            .await
434            .expect("invoke_custom_action should succeed");
435        assert_eq!(result.len(), 1);
436        assert!(result[0].is_success);
437    }
438}