Skip to main content

cirrus_metadata/handlers/
file_based.rs

1//! File-based deploy / retrieve handlers.
2//!
3//! Each operation is a small [`SoapOperation`] implementation that
4//! renders its body XML and names the response wrapper. Public methods
5//! on [`MetadataClient`] wrap them with the user-facing arguments.
6//!
7//! ## Body XML rendering
8//!
9//! We hand-render bodies as strings rather than going through
10//! `quick-xml`'s serde serializer. Two reasons:
11//!
12//! 1. The metadata namespace prefix `met:` must appear on every
13//!    element inside `<met:{op}>` for Salesforce's parser. Driving that
14//!    through serde rename attributes is brittle.
15//! 2. We need explicit control over which fields are emitted — the
16//!    server treats "absent" and "present-but-empty" differently for
17//!    some fields, and serde's `skip_serializing_if` doesn't compose
18//!    cleanly with quick-xml.
19//!
20//! The bodies are short and structurally regular, so the hand-rolled
21//! code stays under a few dozen lines per operation.
22
23use 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
35// ---------------------------------------------------------------------------
36// Operations
37// ---------------------------------------------------------------------------
38
39struct 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    /// The wire is `<result>0Aff00...</result>` — just the new deploy id.
123    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        // RetrieveRequest derives Default, which produces an empty
153        // apiVersion. The server rejects that with an opaque fault; fail
154        // fast with a useful message instead.
155        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
208// ---------------------------------------------------------------------------
209// Render helpers
210// ---------------------------------------------------------------------------
211
212fn 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    // Emit in the order shown in the Metadata API Developer Guide
230    // table — Salesforce's parser tolerates other orders, but emitting
231    // in doc order keeps wire diffs against the published examples
232    // minimal and makes the rendered XML easy to eyeball-diff.
233    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// ---------------------------------------------------------------------------
260// Polling
261// ---------------------------------------------------------------------------
262
263/// Configuration for [`MetadataClient::wait_for_deploy_with`] and
264/// [`MetadataClient::wait_for_retrieve_with`].
265///
266/// Polling uses exponential backoff starting at `initial_delay`,
267/// doubling each round, capped at `max_delay`. Calls don't have a
268/// per-request timeout — the dispatcher's own [`RetryPolicy`] handles
269/// transient failures.
270///
271/// [`RetryPolicy`]: crate::RetryPolicy
272#[derive(Debug, Clone)]
273pub struct WaitConfig {
274    /// Delay before the first poll. Default 2 s.
275    pub initial_delay: Duration,
276    /// Cap on the backoff delay. Default 30 s.
277    pub max_delay: Duration,
278    /// Total wall-clock budget. `None` = wait indefinitely. Default
279    /// `None` — deploys can legitimately run for hours.
280    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    /// Builder-style setter for a wall-clock timeout. Useful when you
295    /// want a CI job to fail fast rather than wait hours on a stuck
296    /// deploy.
297    pub fn with_timeout(mut self, timeout: Duration) -> Self {
298        self.total_timeout = Some(timeout);
299        self
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Public API on MetadataClient
305// ---------------------------------------------------------------------------
306
307impl MetadataClient {
308    /// Starts a metadata deployment.
309    ///
310    /// `zip` is the raw bytes of the deployment zip (containing
311    /// `package.xml` plus the component files). The SDK base64-encodes
312    /// it on the wire — pass the unencoded bytes.
313    ///
314    /// Returns an [`AsyncResult`] whose `id` is the deployment job ID;
315    /// use [`Self::check_deploy_status`] or [`Self::wait_for_deploy`]
316    /// to follow its progress.
317    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    /// Fetches the current status of a deployment.
324    ///
325    /// `include_details` controls whether the response includes
326    /// per-component success/failure entries and Apex test results.
327    /// Costs extra bandwidth, but is required for any meaningful
328    /// post-mortem on a failed deploy.
329    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    /// Requests cancellation of an in-progress deployment.
343    ///
344    /// Returns immediately. If the deployment was still queued, it's
345    /// canceled synchronously (`done == true` in the result). If it
346    /// had started, the cancellation is processed asynchronously —
347    /// check_deploy_status continues to return `Canceling` until the
348    /// server transitions it to `Canceled`.
349    ///
350    /// In API v65+, deployments that have entered `FinalizingDeploy`
351    /// can't be canceled.
352    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    /// Quick-deploys a recently-validated deployment without re-running
361    /// tests.
362    ///
363    /// `validation_id` is the deploy ID returned by an earlier
364    /// `deploy()` call that was run with
365    /// [`DeployOptions::check_only`] set to `true` and finished
366    /// successfully within the last 10 days.
367    ///
368    /// Returns the *new* deployment job ID — use
369    /// [`Self::check_deploy_status`] or [`Self::wait_for_deploy`] to
370    /// follow it.
371    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    /// Starts a metadata retrieval.
380    ///
381    /// Returns an [`AsyncResult`] whose `id` is the retrieve job ID;
382    /// use [`Self::check_retrieve_status`] or
383    /// [`Self::wait_for_retrieve`] to follow it. The retrieved zip
384    /// bytes are returned as part of the [`RetrieveResult`].
385    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    /// Fetches the current status of a retrieval.
392    ///
393    /// `include_zip` controls whether the response embeds the
394    /// base64-encoded zip bytes. The server populates that field only
395    /// when the retrieve has succeeded; intermediate polls return
396    /// `zip_file: None` regardless of this flag. Passing
397    /// `include_zip == true` throughout polling is the simplest pattern
398    /// and what [`Self::wait_for_retrieve`] does.
399    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    /// Polls [`Self::check_deploy_status`] until `done == true` or
413    /// the configured timeout fires.
414    ///
415    /// Uses [`WaitConfig::default()`] — 2 s initial backoff doubling
416    /// to a 30 s cap, no timeout. For CI-friendly timeouts, use
417    /// [`Self::wait_for_deploy_with`] with
418    /// [`WaitConfig::with_timeout`].
419    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    /// Polling form with a configurable [`WaitConfig`].
425    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            // Intermediate polls skip details — they grow with every
434            // processed component and can balloon into megabytes for
435            // large deploys. We fetch the full DeployDetails once after
436            // the deploy reaches a terminal state.
437            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    /// Polls [`Self::check_retrieve_status`] until `done == true` or
454    /// the configured timeout fires. The returned [`RetrieveResult`]
455    /// has the zip bytes populated when the retrieve succeeded.
456    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    /// Polling form with a configurable [`WaitConfig`].
462    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        // Base64 of "PK\x03\x04hello"
503        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        // checkOnly comes before rollbackOnError comes before
518        // runTests comes before testLevel.
519        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        // Nothing optional is set — DeployOptions body should be empty.
535        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&lt;b&gt;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}