Skip to main content

busbar_sf_rest/client/
search.rs

1use 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    /// Execute a SOSL search.
14    ///
15    /// # Security
16    ///
17    /// **IMPORTANT**: If you are including user-provided values in the search term,
18    /// you MUST escape them. Use `busbar_sf_client::security::soql::escape_string()`
19    /// for string values in SOSL queries.
20    #[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    /// Execute a parameterized search request.
33    ///
34    /// This provides a structured alternative to raw SOSL queries,
35    /// with support for filtering by SObject type, field selection,
36    /// and pagination.
37    #[instrument(skip(self, request))]
38    pub async fn parameterized_search(
39        &self,
40        request: &ParameterizedSearchRequest,
41    ) -> Result<ParameterizedSearchResponse> {
42        // Validate SObject names if specified
43        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    /// Get search suggestions (auto-complete) for a query string and SObject type.
60    ///
61    /// Returns suggested records matching the query prefix.
62    #[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    /// Get the search scope order for the current user.
86    ///
87    /// Returns the list of SObjects in the order they appear in the user's search scope.
88    #[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    /// Get search result layouts for the specified SObject types.
99    ///
100    /// Returns the columns displayed in search results for each SObject type.
101    #[instrument(skip(self))]
102    pub async fn search_result_layouts(&self, sobjects: &[&str]) -> Result<Vec<SearchLayoutInfo>> {
103        // Validate all SObject names
104        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}