busbar_sf_rest/client/
search.rs1use serde::de::DeserializeOwned;
2use tracing::instrument;
3
4use busbar_sf_client::security::{soql, url as url_security};
5
6use crate::error::{Error, ErrorKind, Result};
7use crate::search::{
8 ParameterizedSearchRequest, ParameterizedSearchResponse, ScopeEntity, SearchLayoutInfo,
9 SearchSuggestionResult,
10};
11
12impl super::SalesforceRestClient {
13 #[instrument(skip(self))]
21 pub async fn search<T: DeserializeOwned>(&self, sosl: &str) -> Result<super::SearchResult<T>> {
22 let encoded = urlencoding::encode(sosl);
23 let url = format!(
24 "{}/services/data/v{}/search?q={}",
25 self.client.instance_url(),
26 self.client.api_version(),
27 encoded
28 );
29 self.client.get_json(&url).await.map_err(Into::into)
30 }
31
32 #[instrument(skip(self, request))]
38 pub async fn parameterized_search(
39 &self,
40 request: &ParameterizedSearchRequest,
41 ) -> Result<ParameterizedSearchResponse> {
42 if let Some(ref sobjects) = request.sobjects {
44 for spec in sobjects {
45 if !soql::is_safe_sobject_name(&spec.name) {
46 return Err(Error::new(ErrorKind::Salesforce {
47 error_code: "INVALID_SOBJECT".to_string(),
48 message: format!("Invalid SObject name: {}", spec.name),
49 }));
50 }
51 }
52 }
53 self.client
54 .rest_post("parameterizedSearch", request)
55 .await
56 .map_err(Into::into)
57 }
58
59 #[instrument(skip(self))]
63 pub async fn search_suggestions(
64 &self,
65 query: &str,
66 sobject: &str,
67 ) -> Result<SearchSuggestionResult> {
68 if !soql::is_safe_sobject_name(sobject) {
69 return Err(Error::new(ErrorKind::Salesforce {
70 error_code: "INVALID_SOBJECT".to_string(),
71 message: "Invalid SObject name".to_string(),
72 }));
73 }
74 let encoded_query = url_security::encode_param(query);
75 let url = format!(
76 "{}/services/data/v{}/search/suggestions?q={}&sobject={}",
77 self.client.instance_url(),
78 self.client.api_version(),
79 encoded_query,
80 sobject
81 );
82 self.client.get_json(&url).await.map_err(Into::into)
83 }
84
85 #[instrument(skip(self))]
89 pub async fn search_scope_order(&self) -> Result<Vec<ScopeEntity>> {
90 let url = format!(
91 "{}/services/data/v{}/search/scopeOrder",
92 self.client.instance_url(),
93 self.client.api_version(),
94 );
95 self.client.get_json(&url).await.map_err(Into::into)
96 }
97
98 #[instrument(skip(self))]
102 pub async fn search_result_layouts(&self, sobjects: &[&str]) -> Result<Vec<SearchLayoutInfo>> {
103 for sobject in sobjects {
105 if !soql::is_safe_sobject_name(sobject) {
106 return Err(Error::new(ErrorKind::Salesforce {
107 error_code: "INVALID_SOBJECT".to_string(),
108 message: format!("Invalid SObject name: {}", sobject),
109 }));
110 }
111 }
112 let sobjects_param = sobjects.join(",");
113 let url = format!(
114 "{}/services/data/v{}/search/layout?q={}",
115 self.client.instance_url(),
116 self.client.api_version(),
117 sobjects_param
118 );
119 self.client.get_json(&url).await.map_err(Into::into)
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::super::SalesforceRestClient;
126 use crate::search::ParameterizedSearchRequest;
127
128 #[tokio::test]
129 async fn test_parameterized_search_invalid_sobject() {
130 let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
131 let request = ParameterizedSearchRequest {
132 q: "test".to_string(),
133 sobjects: Some(vec![crate::search::SearchSObjectSpec {
134 name: "Bad'; DROP--".to_string(),
135 ..Default::default()
136 }]),
137 ..Default::default()
138 };
139 let result = client.parameterized_search(&request).await;
140 assert!(result.is_err());
141 assert!(result.unwrap_err().to_string().contains("INVALID_SOBJECT"));
142 }
143
144 #[tokio::test]
145 async fn test_search_suggestions_invalid_sobject() {
146 let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
147 let result = client.search_suggestions("test", "Bad'; DROP--").await;
148 assert!(result.is_err());
149 assert!(result.unwrap_err().to_string().contains("INVALID_SOBJECT"));
150 }
151
152 #[tokio::test]
153 async fn test_search_result_layouts_invalid_sobject() {
154 let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
155 let result = client.search_result_layouts(&["Bad'; DROP--"]).await;
156 assert!(result.is_err());
157 assert!(result.unwrap_err().to_string().contains("INVALID_SOBJECT"));
158 }
159
160 #[tokio::test]
161 async fn test_parameterized_search_wiremock() {
162 use wiremock::matchers::{method, path_regex};
163 use wiremock::{Mock, MockServer, ResponseTemplate};
164
165 let mock_server = MockServer::start().await;
166
167 let body = serde_json::json!({
168 "searchRecords": [
169 {
170 "attributes": {"type": "Account", "url": "/services/data/v62.0/sobjects/Account/001xx"},
171 "Id": "001xx000003Dgb2AAC",
172 "Name": "Acme"
173 }
174 ]
175 });
176
177 Mock::given(method("POST"))
178 .and(path_regex(".*/parameterizedSearch$"))
179 .respond_with(ResponseTemplate::new(200).set_body_json(&body))
180 .mount(&mock_server)
181 .await;
182
183 let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
184 let request = ParameterizedSearchRequest {
185 q: "Acme".to_string(),
186 ..Default::default()
187 };
188 let result = client
189 .parameterized_search(&request)
190 .await
191 .expect("parameterized_search should succeed");
192
193 assert_eq!(result.search_records.len(), 1);
194 }
195
196 #[tokio::test]
197 async fn test_search_suggestions_wiremock() {
198 use wiremock::matchers::{method, path_regex};
199 use wiremock::{Mock, MockServer, ResponseTemplate};
200
201 let mock_server = MockServer::start().await;
202
203 let body = serde_json::json!({
204 "autoSuggestResults": [
205 {
206 "attributes": {"type": "Account", "url": "/services/data/v62.0/sobjects/Account/001xx"},
207 "Id": "001xx000003Dgb2AAC",
208 "Name": "Acme Corp"
209 }
210 ],
211 "hasMoreResults": false
212 });
213
214 Mock::given(method("GET"))
215 .and(path_regex(".*/search/suggestions.*"))
216 .respond_with(ResponseTemplate::new(200).set_body_json(&body))
217 .mount(&mock_server)
218 .await;
219
220 let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
221 let result = client
222 .search_suggestions("Acme", "Account")
223 .await
224 .expect("search_suggestions should succeed");
225
226 assert_eq!(result.auto_suggest_results.len(), 1);
227 assert!(!result.has_more_results);
228 }
229
230 #[tokio::test]
231 async fn test_search_scope_order_wiremock() {
232 use wiremock::matchers::{method, path_regex};
233 use wiremock::{Mock, MockServer, ResponseTemplate};
234
235 let mock_server = MockServer::start().await;
236
237 let body = serde_json::json!([
238 {
239 "name": "Account",
240 "label": "Accounts",
241 "inSearchScope": true,
242 "searchScopeOrder": 1
243 }
244 ]);
245
246 Mock::given(method("GET"))
247 .and(path_regex(".*/search/scopeOrder$"))
248 .respond_with(ResponseTemplate::new(200).set_body_json(&body))
249 .mount(&mock_server)
250 .await;
251
252 let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
253 let result = client
254 .search_scope_order()
255 .await
256 .expect("search_scope_order should succeed");
257
258 assert_eq!(result.len(), 1);
259 assert_eq!(result[0].name, "Account");
260 }
261
262 #[tokio::test]
263 async fn test_search_result_layouts_wiremock() {
264 use wiremock::matchers::{method, path_regex};
265 use wiremock::{Mock, MockServer, ResponseTemplate};
266
267 let mock_server = MockServer::start().await;
268
269 let body = serde_json::json!([
270 {
271 "label": "Accounts",
272 "searchColumns": [
273 {
274 "field": "Account.Name",
275 "label": "Account Name",
276 "format": "string",
277 "name": "Name"
278 }
279 ]
280 }
281 ]);
282
283 Mock::given(method("GET"))
284 .and(path_regex(".*/search/layout.*"))
285 .respond_with(ResponseTemplate::new(200).set_body_json(&body))
286 .mount(&mock_server)
287 .await;
288
289 let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
290 let result = client
291 .search_result_layouts(&["Account"])
292 .await
293 .expect("search_result_layouts should succeed");
294
295 assert_eq!(result.len(), 1);
296 assert_eq!(result[0].label, "Accounts");
297 assert_eq!(result[0].columns.len(), 1);
298 }
299}