Skip to main content

busbar_sf_tooling/client/
describe.rs

1use busbar_sf_client::security::soql;
2use tracing::instrument;
3
4use crate::error::{Error, ErrorKind, Result};
5
6impl super::ToolingClient {
7    /// Get a list of all Tooling API SObjects available in the org.
8    #[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    /// Get detailed metadata for a specific Tooling API SObject.
17    #[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    /// Get basic information about a Tooling API SObject.
33    #[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    /// Get a list of all available Tooling API resources.
46    #[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}