busbar_sf_tooling/client/
sobject.rs1use busbar_sf_client::security::{soql, url as url_security};
2use tracing::instrument;
3
4use crate::error::{Error, ErrorKind, Result};
5
6#[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 #[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 #[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 #[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 #[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}