cirrus_metadata/handlers/
file_based.rs1use crate::envelope::xml_escape;
24use crate::error::{MetadataError, MetadataResult};
25use crate::result::{
26 AsyncResult, CancelDeployResult, DeployOptions, DeployResult, RetrieveRequest, RetrieveResult,
27};
28use crate::transport::SoapOperation;
29use crate::{MetadataClient, PackageManifest};
30use base64::Engine;
31use bytes::Bytes;
32use serde::Deserialize;
33use std::time::Duration;
34
35struct DeployOp {
40 zip_b64: String,
41 options: DeployOptions,
42}
43
44impl DeployOp {
45 fn new(zip: &[u8], options: DeployOptions) -> Self {
46 let zip_b64 = base64::engine::general_purpose::STANDARD.encode(zip);
47 Self { zip_b64, options }
48 }
49}
50
51#[derive(Deserialize)]
52struct DeployResponseWire {
53 result: AsyncResult,
54}
55
56impl SoapOperation for DeployOp {
57 const NAME: &'static str = "deploy";
58 type Response = DeployResponseWire;
59
60 fn render_body(&self) -> MetadataResult<String> {
61 let mut out = String::with_capacity(self.zip_b64.len() + 256);
62 out.push_str("<met:ZipFile>");
63 out.push_str(&self.zip_b64);
64 out.push_str("</met:ZipFile><met:DeployOptions>");
65 render_deploy_options(&self.options, &mut out);
66 out.push_str("</met:DeployOptions>");
67 Ok(out)
68 }
69}
70
71struct CheckDeployStatusOp {
72 async_process_id: String,
73 include_details: bool,
74}
75
76#[derive(Deserialize)]
77struct CheckDeployStatusResponseWire {
78 result: DeployResult,
79}
80
81impl SoapOperation for CheckDeployStatusOp {
82 const NAME: &'static str = "checkDeployStatus";
83 type Response = CheckDeployStatusResponseWire;
84
85 fn render_body(&self) -> MetadataResult<String> {
86 Ok(format!(
87 "<met:asyncProcessId>{}</met:asyncProcessId>\
88 <met:includeDetails>{}</met:includeDetails>",
89 xml_escape(&self.async_process_id),
90 self.include_details,
91 ))
92 }
93}
94
95struct CancelDeployOp {
96 async_process_id: String,
97}
98
99#[derive(Deserialize)]
100struct CancelDeployResponseWire {
101 result: CancelDeployResult,
102}
103
104impl SoapOperation for CancelDeployOp {
105 const NAME: &'static str = "cancelDeploy";
106 type Response = CancelDeployResponseWire;
107
108 fn render_body(&self) -> MetadataResult<String> {
109 Ok(format!(
110 "<met:asyncProcessId>{}</met:asyncProcessId>",
111 xml_escape(&self.async_process_id),
112 ))
113 }
114}
115
116struct DeployRecentValidationOp {
117 validation_id: String,
118}
119
120#[derive(Deserialize)]
121struct DeployRecentValidationResponseWire {
122 result: String,
124}
125
126impl SoapOperation for DeployRecentValidationOp {
127 const NAME: &'static str = "deployRecentValidation";
128 type Response = DeployRecentValidationResponseWire;
129
130 fn render_body(&self) -> MetadataResult<String> {
131 Ok(format!(
132 "<met:validationId>{}</met:validationId>",
133 xml_escape(&self.validation_id),
134 ))
135 }
136}
137
138struct RetrieveOp {
139 request: RetrieveRequest,
140}
141
142#[derive(Deserialize)]
143struct RetrieveResponseWire {
144 result: AsyncResult,
145}
146
147impl SoapOperation for RetrieveOp {
148 const NAME: &'static str = "retrieve";
149 type Response = RetrieveResponseWire;
150
151 fn render_body(&self) -> MetadataResult<String> {
152 if self.request.api_version.is_empty() {
156 return Err(MetadataError::InvalidArgument(
157 "RetrieveRequest.api_version is required (e.g. \"66.0\")".into(),
158 ));
159 }
160 let mut out = String::with_capacity(256);
161 out.push_str("<met:RetrieveRequest>");
162 out.push_str("<met:apiVersion>");
163 out.push_str(&xml_escape(&self.request.api_version));
164 out.push_str("</met:apiVersion>");
165 for pkg in &self.request.package_names {
166 out.push_str("<met:packageNames>");
167 out.push_str(&xml_escape(pkg));
168 out.push_str("</met:packageNames>");
169 }
170 write_bool(&mut out, "singlePackage", self.request.single_package);
171 for f in &self.request.specific_files {
172 out.push_str("<met:specificFiles>");
173 out.push_str(&xml_escape(f));
174 out.push_str("</met:specificFiles>");
175 }
176 if let Some(pkg) = &self.request.unpackaged {
177 render_unpackaged(pkg, &mut out);
178 }
179 out.push_str("</met:RetrieveRequest>");
180 Ok(out)
181 }
182}
183
184struct CheckRetrieveStatusOp {
185 async_process_id: String,
186 include_zip: bool,
187}
188
189#[derive(Deserialize)]
190struct CheckRetrieveStatusResponseWire {
191 result: RetrieveResult,
192}
193
194impl SoapOperation for CheckRetrieveStatusOp {
195 const NAME: &'static str = "checkRetrieveStatus";
196 type Response = CheckRetrieveStatusResponseWire;
197
198 fn render_body(&self) -> MetadataResult<String> {
199 Ok(format!(
200 "<met:asyncProcessId>{}</met:asyncProcessId>\
201 <met:includeZip>{}</met:includeZip>",
202 xml_escape(&self.async_process_id),
203 self.include_zip,
204 ))
205 }
206}
207
208fn write_bool(out: &mut String, name: &str, value: bool) {
213 out.push_str("<met:");
214 out.push_str(name);
215 out.push('>');
216 out.push_str(if value { "true" } else { "false" });
217 out.push_str("</met:");
218 out.push_str(name);
219 out.push('>');
220}
221
222fn write_opt_bool(out: &mut String, name: &str, value: Option<bool>) {
223 if let Some(v) = value {
224 write_bool(out, name, v);
225 }
226}
227
228fn render_deploy_options(opts: &DeployOptions, out: &mut String) {
229 write_opt_bool(out, "allowMissingFiles", opts.allow_missing_files);
234 write_opt_bool(out, "autoUpdatePackage", opts.auto_update_package);
235 write_opt_bool(out, "checkOnly", opts.check_only);
236 write_opt_bool(out, "ignoreWarnings", opts.ignore_warnings);
237 write_opt_bool(out, "performRetrieve", opts.perform_retrieve);
238 write_opt_bool(out, "purgeOnDelete", opts.purge_on_delete);
239 write_opt_bool(out, "rollbackOnError", opts.rollback_on_error);
240 for test in &opts.run_tests {
241 out.push_str("<met:runTests>");
242 out.push_str(&xml_escape(test));
243 out.push_str("</met:runTests>");
244 }
245 write_opt_bool(out, "singlePackage", opts.single_package);
246 if let Some(level) = opts.test_level {
247 out.push_str("<met:testLevel>");
248 out.push_str(level.as_wire());
249 out.push_str("</met:testLevel>");
250 }
251}
252
253fn render_unpackaged(pkg: &PackageManifest, out: &mut String) {
254 out.push_str("<met:unpackaged>");
255 out.push_str(&pkg.render_soap_inner());
256 out.push_str("</met:unpackaged>");
257}
258
259#[derive(Debug, Clone)]
273pub struct WaitConfig {
274 pub initial_delay: Duration,
276 pub max_delay: Duration,
278 pub total_timeout: Option<Duration>,
281}
282
283impl Default for WaitConfig {
284 fn default() -> Self {
285 Self {
286 initial_delay: Duration::from_secs(2),
287 max_delay: Duration::from_secs(30),
288 total_timeout: None,
289 }
290 }
291}
292
293impl WaitConfig {
294 pub fn with_timeout(mut self, timeout: Duration) -> Self {
298 self.total_timeout = Some(timeout);
299 self
300 }
301}
302
303impl MetadataClient {
308 pub async fn deploy(&self, zip: Bytes, options: DeployOptions) -> MetadataResult<AsyncResult> {
318 let op = DeployOp::new(&zip, options);
319 let resp = self.call(&op).await?;
320 Ok(resp.result)
321 }
322
323 pub async fn check_deploy_status(
330 &self,
331 deploy_id: &str,
332 include_details: bool,
333 ) -> MetadataResult<DeployResult> {
334 let op = CheckDeployStatusOp {
335 async_process_id: deploy_id.to_string(),
336 include_details,
337 };
338 let resp = self.call(&op).await?;
339 Ok(resp.result)
340 }
341
342 pub async fn cancel_deploy(&self, deploy_id: &str) -> MetadataResult<CancelDeployResult> {
353 let op = CancelDeployOp {
354 async_process_id: deploy_id.to_string(),
355 };
356 let resp = self.call(&op).await?;
357 Ok(resp.result)
358 }
359
360 pub async fn deploy_recent_validation(&self, validation_id: &str) -> MetadataResult<String> {
372 let op = DeployRecentValidationOp {
373 validation_id: validation_id.to_string(),
374 };
375 let resp = self.call(&op).await?;
376 Ok(resp.result)
377 }
378
379 pub async fn retrieve(&self, request: RetrieveRequest) -> MetadataResult<AsyncResult> {
386 let op = RetrieveOp { request };
387 let resp = self.call(&op).await?;
388 Ok(resp.result)
389 }
390
391 pub async fn check_retrieve_status(
400 &self,
401 retrieve_id: &str,
402 include_zip: bool,
403 ) -> MetadataResult<RetrieveResult> {
404 let op = CheckRetrieveStatusOp {
405 async_process_id: retrieve_id.to_string(),
406 include_zip,
407 };
408 let resp = self.call(&op).await?;
409 Ok(resp.result)
410 }
411
412 pub async fn wait_for_deploy(&self, deploy_id: &str) -> MetadataResult<DeployResult> {
420 self.wait_for_deploy_with(deploy_id, WaitConfig::default())
421 .await
422 }
423
424 pub async fn wait_for_deploy_with(
426 &self,
427 deploy_id: &str,
428 config: WaitConfig,
429 ) -> MetadataResult<DeployResult> {
430 let start = tokio::time::Instant::now();
431 let mut delay = config.initial_delay;
432 loop {
433 let result = self.check_deploy_status(deploy_id, false).await?;
438 if result.done {
439 return self.check_deploy_status(deploy_id, true).await;
440 }
441 if let Some(timeout) = config.total_timeout
442 && start.elapsed() >= timeout
443 {
444 return Err(MetadataError::PollTimeout(format!(
445 "wait_for_deploy timed out after {timeout:?} (deploy still in progress)"
446 )));
447 }
448 tokio::time::sleep(delay).await;
449 delay = delay.saturating_mul(2).min(config.max_delay);
450 }
451 }
452
453 pub async fn wait_for_retrieve(&self, retrieve_id: &str) -> MetadataResult<RetrieveResult> {
457 self.wait_for_retrieve_with(retrieve_id, WaitConfig::default())
458 .await
459 }
460
461 pub async fn wait_for_retrieve_with(
463 &self,
464 retrieve_id: &str,
465 config: WaitConfig,
466 ) -> MetadataResult<RetrieveResult> {
467 let start = tokio::time::Instant::now();
468 let mut delay = config.initial_delay;
469 loop {
470 let result = self.check_retrieve_status(retrieve_id, true).await?;
471 if result.done {
472 return Ok(result);
473 }
474 if let Some(timeout) = config.total_timeout
475 && start.elapsed() >= timeout
476 {
477 return Err(MetadataError::PollTimeout(format!(
478 "wait_for_retrieve timed out after {timeout:?} (retrieve still in progress)"
479 )));
480 }
481 tokio::time::sleep(delay).await;
482 delay = delay.saturating_mul(2).min(config.max_delay);
483 }
484 }
485}
486
487#[cfg(test)]
488#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
489mod tests {
490 use super::*;
491 use crate::MetadataType;
492 use crate::result::TestLevel;
493
494 #[test]
495 fn deploy_op_emits_zipfile_and_deployoptions() {
496 let op = DeployOp::new(b"PK\x03\x04hello", DeployOptions::default());
497 let body = op.render_body().unwrap();
498 assert!(body.contains("<met:ZipFile>"));
499 assert!(body.contains("</met:ZipFile>"));
500 assert!(body.contains("<met:DeployOptions>"));
501 assert!(body.contains("</met:DeployOptions>"));
502 assert!(body.contains("UEsDBGhlbGxv"));
504 }
505
506 #[test]
507 fn deploy_op_emits_options_in_doc_order() {
508 let opts = DeployOptions {
509 check_only: Some(true),
510 rollback_on_error: Some(true),
511 test_level: Some(TestLevel::RunLocalTests),
512 run_tests: vec!["MyTest".into()],
513 ..Default::default()
514 };
515 let op = DeployOp::new(b"", opts);
516 let body = op.render_body().unwrap();
517 let i_check = body.find("checkOnly").unwrap();
520 let i_rollback = body.find("rollbackOnError").unwrap();
521 let i_runtests = body.find("runTests").unwrap();
522 let i_testlevel = body.find("testLevel").unwrap();
523 assert!(i_check < i_rollback);
524 assert!(i_rollback < i_runtests);
525 assert!(i_runtests < i_testlevel);
526 assert!(body.contains("<met:runTests>MyTest</met:runTests>"));
527 assert!(body.contains("<met:testLevel>RunLocalTests</met:testLevel>"));
528 }
529
530 #[test]
531 fn deploy_op_skips_none_options() {
532 let op = DeployOp::new(b"", DeployOptions::default());
533 let body = op.render_body().unwrap();
534 assert!(body.contains("<met:DeployOptions></met:DeployOptions>"));
536 }
537
538 #[test]
539 fn check_deploy_status_body_round_trip() {
540 let op = CheckDeployStatusOp {
541 async_process_id: "0Aff00000abc".into(),
542 include_details: true,
543 };
544 let body = op.render_body().unwrap();
545 assert_eq!(
546 body,
547 "<met:asyncProcessId>0Aff00000abc</met:asyncProcessId>\
548 <met:includeDetails>true</met:includeDetails>"
549 );
550 }
551
552 #[test]
553 fn retrieve_op_emits_unpackaged_manifest() {
554 let req = RetrieveRequest {
555 api_version: "66.0".into(),
556 single_package: true,
557 unpackaged: Some(
558 PackageManifest::new("66.0")
559 .add(MetadataType::APEX_CLASS, ["MyClass", "OtherClass"]),
560 ),
561 ..Default::default()
562 };
563 let op = RetrieveOp { request: req };
564 let body = op.render_body().unwrap();
565 assert!(body.contains("<met:RetrieveRequest>"));
566 assert!(body.contains("<met:apiVersion>66.0</met:apiVersion>"));
567 assert!(body.contains("<met:singlePackage>true</met:singlePackage>"));
568 assert!(body.contains("<met:unpackaged>"));
569 assert!(body.contains("<met:types>"));
570 assert!(body.contains("<met:members>MyClass</met:members>"));
571 assert!(body.contains("<met:members>OtherClass</met:members>"));
572 assert!(body.contains("<met:name>ApexClass</met:name>"));
573 assert!(body.contains("<met:version>66.0</met:version>"));
574 }
575
576 #[test]
577 fn retrieve_op_escapes_specific_files() {
578 let req = RetrieveRequest {
579 api_version: "66.0".into(),
580 single_package: true,
581 specific_files: vec!["a<b>c".into()],
582 ..Default::default()
583 };
584 let op = RetrieveOp { request: req };
585 let body = op.render_body().unwrap();
586 assert!(body.contains("<met:specificFiles>a<b>c</met:specificFiles>"));
587 }
588
589 #[test]
590 fn retrieve_op_rejects_empty_api_version() {
591 let req = RetrieveRequest::default();
592 let op = RetrieveOp { request: req };
593 let err = op.render_body().unwrap_err();
594 assert!(matches!(err, MetadataError::InvalidArgument(_)));
595 assert!(err.to_string().contains("api_version"));
596 }
597
598 #[test]
599 fn check_retrieve_status_emits_include_zip_flag() {
600 let op = CheckRetrieveStatusOp {
601 async_process_id: "0Aff00000abc".into(),
602 include_zip: false,
603 };
604 let body = op.render_body().unwrap();
605 assert!(body.contains("<met:includeZip>false</met:includeZip>"));
606 }
607}