Skip to main content

busbar_sf_tooling/client/
sobject.rs

1use busbar_sf_client::security::{soql, url as url_security};
2use tracing::instrument;
3
4use crate::error::{Error, ErrorKind, Result};
5
6/// Response from create operations.
7#[derive(Debug, Clone, serde::Deserialize)]
8pub(super) struct CreateResponse {
9    pub(super) id: String,
10    pub(super) success: bool,
11    #[serde(default)]
12    pub(super) errors: Vec<CreateError>,
13}
14
15#[derive(Debug, Clone, serde::Deserialize)]
16pub(super) struct CreateError {
17    pub(super) message: String,
18    #[serde(rename = "statusCode")]
19    #[allow(dead_code)]
20    pub(super) status_code: String,
21}
22
23impl super::ToolingClient {
24    /// Get a Tooling API SObject by ID.
25    #[instrument(skip(self))]
26    pub async fn get<T: serde::de::DeserializeOwned>(&self, sobject: &str, id: &str) -> Result<T> {
27        if !soql::is_safe_sobject_name(sobject) {
28            return Err(Error::new(ErrorKind::Salesforce {
29                error_code: "INVALID_SOBJECT".to_string(),
30                message: "Invalid SObject name".to_string(),
31            }));
32        }
33        if !url_security::is_valid_salesforce_id(id) {
34            return Err(Error::new(ErrorKind::Salesforce {
35                error_code: "INVALID_ID".to_string(),
36                message: "Invalid Salesforce ID format".to_string(),
37            }));
38        }
39        let path = format!("sobjects/{}/{}", sobject, id);
40        self.client.tooling_get(&path).await.map_err(Into::into)
41    }
42
43    /// Create a Tooling API SObject.
44    #[instrument(skip(self, record))]
45    pub async fn create<T: serde::Serialize>(&self, sobject: &str, record: &T) -> Result<String> {
46        if !soql::is_safe_sobject_name(sobject) {
47            return Err(Error::new(ErrorKind::Salesforce {
48                error_code: "INVALID_SOBJECT".to_string(),
49                message: "Invalid SObject name".to_string(),
50            }));
51        }
52        let path = format!("sobjects/{}", sobject);
53        let result: CreateResponse = self.client.tooling_post(&path, record).await?;
54
55        if result.success {
56            Ok(result.id)
57        } else {
58            Err(Error::new(ErrorKind::Salesforce {
59                error_code: "CREATE_FAILED".to_string(),
60                message: result
61                    .errors
62                    .into_iter()
63                    .map(|e| e.message)
64                    .collect::<Vec<_>>()
65                    .join("; "),
66            }))
67        }
68    }
69
70    /// Update a Tooling API SObject (partial update).
71    #[instrument(skip(self, record))]
72    pub async fn update<T: serde::Serialize>(
73        &self,
74        sobject: &str,
75        id: &str,
76        record: &T,
77    ) -> Result<()> {
78        if !soql::is_safe_sobject_name(sobject) {
79            return Err(Error::new(ErrorKind::Salesforce {
80                error_code: "INVALID_SOBJECT".to_string(),
81                message: "Invalid SObject name".to_string(),
82            }));
83        }
84        if !url_security::is_valid_salesforce_id(id) {
85            return Err(Error::new(ErrorKind::Salesforce {
86                error_code: "INVALID_ID".to_string(),
87                message: "Invalid Salesforce ID format".to_string(),
88            }));
89        }
90        let url = format!(
91            "{}/services/data/v{}/tooling/sobjects/{}/{}",
92            self.client.instance_url(),
93            self.client.api_version(),
94            sobject,
95            id
96        );
97
98        self.client
99            .patch_json(&url, record)
100            .await
101            .map_err(Into::into)
102    }
103
104    /// Delete a Tooling API SObject.
105    #[instrument(skip(self))]
106    pub async fn delete(&self, sobject: &str, id: &str) -> Result<()> {
107        if !soql::is_safe_sobject_name(sobject) {
108            return Err(Error::new(ErrorKind::Salesforce {
109                error_code: "INVALID_SOBJECT".to_string(),
110                message: "Invalid SObject name".to_string(),
111            }));
112        }
113        if !url_security::is_valid_salesforce_id(id) {
114            return Err(Error::new(ErrorKind::Salesforce {
115                error_code: "INVALID_ID".to_string(),
116                message: "Invalid Salesforce ID format".to_string(),
117            }));
118        }
119        let url = format!(
120            "{}/services/data/v{}/tooling/sobjects/{}/{}",
121            self.client.instance_url(),
122            self.client.api_version(),
123            sobject,
124            id
125        );
126
127        let request = self.client.delete(&url);
128        let response = self.client.execute(request).await?;
129
130        if response.status() == 204 || response.is_success() {
131            Ok(())
132        } else {
133            Err(Error::new(ErrorKind::Salesforce {
134                error_code: "DELETE_FAILED".to_string(),
135                message: format!("Failed to delete {}: status {}", sobject, response.status()),
136            }))
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::super::ToolingClient;
144
145    #[tokio::test]
146    async fn test_update_wiremock() {
147        use wiremock::matchers::{method, path_regex};
148        use wiremock::{Mock, MockServer, ResponseTemplate};
149
150        let mock_server = MockServer::start().await;
151
152        Mock::given(method("PATCH"))
153            .and(path_regex(
154                ".*/tooling/sobjects/TraceFlag/7tf000000000001AAA",
155            ))
156            .respond_with(ResponseTemplate::new(204))
157            .mount(&mock_server)
158            .await;
159
160        let client = ToolingClient::new(mock_server.uri(), "test-token").unwrap();
161        let update_body = serde_json::json!({
162            "ExpirationDate": "2026-12-31T23:59:59.000Z"
163        });
164        let result = client
165            .update("TraceFlag", "7tf000000000001AAA", &update_body)
166            .await;
167        assert!(result.is_ok(), "update should succeed: {:?}", result.err());
168    }
169
170    #[tokio::test]
171    async fn test_update_invalid_sobject() {
172        let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
173        let result = client
174            .update(
175                "Robert'; DROP TABLE--",
176                "7tf000000000001AAA",
177                &serde_json::json!({}),
178            )
179            .await;
180        assert!(result.is_err());
181        let err = result.unwrap_err();
182        assert!(
183            err.to_string().contains("INVALID_SOBJECT"),
184            "Expected INVALID_SOBJECT, got: {err}"
185        );
186    }
187
188    #[tokio::test]
189    async fn test_update_invalid_id() {
190        let client = ToolingClient::new("https://na1.salesforce.com", "token").unwrap();
191        let result = client
192            .update("TraceFlag", "not-valid-id!", &serde_json::json!({}))
193            .await;
194        assert!(result.is_err());
195        let err = result.unwrap_err();
196        assert!(
197            err.to_string().contains("INVALID_ID"),
198            "Expected INVALID_ID, got: {err}"
199        );
200    }
201}