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    // Read-only status poll: safe to replay. Keeping this retryable
84    // matters — wait_for_deploy polls through it, and a transient 503
85    // mid-wait would otherwise abort a long-running deploy watch.
86    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    /// The wire is `<result>0Aff00...</result>` — just the new deploy id.
127    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        // RetrieveRequest derives Default, which produces an empty
157        // apiVersion. The server rejects that with an opaque fault; fail
158        // fast with a useful message instead.
159        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    // Read-only status poll: safe to replay (see CheckDeployStatusOp).
201    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
214// ---------------------------------------------------------------------------
215// Render helpers
216// ---------------------------------------------------------------------------
217
218fn 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    // Emit in the order shown in the Metadata API Developer Guide
236    // table — Salesforce's parser tolerates other orders, but emitting
237    // in doc order keeps wire diffs against the published examples
238    // minimal and makes the rendered XML easy to eyeball-diff.
239    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// ---------------------------------------------------------------------------
266// Polling
267// ---------------------------------------------------------------------------
268
269/// Configuration for [`MetadataClient::wait_for_deploy_with`] and
270/// [`MetadataClient::wait_for_retrieve_with`].
271///
272/// Polling uses exponential backoff starting at `initial_delay`,
273/// doubling each round, capped at `max_delay`. Calls don't have a
274/// per-request timeout — the dispatcher's own [`RetryPolicy`] handles
275/// transient failures.
276///
277/// [`RetryPolicy`]: crate::RetryPolicy
278#[derive(Debug, Clone)]
279pub struct WaitConfig {
280    /// Delay before the first poll. Default 2 s.
281    pub initial_delay: Duration,
282    /// Cap on the backoff delay. Default 30 s.
283    pub max_delay: Duration,
284    /// Total wall-clock budget. `None` = wait indefinitely. Default
285    /// `None` — deploys can legitimately run for hours.
286    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    /// Builder-style setter for a wall-clock timeout. Useful when you
301    /// want a CI job to fail fast rather than wait hours on a stuck
302    /// deploy.
303    pub fn with_timeout(mut self, timeout: Duration) -> Self {
304        self.total_timeout = Some(timeout);
305        self
306    }
307}
308
309// ---------------------------------------------------------------------------
310// Public API on MetadataClient
311// ---------------------------------------------------------------------------
312
313impl MetadataClient {
314    /// Starts a metadata deployment.
315    ///
316    /// `zip` is the raw bytes of the deployment zip (containing
317    /// `package.xml` plus the component files). The SDK base64-encodes
318    /// it on the wire — pass the unencoded bytes.
319    ///
320    /// Returns an [`AsyncResult`] whose `id` is the deployment job ID;
321    /// use [`Self::check_deploy_status`] or [`Self::wait_for_deploy`]
322    /// to follow its progress.
323    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    /// Fetches the current status of a deployment.
330    ///
331    /// `include_details` controls whether the response includes
332    /// per-component success/failure entries and Apex test results.
333    /// Costs extra bandwidth, but is required for any meaningful
334    /// post-mortem on a failed deploy.
335    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    /// Requests cancellation of an in-progress deployment.
349    ///
350    /// Returns immediately. If the deployment was still queued, it's
351    /// canceled synchronously (`done == true` in the result). If it
352    /// had started, the cancellation is processed asynchronously —
353    /// check_deploy_status continues to return `Canceling` until the
354    /// server transitions it to `Canceled`.
355    ///
356    /// In API v65+, deployments that have entered `FinalizingDeploy`
357    /// can't be canceled.
358    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    /// Quick-deploys a recently-validated deployment without re-running
367    /// tests.
368    ///
369    /// `validation_id` is the deploy ID returned by an earlier
370    /// `deploy()` call that was run with
371    /// [`DeployOptions::check_only`] set to `true` and finished
372    /// successfully within the last 10 days.
373    ///
374    /// Returns the *new* deployment job ID — use
375    /// [`Self::check_deploy_status`] or [`Self::wait_for_deploy`] to
376    /// follow it.
377    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    /// Starts a metadata retrieval.
386    ///
387    /// Returns an [`AsyncResult`] whose `id` is the retrieve job ID;
388    /// use [`Self::check_retrieve_status`] or
389    /// [`Self::wait_for_retrieve`] to follow it. The retrieved zip
390    /// bytes are returned as part of the [`RetrieveResult`].
391    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    /// Fetches the current status of a retrieval.
398    ///
399    /// `include_zip` controls whether the response embeds the
400    /// base64-encoded zip bytes. The server populates that field only
401    /// when the retrieve has succeeded; intermediate polls return
402    /// `zip_file: None` regardless of this flag. Passing
403    /// `include_zip == true` throughout polling is the simplest pattern
404    /// and what [`Self::wait_for_retrieve`] does.
405    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    /// Polls [`Self::check_deploy_status`] until `done == true` or
419    /// the configured timeout fires.
420    ///
421    /// Uses [`WaitConfig::default()`] — 2 s initial backoff doubling
422    /// to a 30 s cap, no timeout. For CI-friendly timeouts, use
423    /// [`Self::wait_for_deploy_with`] with
424    /// [`WaitConfig::with_timeout`].
425    ///
426    /// [`DeployResult::details`] is populated best-effort: it comes
427    /// from one final `include_details: true` fetch after the deploy
428    /// reaches a terminal state, and if that follow-up call fails the
429    /// terminal result is returned with `details: None` rather than
430    /// discarding a completed deploy's outcome behind an error.
431    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    /// Polling form with a configurable [`WaitConfig`].
437    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            // Intermediate polls skip details — they grow with every
446            // processed component and can balloon into megabytes for
447            // large deploys. We fetch the full DeployDetails once after
448            // the deploy reaches a terminal state.
449            let result = self.check_deploy_status(deploy_id, false).await?;
450            if result.done {
451                // The terminal result is already in hand; the details
452                // fetch only enriches it. If the follow-up fails (after
453                // its own retries), return what we have — an error here
454                // would discard a completed deploy's outcome.
455                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    /// Polls [`Self::check_retrieve_status`] until `done == true` or
482    /// the configured timeout fires. The returned [`RetrieveResult`]
483    /// has the zip bytes populated when the retrieve succeeded.
484    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    /// Polling form with a configurable [`WaitConfig`].
490    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        // Base64 of "PK\x03\x04hello"
531        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        // checkOnly comes before rollbackOnError comes before
546        // runTests comes before testLevel.
547        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        // Nothing optional is set — DeployOptions body should be empty.
563        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&lt;b&gt;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}