Skip to main content

busbar_sf_rest/client/
layout.rs

1use tracing::instrument;
2
3use busbar_sf_client::security::{soql, url as url_security};
4
5use crate::error::{Error, ErrorKind, Result};
6
7impl super::SalesforceRestClient {
8    /// Get all page layouts for a specific SObject.
9    ///
10    /// This returns metadata about all page layouts configured for the SObject,
11    /// including sections, rows, items, and field metadata.
12    ///
13    /// # Example
14    ///
15    /// ```rust,ignore
16    /// let layouts = client.describe_layouts("Account").await?;
17    /// println!("Account layouts: {:?}", layouts);
18    /// ```
19    ///
20    /// This is equivalent to calling `/services/data/vXX.0/sobjects/{sobject}/describe/layouts`.
21    #[instrument(skip(self))]
22    pub async fn describe_layouts(
23        &self,
24        sobject: &str,
25    ) -> Result<crate::layout::DescribeLayoutsResult> {
26        if !soql::is_safe_sobject_name(sobject) {
27            return Err(Error::new(ErrorKind::Salesforce {
28                error_code: "INVALID_SOBJECT".to_string(),
29                message: "Invalid SObject name".to_string(),
30            }));
31        }
32        let path = format!("sobjects/{}/describe/layouts", sobject);
33        self.client.rest_get(&path).await.map_err(Into::into)
34    }
35
36    /// Get a specific named layout for an SObject.
37    ///
38    /// This returns the layout metadata for a specific named layout.
39    ///
40    /// # Example
41    ///
42    /// ```rust,ignore
43    /// let layout = client.describe_named_layout("Account", "MyCustomLayout").await?;
44    /// println!("Layout metadata: {:?}", layout);
45    /// ```
46    ///
47    /// This is equivalent to calling `/services/data/vXX.0/sobjects/{sobject}/describe/namedLayouts/{layoutName}`.
48    #[instrument(skip(self))]
49    pub async fn describe_named_layout(
50        &self,
51        sobject: &str,
52        layout_name: &str,
53    ) -> Result<crate::layout::NamedLayoutResult> {
54        if !soql::is_safe_sobject_name(sobject) {
55            return Err(Error::new(ErrorKind::Salesforce {
56                error_code: "INVALID_SOBJECT".to_string(),
57                message: "Invalid SObject name".to_string(),
58            }));
59        }
60        // URL-encode the layout name to handle special characters
61        let encoded_name = url_security::encode_param(layout_name);
62        let path = format!(
63            "sobjects/{}/describe/namedLayouts/{}",
64            sobject, encoded_name
65        );
66        self.client.rest_get(&path).await.map_err(Into::into)
67    }
68
69    /// Get approval process layouts for a specific SObject.
70    ///
71    /// This returns the approval process layout information including
72    /// approval steps, actions, and field mappings.
73    ///
74    /// # Example
75    ///
76    /// ```rust,ignore
77    /// let approval_layouts = client.describe_approval_layouts("Account").await?;
78    /// println!("Approval layouts: {:?}", approval_layouts);
79    /// ```
80    ///
81    /// This is equivalent to calling `/services/data/vXX.0/sobjects/{sobject}/describe/approvalLayouts`.
82    #[instrument(skip(self))]
83    pub async fn describe_approval_layouts(
84        &self,
85        sobject: &str,
86    ) -> Result<crate::layout::ApprovalLayoutsResult> {
87        if !soql::is_safe_sobject_name(sobject) {
88            return Err(Error::new(ErrorKind::Salesforce {
89                error_code: "INVALID_SOBJECT".to_string(),
90                message: "Invalid SObject name".to_string(),
91            }));
92        }
93        let path = format!("sobjects/{}/describe/approvalLayouts", sobject);
94        self.client.rest_get(&path).await.map_err(Into::into)
95    }
96
97    /// Get compact layouts for a specific SObject.
98    ///
99    /// Compact layouts are used in the Salesforce mobile app and Lightning Experience
100    /// to show a preview of a record in a compact space.
101    ///
102    /// # Example
103    ///
104    /// ```rust,ignore
105    /// let compact_layouts = client.describe_compact_layouts("Account").await?;
106    /// println!("Compact layouts: {:?}", compact_layouts);
107    /// ```
108    ///
109    /// This is equivalent to calling `/services/data/vXX.0/sobjects/{sobject}/describe/compactLayouts`.
110    #[instrument(skip(self))]
111    pub async fn describe_compact_layouts(
112        &self,
113        sobject: &str,
114    ) -> Result<crate::layout::CompactLayoutsResult> {
115        if !soql::is_safe_sobject_name(sobject) {
116            return Err(Error::new(ErrorKind::Salesforce {
117                error_code: "INVALID_SOBJECT".to_string(),
118                message: "Invalid SObject name".to_string(),
119            }));
120        }
121        let path = format!("sobjects/{}/describe/compactLayouts", sobject);
122        self.client.rest_get(&path).await.map_err(Into::into)
123    }
124
125    /// Get global publisher layouts (global quick actions).
126    ///
127    /// This returns global quick actions and publisher layouts that are
128    /// available across the entire organization, not tied to a specific SObject.
129    ///
130    /// # Example
131    ///
132    /// ```rust,ignore
133    /// let global_layouts = client.describe_global_publisher_layouts().await?;
134    /// println!("Global layouts: {:?}", global_layouts);
135    /// ```
136    ///
137    /// This is equivalent to calling `/services/data/vXX.0/sobjects/Global/describe/layouts`.
138    #[instrument(skip(self))]
139    pub async fn describe_global_publisher_layouts(
140        &self,
141    ) -> Result<crate::layout::GlobalPublisherLayoutsResult> {
142        let path = "sobjects/Global/describe/layouts";
143        self.client.rest_get(path).await.map_err(Into::into)
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::super::SalesforceRestClient;
150
151    #[tokio::test]
152    async fn test_describe_layouts_wiremock() {
153        use wiremock::matchers::{method, path_regex};
154        use wiremock::{Mock, MockServer, ResponseTemplate};
155
156        let mock_server = MockServer::start().await;
157
158        let body = serde_json::json!({
159            "layouts": [{"id": "00h000000000001", "name": "Account Layout"}],
160            "recordTypeMappings": []
161        });
162
163        Mock::given(method("GET"))
164            .and(path_regex(".*/sobjects/Account/describe/layouts$"))
165            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
166            .mount(&mock_server)
167            .await;
168
169        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
170        let result = client
171            .describe_layouts("Account")
172            .await
173            .expect("describe_layouts should succeed");
174
175        assert!(result["layouts"].is_array());
176        assert_eq!(result["layouts"][0]["name"], "Account Layout");
177    }
178
179    #[tokio::test]
180    async fn test_describe_layouts_invalid_sobject() {
181        let client = SalesforceRestClient::new("https://test.salesforce.com", "token").unwrap();
182        let result = client.describe_layouts("Bad'; DROP--").await;
183        assert!(result.is_err());
184        assert!(result.unwrap_err().to_string().contains("INVALID_SOBJECT"));
185    }
186
187    #[tokio::test]
188    async fn test_describe_named_layout_wiremock() {
189        use wiremock::matchers::{method, path_regex};
190        use wiremock::{Mock, MockServer, ResponseTemplate};
191
192        let mock_server = MockServer::start().await;
193
194        let body = serde_json::json!({
195            "layouts": [{"detailLayoutSections": [], "editLayoutSections": []}]
196        });
197
198        Mock::given(method("GET"))
199            .and(path_regex(
200                ".*/sobjects/Account/describe/namedLayouts/MyLayout",
201            ))
202            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
203            .mount(&mock_server)
204            .await;
205
206        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
207        let result = client
208            .describe_named_layout("Account", "MyLayout")
209            .await
210            .expect("describe_named_layout should succeed");
211
212        assert!(result["layouts"].is_array());
213    }
214
215    #[tokio::test]
216    async fn test_describe_approval_layouts_wiremock() {
217        use wiremock::matchers::{method, path_regex};
218        use wiremock::{Mock, MockServer, ResponseTemplate};
219
220        let mock_server = MockServer::start().await;
221
222        let body = serde_json::json!({
223            "approvalLayouts": []
224        });
225
226        Mock::given(method("GET"))
227            .and(path_regex(".*/sobjects/Account/describe/approvalLayouts$"))
228            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
229            .mount(&mock_server)
230            .await;
231
232        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
233        let result = client
234            .describe_approval_layouts("Account")
235            .await
236            .expect("describe_approval_layouts should succeed");
237
238        assert!(result["approvalLayouts"].is_array());
239    }
240
241    #[tokio::test]
242    async fn test_describe_compact_layouts_wiremock() {
243        use wiremock::matchers::{method, path_regex};
244        use wiremock::{Mock, MockServer, ResponseTemplate};
245
246        let mock_server = MockServer::start().await;
247
248        let body = serde_json::json!({
249            "compactLayouts": [{"id": "0AH000000000001", "name": "System Default"}],
250            "defaultCompactLayoutId": "0AH000000000001"
251        });
252
253        Mock::given(method("GET"))
254            .and(path_regex(".*/sobjects/Account/describe/compactLayouts$"))
255            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
256            .mount(&mock_server)
257            .await;
258
259        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
260        let result = client
261            .describe_compact_layouts("Account")
262            .await
263            .expect("describe_compact_layouts should succeed");
264
265        assert!(result["compactLayouts"].is_array());
266        assert_eq!(result["compactLayouts"][0]["name"], "System Default");
267    }
268
269    #[tokio::test]
270    async fn test_describe_global_publisher_layouts_wiremock() {
271        use wiremock::matchers::{method, path_regex};
272        use wiremock::{Mock, MockServer, ResponseTemplate};
273
274        let mock_server = MockServer::start().await;
275
276        let body = serde_json::json!({
277            "layouts": [{"id": "00h000000000002", "name": "Global Layout"}]
278        });
279
280        Mock::given(method("GET"))
281            .and(path_regex(".*/sobjects/Global/describe/layouts$"))
282            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
283            .mount(&mock_server)
284            .await;
285
286        let client = SalesforceRestClient::new(mock_server.uri(), "test-token").unwrap();
287        let result = client
288            .describe_global_publisher_layouts()
289            .await
290            .expect("describe_global_publisher_layouts should succeed");
291
292        assert!(result["layouts"].is_array());
293        assert_eq!(result["layouts"][0]["name"], "Global Layout");
294    }
295}