busbar_sf_tooling/client/
describe.rs1use busbar_sf_client::security::soql;
2use tracing::instrument;
3
4use crate::error::{Error, ErrorKind, Result};
5
6impl super::ToolingClient {
7 #[instrument(skip(self))]
9 pub async fn describe_global(&self) -> Result<busbar_sf_rest::DescribeGlobalResult> {
10 self.client
11 .tooling_get("sobjects")
12 .await
13 .map_err(Into::into)
14 }
15
16 #[instrument(skip(self))]
18 pub async fn describe_sobject(
19 &self,
20 sobject: &str,
21 ) -> Result<busbar_sf_rest::DescribeSObjectResult> {
22 if !soql::is_safe_sobject_name(sobject) {
23 return Err(Error::new(ErrorKind::Salesforce {
24 error_code: "INVALID_SOBJECT".to_string(),
25 message: "Invalid SObject name".to_string(),
26 }));
27 }
28 let path = format!("sobjects/{}/describe", sobject);
29 self.client.tooling_get(&path).await.map_err(Into::into)
30 }
31
32 #[instrument(skip(self))]
34 pub async fn basic_info(&self, sobject: &str) -> Result<serde_json::Value> {
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 let path = format!("sobjects/{}", sobject);
42 self.client.tooling_get(&path).await.map_err(Into::into)
43 }
44
45 #[instrument(skip(self))]
47 pub async fn resources(&self) -> Result<serde_json::Value> {
48 let url = format!(
49 "{}/services/data/v{}/tooling/",
50 self.client.instance_url(),
51 self.client.api_version()
52 );
53 self.client.get_json(&url).await.map_err(Into::into)
54 }
55}
56
57#[cfg(test)]
58mod tests {
59 use super::super::ToolingClient;
60
61 #[tokio::test]
62 async fn test_describe_global_wiremock() {
63 use wiremock::matchers::{method, path_regex};
64 use wiremock::{Mock, MockServer, ResponseTemplate};
65
66 let mock_server = MockServer::start().await;
67 let body = serde_json::json!({
68 "encoding": "UTF-8",
69 "maxBatchSize": 200,
70 "sobjects": [
71 {
72 "name": "ApexClass",
73 "label": "Apex Class",
74 "labelPlural": "Apex Classes",
75 "keyPrefix": "01p",
76 "custom": false,
77 "queryable": true,
78 "createable": true,
79 "updateable": true,
80 "deletable": true,
81 "searchable": true,
82 "retrieveable": true
83 }
84 ]
85 });
86
87 Mock::given(method("GET"))
88 .and(path_regex(".*/tooling/sobjects$"))
89 .respond_with(ResponseTemplate::new(200).set_body_json(&body))
90 .mount(&mock_server)
91 .await;
92
93 let client = ToolingClient::new(mock_server.uri(), "test-token").unwrap();
94 let result = client.describe_global().await.expect("should succeed");
95 assert_eq!(result.encoding, "UTF-8");
96 assert_eq!(result.max_batch_size, 200);
97 assert_eq!(result.sobjects.len(), 1);
98 assert_eq!(result.sobjects[0].name, "ApexClass");
99 }
100
101 #[tokio::test]
102 async fn test_describe_sobject_wiremock() {
103 use wiremock::matchers::{method, path_regex};
104 use wiremock::{Mock, MockServer, ResponseTemplate};
105
106 let mock_server = MockServer::start().await;
107 let body = serde_json::json!({
108 "name": "ApexClass",
109 "label": "Apex Class",
110 "labelPlural": "Apex Classes",
111 "keyPrefix": "01p",
112 "custom": false,
113 "createable": true,
114 "updateable": true,
115 "deletable": true,
116 "queryable": true,
117 "searchable": true,
118 "retrieveable": true,
119 "fields": [
120 {
121 "name": "Id",
122 "label": "Apex Class ID",
123 "type": "id"
124 }
125 ]
126 });
127
128 Mock::given(method("GET"))
129 .and(path_regex(".*/tooling/sobjects/ApexClass/describe"))
130 .respond_with(ResponseTemplate::new(200).set_body_json(&body))
131 .mount(&mock_server)
132 .await;
133
134 let client = ToolingClient::new(mock_server.uri(), "test-token").unwrap();
135 let result = client
136 .describe_sobject("ApexClass")
137 .await
138 .expect("should succeed");
139 assert_eq!(result.name, "ApexClass");
140 assert_eq!(result.label, "Apex Class");
141 assert!(result.createable);
142 assert!(!result.fields.is_empty());
143 assert_eq!(result.fields[0].name, "Id");
144 }
145
146 #[tokio::test]
147 async fn test_describe_sobject_invalid_name() {
148 let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
149 let result = client.describe_sobject("Robert'; DROP TABLE--").await;
150 assert!(result.is_err());
151 let err = result.unwrap_err();
152 assert!(
153 err.to_string().contains("INVALID_SOBJECT"),
154 "Expected INVALID_SOBJECT, got: {err}"
155 );
156 }
157
158 #[tokio::test]
159 async fn test_basic_info_wiremock() {
160 use wiremock::matchers::{method, path_regex};
161 use wiremock::{Mock, MockServer, ResponseTemplate};
162
163 let mock_server = MockServer::start().await;
164 let body = serde_json::json!({
165 "objectDescribe": {
166 "name": "ApexClass",
167 "label": "Apex Class",
168 "keyPrefix": "01p"
169 },
170 "recentItems": []
171 });
172
173 Mock::given(method("GET"))
174 .and(path_regex(".*/tooling/sobjects/ApexClass$"))
175 .respond_with(ResponseTemplate::new(200).set_body_json(&body))
176 .mount(&mock_server)
177 .await;
178
179 let client = ToolingClient::new(mock_server.uri(), "test-token").unwrap();
180 let result = client
181 .basic_info("ApexClass")
182 .await
183 .expect("should succeed");
184 let describe = result
185 .get("objectDescribe")
186 .expect("should have objectDescribe");
187 assert_eq!(describe.get("name").unwrap().as_str().unwrap(), "ApexClass");
188 }
189
190 #[tokio::test]
191 async fn test_basic_info_invalid_sobject() {
192 let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
193 let result = client.basic_info("Robert'; DROP TABLE--").await;
194 assert!(result.is_err());
195 let err = result.unwrap_err();
196 assert!(
197 err.to_string().contains("INVALID_SOBJECT"),
198 "Expected INVALID_SOBJECT, got: {err}"
199 );
200 }
201
202 #[tokio::test]
203 async fn test_resources_wiremock() {
204 use wiremock::matchers::{method, path_regex};
205 use wiremock::{Mock, MockServer, ResponseTemplate};
206
207 let mock_server = MockServer::start().await;
208 let body = serde_json::json!({
209 "tooling": "/services/data/v62.0/tooling",
210 "query": "/services/data/v62.0/tooling/query",
211 "search": "/services/data/v62.0/tooling/search",
212 "sobjects": "/services/data/v62.0/tooling/sobjects"
213 });
214
215 Mock::given(method("GET"))
216 .and(path_regex(".*/tooling/$"))
217 .respond_with(ResponseTemplate::new(200).set_body_json(&body))
218 .mount(&mock_server)
219 .await;
220
221 let client = ToolingClient::new(mock_server.uri(), "test-token").unwrap();
222 let result = client.resources().await.expect("should succeed");
223 assert!(result.get("query").is_some());
224 assert!(result.get("search").is_some());
225 assert!(result.get("sobjects").is_some());
226 }
227}