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 const IDEMPOTENT: bool = true;
87 type Response = CheckDeployStatusResponseWire;
88
89 fn render_body(&self) -> MetadataResult<String> {
90 Ok(format!(
91 "<met:asyncProcessId>{}</met:asyncProcessId>\
92 <met:includeDetails>{}</met:includeDetails>",
93 xml_escape(&self.async_process_id),
94 self.include_details,
95 ))
96 }
97}
98
99struct CancelDeployOp {
100 async_process_id: String,
101}
102
103#[derive(Deserialize)]
104struct CancelDeployResponseWire {
105 result: CancelDeployResult,
106}
107
108impl SoapOperation for CancelDeployOp {
109 const NAME: &'static str = "cancelDeploy";
110 type Response = CancelDeployResponseWire;
111
112 fn render_body(&self) -> MetadataResult<String> {
113 Ok(format!(
114 "<met:asyncProcessId>{}</met:asyncProcessId>",
115 xml_escape(&self.async_process_id),
116 ))
117 }
118}
119
120struct DeployRecentValidationOp {
121 validation_id: String,
122}
123
124#[derive(Deserialize)]
125struct DeployRecentValidationResponseWire {
126 result: String,
128}
129
130impl SoapOperation for DeployRecentValidationOp {
131 const NAME: &'static str = "deployRecentValidation";
132 type Response = DeployRecentValidationResponseWire;
133
134 fn render_body(&self) -> MetadataResult<String> {
135 Ok(format!(
136 "<met:validationId>{}</met:validationId>",
137 xml_escape(&self.validation_id),
138 ))
139 }
140}
141
142struct RetrieveOp {
143 request: RetrieveRequest,
144}
145
146#[derive(Deserialize)]
147struct RetrieveResponseWire {
148 result: AsyncResult,
149}
150
151impl SoapOperation for RetrieveOp {
152 const NAME: &'static str = "retrieve";
153 type Response = RetrieveResponseWire;
154
155 fn render_body(&self) -> MetadataResult<String> {
156 if self.request.api_version.is_empty() {
160 return Err(MetadataError::InvalidArgument(
161 "RetrieveRequest.api_version is required (e.g. \"66.0\")".into(),
162 ));
163 }
164 let mut out = String::with_capacity(256);
165 out.push_str("<met:RetrieveRequest>");
166 out.push_str("<met:apiVersion>");
167 out.push_str(&xml_escape(&self.request.api_version));
168 out.push_str("</met:apiVersion>");
169 for pkg in &self.request.package_names {
170 out.push_str("<met:packageNames>");
171 out.push_str(&xml_escape(pkg));
172 out.push_str("</met:packageNames>");
173 }
174 write_bool(&mut out, "singlePackage", self.request.single_package);
175 for f in &self.request.specific_files {
176 out.push_str("<met:specificFiles>");
177 out.push_str(&xml_escape(f));
178 out.push_str("</met:specificFiles>");
179 }
180 if let Some(pkg) = &self.request.unpackaged {
181 render_unpackaged(pkg, &mut out);
182 }
183 out.push_str("</met:RetrieveRequest>");
184 Ok(out)
185 }
186}
187
188struct CheckRetrieveStatusOp {
189 async_process_id: String,
190 include_zip: bool,
191}
192
193#[derive(Deserialize)]
194struct CheckRetrieveStatusResponseWire {
195 result: RetrieveResult,
196}
197
198impl SoapOperation for CheckRetrieveStatusOp {
199 const NAME: &'static str = "checkRetrieveStatus";
200 const IDEMPOTENT: bool = true;
202 type Response = CheckRetrieveStatusResponseWire;
203
204 fn render_body(&self) -> MetadataResult<String> {
205 Ok(format!(
206 "<met:asyncProcessId>{}</met:asyncProcessId>\
207 <met:includeZip>{}</met:includeZip>",
208 xml_escape(&self.async_process_id),
209 self.include_zip,
210 ))
211 }
212}
213
214fn write_bool(out: &mut String, name: &str, value: bool) {
219 out.push_str("<met:");
220 out.push_str(name);
221 out.push('>');
222 out.push_str(if value { "true" } else { "false" });
223 out.push_str("</met:");
224 out.push_str(name);
225 out.push('>');
226}
227
228fn write_opt_bool(out: &mut String, name: &str, value: Option<bool>) {
229 if let Some(v) = value {
230 write_bool(out, name, v);
231 }
232}
233
234fn render_deploy_options(opts: &DeployOptions, out: &mut String) {
235 write_opt_bool(out, "allowMissingFiles", opts.allow_missing_files);
240 write_opt_bool(out, "autoUpdatePackage", opts.auto_update_package);
241 write_opt_bool(out, "checkOnly", opts.check_only);
242 write_opt_bool(out, "ignoreWarnings", opts.ignore_warnings);
243 write_opt_bool(out, "performRetrieve", opts.perform_retrieve);
244 write_opt_bool(out, "purgeOnDelete", opts.purge_on_delete);
245 write_opt_bool(out, "rollbackOnError", opts.rollback_on_error);
246 for test in &opts.run_tests {
247 out.push_str("<met:runTests>");
248 out.push_str(&xml_escape(test));
249 out.push_str("</met:runTests>");
250 }
251 write_opt_bool(out, "singlePackage", opts.single_package);
252 if let Some(level) = opts.test_level {
253 out.push_str("<met:testLevel>");
254 out.push_str(level.as_wire());
255 out.push_str("</met:testLevel>");
256 }
257}
258
259fn render_unpackaged(pkg: &PackageManifest, out: &mut String) {
260 out.push_str("<met:unpackaged>");
261 out.push_str(&pkg.render_soap_inner());
262 out.push_str("</met:unpackaged>");
263}
264
265#[derive(Debug, Clone)]
279pub struct WaitConfig {
280 pub initial_delay: Duration,
282 pub max_delay: Duration,
284 pub total_timeout: Option<Duration>,
287}
288
289impl Default for WaitConfig {
290 fn default() -> Self {
291 Self {
292 initial_delay: Duration::from_secs(2),
293 max_delay: Duration::from_secs(30),
294 total_timeout: None,
295 }
296 }
297}
298
299impl WaitConfig {
300 pub fn with_timeout(mut self, timeout: Duration) -> Self {
304 self.total_timeout = Some(timeout);
305 self
306 }
307}
308
309impl MetadataClient {
314 pub async fn deploy(&self, zip: Bytes, options: DeployOptions) -> MetadataResult<AsyncResult> {
324 let op = DeployOp::new(&zip, options);
325 let resp = self.call(&op).await?;
326 Ok(resp.result)
327 }
328
329 pub async fn check_deploy_status(
336 &self,
337 deploy_id: &str,
338 include_details: bool,
339 ) -> MetadataResult<DeployResult> {
340 let op = CheckDeployStatusOp {
341 async_process_id: deploy_id.to_string(),
342 include_details,
343 };
344 let resp = self.call(&op).await?;
345 Ok(resp.result)
346 }
347
348 pub async fn cancel_deploy(&self, deploy_id: &str) -> MetadataResult<CancelDeployResult> {
359 let op = CancelDeployOp {
360 async_process_id: deploy_id.to_string(),
361 };
362 let resp = self.call(&op).await?;
363 Ok(resp.result)
364 }
365
366 pub async fn deploy_recent_validation(&self, validation_id: &str) -> MetadataResult<String> {
378 let op = DeployRecentValidationOp {
379 validation_id: validation_id.to_string(),
380 };
381 let resp = self.call(&op).await?;
382 Ok(resp.result)
383 }
384
385 pub async fn retrieve(&self, request: RetrieveRequest) -> MetadataResult<AsyncResult> {
392 let op = RetrieveOp { request };
393 let resp = self.call(&op).await?;
394 Ok(resp.result)
395 }
396
397 pub async fn check_retrieve_status(
406 &self,
407 retrieve_id: &str,
408 include_zip: bool,
409 ) -> MetadataResult<RetrieveResult> {
410 let op = CheckRetrieveStatusOp {
411 async_process_id: retrieve_id.to_string(),
412 include_zip,
413 };
414 let resp = self.call(&op).await?;
415 Ok(resp.result)
416 }
417
418 pub async fn wait_for_deploy(&self, deploy_id: &str) -> MetadataResult<DeployResult> {
432 self.wait_for_deploy_with(deploy_id, WaitConfig::default())
433 .await
434 }
435
436 pub async fn wait_for_deploy_with(
438 &self,
439 deploy_id: &str,
440 config: WaitConfig,
441 ) -> MetadataResult<DeployResult> {
442 let start = tokio::time::Instant::now();
443 let mut delay = config.initial_delay;
444 loop {
445 let result = self.check_deploy_status(deploy_id, false).await?;
450 if result.done {
451 return match self.check_deploy_status(deploy_id, true).await {
456 Ok(with_details) => Ok(with_details),
457 Err(e) => {
458 tracing::warn!(
459 target: "cirrus_metadata::poll",
460 deploy_id,
461 error = %e,
462 "deploy reached a terminal state but the details fetch failed; \
463 returning the result without details",
464 );
465 Ok(result)
466 }
467 };
468 }
469 if let Some(timeout) = config.total_timeout
470 && start.elapsed() >= timeout
471 {
472 return Err(MetadataError::PollTimeout(format!(
473 "wait_for_deploy timed out after {timeout:?} (deploy still in progress)"
474 )));
475 }
476 tokio::time::sleep(delay).await;
477 delay = delay.saturating_mul(2).min(config.max_delay);
478 }
479 }
480
481 pub async fn wait_for_retrieve(&self, retrieve_id: &str) -> MetadataResult<RetrieveResult> {
485 self.wait_for_retrieve_with(retrieve_id, WaitConfig::default())
486 .await
487 }
488
489 pub async fn wait_for_retrieve_with(
491 &self,
492 retrieve_id: &str,
493 config: WaitConfig,
494 ) -> MetadataResult<RetrieveResult> {
495 let start = tokio::time::Instant::now();
496 let mut delay = config.initial_delay;
497 loop {
498 let result = self.check_retrieve_status(retrieve_id, true).await?;
499 if result.done {
500 return Ok(result);
501 }
502 if let Some(timeout) = config.total_timeout
503 && start.elapsed() >= timeout
504 {
505 return Err(MetadataError::PollTimeout(format!(
506 "wait_for_retrieve timed out after {timeout:?} (retrieve still in progress)"
507 )));
508 }
509 tokio::time::sleep(delay).await;
510 delay = delay.saturating_mul(2).min(config.max_delay);
511 }
512 }
513}
514
515#[cfg(test)]
516#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
517mod tests {
518 use super::*;
519 use crate::MetadataType;
520 use crate::result::TestLevel;
521
522 #[test]
523 fn deploy_op_emits_zipfile_and_deployoptions() {
524 let op = DeployOp::new(b"PK\x03\x04hello", DeployOptions::default());
525 let body = op.render_body().unwrap();
526 assert!(body.contains("<met:ZipFile>"));
527 assert!(body.contains("</met:ZipFile>"));
528 assert!(body.contains("<met:DeployOptions>"));
529 assert!(body.contains("</met:DeployOptions>"));
530 assert!(body.contains("UEsDBGhlbGxv"));
532 }
533
534 #[test]
535 fn deploy_op_emits_options_in_doc_order() {
536 let opts = DeployOptions {
537 check_only: Some(true),
538 rollback_on_error: Some(true),
539 test_level: Some(TestLevel::RunLocalTests),
540 run_tests: vec!["MyTest".into()],
541 ..Default::default()
542 };
543 let op = DeployOp::new(b"", opts);
544 let body = op.render_body().unwrap();
545 let i_check = body.find("checkOnly").unwrap();
548 let i_rollback = body.find("rollbackOnError").unwrap();
549 let i_runtests = body.find("runTests").unwrap();
550 let i_testlevel = body.find("testLevel").unwrap();
551 assert!(i_check < i_rollback);
552 assert!(i_rollback < i_runtests);
553 assert!(i_runtests < i_testlevel);
554 assert!(body.contains("<met:runTests>MyTest</met:runTests>"));
555 assert!(body.contains("<met:testLevel>RunLocalTests</met:testLevel>"));
556 }
557
558 #[test]
559 fn deploy_op_skips_none_options() {
560 let op = DeployOp::new(b"", DeployOptions::default());
561 let body = op.render_body().unwrap();
562 assert!(body.contains("<met:DeployOptions></met:DeployOptions>"));
564 }
565
566 #[test]
567 fn check_deploy_status_body_round_trip() {
568 let op = CheckDeployStatusOp {
569 async_process_id: "0Aff00000abc".into(),
570 include_details: true,
571 };
572 let body = op.render_body().unwrap();
573 assert_eq!(
574 body,
575 "<met:asyncProcessId>0Aff00000abc</met:asyncProcessId>\
576 <met:includeDetails>true</met:includeDetails>"
577 );
578 }
579
580 #[test]
581 fn retrieve_op_emits_unpackaged_manifest() {
582 let req = RetrieveRequest {
583 api_version: "66.0".into(),
584 single_package: true,
585 unpackaged: Some(
586 PackageManifest::new("66.0")
587 .add(MetadataType::APEX_CLASS, ["MyClass", "OtherClass"]),
588 ),
589 ..Default::default()
590 };
591 let op = RetrieveOp { request: req };
592 let body = op.render_body().unwrap();
593 assert!(body.contains("<met:RetrieveRequest>"));
594 assert!(body.contains("<met:apiVersion>66.0</met:apiVersion>"));
595 assert!(body.contains("<met:singlePackage>true</met:singlePackage>"));
596 assert!(body.contains("<met:unpackaged>"));
597 assert!(body.contains("<met:types>"));
598 assert!(body.contains("<met:members>MyClass</met:members>"));
599 assert!(body.contains("<met:members>OtherClass</met:members>"));
600 assert!(body.contains("<met:name>ApexClass</met:name>"));
601 assert!(body.contains("<met:version>66.0</met:version>"));
602 }
603
604 #[test]
605 fn retrieve_op_escapes_specific_files() {
606 let req = RetrieveRequest {
607 api_version: "66.0".into(),
608 single_package: true,
609 specific_files: vec!["a<b>c".into()],
610 ..Default::default()
611 };
612 let op = RetrieveOp { request: req };
613 let body = op.render_body().unwrap();
614 assert!(body.contains("<met:specificFiles>a<b>c</met:specificFiles>"));
615 }
616
617 #[test]
618 fn retrieve_op_rejects_empty_api_version() {
619 let req = RetrieveRequest::default();
620 let op = RetrieveOp { request: req };
621 let err = op.render_body().unwrap_err();
622 assert!(matches!(err, MetadataError::InvalidArgument(_)));
623 assert!(err.to_string().contains("api_version"));
624 }
625
626 #[test]
627 fn check_retrieve_status_emits_include_zip_flag() {
628 let op = CheckRetrieveStatusOp {
629 async_process_id: "0Aff00000abc".into(),
630 include_zip: false,
631 };
632 let body = op.render_body().unwrap();
633 assert!(body.contains("<met:includeZip>false</met:includeZip>"));
634 }
635}