busbar_sf_tooling/client/
collections.rs1use busbar_sf_client::security::{soql, url as url_security};
2use tracing::instrument;
3
4use crate::error::{Error, ErrorKind, Result};
5
6impl super::ToolingClient {
7 #[instrument(skip(self))]
26 pub async fn get_multiple<T: serde::de::DeserializeOwned + Clone>(
27 &self,
28 sobject: &str,
29 ids: &[&str],
30 fields: &[&str],
31 ) -> Result<Vec<T>> {
32 if ids.is_empty() {
33 return Ok(Vec::new());
34 }
35 if !soql::is_safe_sobject_name(sobject) {
36 return Err(Error::new(ErrorKind::Salesforce {
37 error_code: "INVALID_SOBJECT".to_string(),
38 message: "Invalid SObject name".to_string(),
39 }));
40 }
41 for id in ids {
42 if !url_security::is_valid_salesforce_id(id) {
43 return Err(Error::new(ErrorKind::Salesforce {
44 error_code: "INVALID_ID".to_string(),
45 message: "Invalid Salesforce ID format".to_string(),
46 }));
47 }
48 }
49 let safe_fields: Vec<&str> = soql::filter_safe_fields(fields.iter().copied()).collect();
50 if safe_fields.is_empty() {
51 return Err(Error::new(ErrorKind::Salesforce {
52 error_code: "INVALID_FIELDS".to_string(),
53 message: "No valid field names provided".to_string(),
54 }));
55 }
56 let fields_clause = safe_fields.join(", ");
60 let ids_clause: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
61 let soql = format!(
62 "SELECT {} FROM {} WHERE Id IN ({})",
63 fields_clause,
64 sobject,
65 ids_clause.join(", ")
66 );
67 self.query_all(&soql).await
68 }
69
70 #[instrument(skip(self, records))]
92 pub async fn create_multiple<T: serde::Serialize>(
93 &self,
94 sobject: &str,
95 records: &[T],
96 all_or_none: bool,
97 ) -> Result<Vec<busbar_sf_rest::CollectionResult>> {
98 if !soql::is_safe_sobject_name(sobject) {
99 return Err(Error::new(ErrorKind::Salesforce {
100 error_code: "INVALID_SOBJECT".to_string(),
101 message: "Invalid SObject name".to_string(),
102 }));
103 }
104 let request = busbar_sf_rest::CollectionRequest {
105 all_or_none,
106 records: records
107 .iter()
108 .map(|r| {
109 let mut value = serde_json::to_value(r).unwrap_or(serde_json::Value::Null);
110 if let serde_json::Value::Object(ref mut map) = value {
111 map.insert(
112 "attributes".to_string(),
113 serde_json::json!({"type": sobject}),
114 );
115 }
116 value
117 })
118 .collect(),
119 };
120 let url = self.client.tooling_url("composite/sobjects");
121 self.client
122 .post_json(&url, &request)
123 .await
124 .map_err(Into::into)
125 }
126
127 #[instrument(skip(self, records))]
136 pub async fn update_multiple<T: serde::Serialize>(
137 &self,
138 sobject: &str,
139 records: &[(String, T)],
140 all_or_none: bool,
141 ) -> Result<Vec<busbar_sf_rest::CollectionResult>> {
142 if !soql::is_safe_sobject_name(sobject) {
143 return Err(Error::new(ErrorKind::Salesforce {
144 error_code: "INVALID_SOBJECT".to_string(),
145 message: "Invalid SObject name".to_string(),
146 }));
147 }
148 for (id, _) in records {
150 if !url_security::is_valid_salesforce_id(id) {
151 return Err(Error::new(ErrorKind::Salesforce {
152 error_code: "INVALID_ID".to_string(),
153 message: "Invalid Salesforce ID format".to_string(),
154 }));
155 }
156 }
157 let request = busbar_sf_rest::CollectionRequest {
158 all_or_none,
159 records: records
160 .iter()
161 .map(|(id, r)| {
162 let mut value = serde_json::to_value(r).unwrap_or(serde_json::Value::Null);
163 if let serde_json::Value::Object(ref mut map) = value {
164 map.insert(
165 "attributes".to_string(),
166 serde_json::json!({"type": sobject}),
167 );
168 map.insert("Id".to_string(), serde_json::json!(id));
169 }
170 value
171 })
172 .collect(),
173 };
174
175 let url = self.client.tooling_url("composite/sobjects");
176 let request_builder = self.client.patch(&url).json(&request)?;
177 let response = self.client.execute(request_builder).await?;
178 response.json().await.map_err(Into::into)
179 }
180
181 #[instrument(skip(self))]
189 pub async fn delete_multiple(
190 &self,
191 ids: &[&str],
192 all_or_none: bool,
193 ) -> Result<Vec<busbar_sf_rest::CollectionResult>> {
194 for id in ids {
196 if !url_security::is_valid_salesforce_id(id) {
197 return Err(Error::new(ErrorKind::Salesforce {
198 error_code: "INVALID_ID".to_string(),
199 message: "Invalid Salesforce ID format".to_string(),
200 }));
201 }
202 }
203 let ids_param = ids.join(",");
204 let url = format!(
205 "{}/services/data/v{}/tooling/composite/sobjects?ids={}&allOrNone={}",
206 self.client.instance_url(),
207 self.client.api_version(),
208 ids_param,
209 all_or_none
210 );
211 let request = self.client.delete(&url);
212 let response = self.client.execute(request).await?;
213 response.json().await.map_err(Into::into)
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::super::ToolingClient;
220
221 #[test]
222 fn test_collections_get_soql_construction() {
223 let sobject = "ApexClass";
224 let ids = ["01p000000000001AAA", "01p000000000002AAA"];
225 let fields = ["Id", "Name"];
226
227 let fields_clause = fields.join(", ");
228 let ids_clause: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
229 let soql = format!(
230 "SELECT {} FROM {} WHERE Id IN ({})",
231 fields_clause,
232 sobject,
233 ids_clause.join(", ")
234 );
235
236 assert_eq!(
237 soql,
238 "SELECT Id, Name FROM ApexClass WHERE Id IN ('01p000000000001AAA', '01p000000000002AAA')"
239 );
240 }
241
242 #[test]
243 fn test_collections_create_url_construction() {
244 let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
245
246 let url = client.client.tooling_url("composite/sobjects");
247 assert_eq!(
248 url,
249 "https://na1.salesforce.com/services/data/v62.0/tooling/composite/sobjects"
250 );
251 }
252
253 #[test]
254 fn test_collections_delete_url_construction() {
255 let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
256
257 let ids = ["01p000000000001AAA", "01p000000000002AAA"];
258 let ids_param = ids.join(",");
259
260 let url = format!(
261 "{}/services/data/v{}/tooling/composite/sobjects?ids={}&allOrNone={}",
262 client.client.instance_url(),
263 client.client.api_version(),
264 ids_param,
265 false
266 );
267
268 assert_eq!(
269 url,
270 "https://na1.salesforce.com/services/data/v62.0/tooling/composite/sobjects?ids=01p000000000001AAA,01p000000000002AAA&allOrNone=false"
271 );
272 }
273
274 #[tokio::test]
275 async fn test_get_multiple_empty_ids_returns_empty() {
276 let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
277 let result: Vec<serde_json::Value> = client
278 .get_multiple("ApexClass", &[], &["Id", "Name"])
279 .await
280 .unwrap();
281 assert!(result.is_empty());
282 }
283
284 #[tokio::test]
285 async fn test_get_multiple_invalid_sobject_name() {
286 let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
287 let result: std::result::Result<Vec<serde_json::Value>, _> = client
288 .get_multiple("Robert'; DROP TABLE--", &["01p000000000001AAA"], &["Id"])
289 .await;
290 assert!(result.is_err());
291 let err = result.unwrap_err();
292 assert!(
293 err.to_string().contains("INVALID_SOBJECT"),
294 "Expected INVALID_SOBJECT error, got: {err}"
295 );
296 }
297
298 #[tokio::test]
299 async fn test_get_multiple_invalid_id_format() {
300 let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
301 let result: std::result::Result<Vec<serde_json::Value>, _> = client
302 .get_multiple("ApexClass", &["not-a-valid-sf-id!"], &["Id"])
303 .await;
304 assert!(result.is_err());
305 let err = result.unwrap_err();
306 assert!(
307 err.to_string().contains("INVALID_ID"),
308 "Expected INVALID_ID error, got: {err}"
309 );
310 }
311
312 #[tokio::test]
313 async fn test_get_multiple_invalid_fields_filtered() {
314 let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
315 let result: std::result::Result<Vec<serde_json::Value>, _> = client
316 .get_multiple(
317 "ApexClass",
318 &["01p000000000001AAA"],
319 &["'; DROP TABLE--", "1=1 OR"],
320 )
321 .await;
322 assert!(result.is_err());
323 let err = result.unwrap_err();
324 assert!(
325 err.to_string().contains("INVALID_FIELDS"),
326 "Expected INVALID_FIELDS error, got: {err}"
327 );
328 }
329
330 #[test]
331 fn test_get_multiple_soql_construction_with_many_ids() {
332 let sobject = "ApexClass";
333 let ids = [
334 "01p000000000001AAA",
335 "01p000000000002AAA",
336 "01p000000000003AAA",
337 ];
338 let fields = ["Id", "Name", "Body"];
339
340 let fields_clause = fields.join(", ");
341 let ids_clause: Vec<String> = ids.iter().map(|id| format!("'{id}'")).collect();
342 let soql = format!(
343 "SELECT {} FROM {} WHERE Id IN ({})",
344 fields_clause,
345 sobject,
346 ids_clause.join(", ")
347 );
348
349 assert_eq!(
350 soql,
351 "SELECT Id, Name, Body FROM ApexClass WHERE Id IN ('01p000000000001AAA', '01p000000000002AAA', '01p000000000003AAA')"
352 );
353 }
354}