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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}