Skip to main content

cirrus_metadata/handlers/
crud.rs

1//! Synchronous CRUD-based Metadata API handlers.
2//!
3//! These calls let you create / read / update / upsert / delete /
4//! rename individual metadata components in a single SOAP round-trip
5//! — no zip files, no async polling. They sit alongside the file-based
6//! [`deploy`] / [`retrieve`] flow and cover the same lifecycle
7//! operations at a finer grain.
8//!
9//! ## What the SDK does and doesn't model
10//!
11//! Salesforce defines ~200 concrete metadata types (`CustomObject`,
12//! `ApexClass`, `Profile`, …). Modeling every one as a typed Rust
13//! struct would be brittle (types change every release) and against
14//! cirrus's "no user-facing types" principle. So:
15//!
16//! - For [`MetadataClient::create_metadata`] /
17//!   [`MetadataClient::update_metadata`] /
18//!   [`MetadataClient::upsert_metadata`] the caller supplies
19//!   **pre-rendered XML inner content** per component. The SDK wraps
20//!   each component in a
21//!   `<metadata xsi:type="met:{TypeName}">…</metadata>` element and
22//!   handles the SOAP envelope.
23//! - For [`MetadataClient::read_metadata`] the caller supplies a typed
24//!   `R: Deserialize` shape that maps over one `<records>` element. The
25//!   SDK returns `Vec<R>`.
26//!
27//! Inside the `<metadata>` wrapper the metadata namespace is declared
28//! as the default, so callers can write naked element names —
29//! `<fullName>Foo</fullName>` rather than
30//! `<met:fullName>Foo</met:fullName>`. Both forms work; the naked
31//! form is more readable for hand-built XML.
32//!
33//! ## Per-call component cap
34//!
35//! All five "multi" CRUD calls cap at 10 components per call (server
36//! limit). The SDK enforces this client-side via
37//! [`MAX_CRUD_COMPONENTS_PER_CALL`] — passing more returns
38//! [`MetadataError::InvalidArgument`] before hitting the wire.
39//!
40//! [`deploy`]: crate::MetadataClient::deploy
41//! [`retrieve`]: crate::MetadataClient::retrieve
42//! [`MetadataError::InvalidArgument`]: crate::MetadataError::InvalidArgument
43
44use crate::MetadataClient;
45use crate::envelope::xml_escape;
46use crate::error::{MetadataError, MetadataResult};
47use crate::result::{DeleteResult, SaveResult, UpsertResult};
48use crate::transport::SoapOperation;
49use serde::Deserialize;
50use serde::de::DeserializeOwned;
51use std::marker::PhantomData;
52
53/// Salesforce server limit on per-call component count for
54/// `createMetadata`, `updateMetadata`, `upsertMetadata`,
55/// `readMetadata`, and `deleteMetadata`.
56pub const MAX_CRUD_COMPONENTS_PER_CALL: usize = 10;
57
58// ---------------------------------------------------------------------------
59// Shared helpers
60// ---------------------------------------------------------------------------
61
62/// Render `<met:metadata xsi:type="met:{TYPE}" xmlns="...">CHILDREN</met:metadata>`
63/// for each caller-supplied component. The default-namespace declaration
64/// on the wrapper means callers' children don't need a `met:` prefix.
65fn render_metadata_components<S: AsRef<str>>(type_name: &str, components: &[S], out: &mut String) {
66    for component in components {
67        out.push_str(r#"<met:metadata xsi:type="met:"#);
68        out.push_str(&xml_escape(type_name));
69        // Declare the metadata namespace as default *within* the
70        // wrapper. Caller-written children without a prefix end up
71        // in the metadata namespace, which is what the server
72        // expects.
73        out.push_str(r#"" xmlns="http://soap.sforce.com/2006/04/metadata">"#);
74        out.push_str(component.as_ref());
75        out.push_str("</met:metadata>");
76    }
77}
78
79/// Render `<met:type>X</met:type><met:fullNames>...</met:fullNames>...` —
80/// the shared body shape for `readMetadata` and `deleteMetadata`.
81fn render_type_and_full_names<S: AsRef<str>>(type_name: &str, full_names: &[S], out: &mut String) {
82    out.push_str("<met:type>");
83    out.push_str(&xml_escape(type_name));
84    out.push_str("</met:type>");
85    for name in full_names {
86        out.push_str("<met:fullNames>");
87        out.push_str(&xml_escape(name.as_ref()));
88        out.push_str("</met:fullNames>");
89    }
90}
91
92fn check_component_cap(count: usize, op_label: &str) -> MetadataResult<()> {
93    if count == 0 {
94        return Err(MetadataError::InvalidArgument(format!(
95            "{op_label} requires at least one component; got 0"
96        )));
97    }
98    if count > MAX_CRUD_COMPONENTS_PER_CALL {
99        return Err(MetadataError::InvalidArgument(format!(
100            "{op_label} accepts at most {MAX_CRUD_COMPONENTS_PER_CALL} components per call; \
101             got {count}"
102        )));
103    }
104    Ok(())
105}
106
107// ---------------------------------------------------------------------------
108// Operations
109// ---------------------------------------------------------------------------
110
111struct CreateMetadataOp<'a, S: AsRef<str>> {
112    type_name: &'a str,
113    components: &'a [S],
114}
115
116#[derive(Deserialize)]
117struct SaveResultsWire {
118    #[serde(default, rename = "result")]
119    results: Vec<SaveResult>,
120}
121
122impl<S: AsRef<str>> SoapOperation for CreateMetadataOp<'_, S> {
123    const NAME: &'static str = "createMetadata";
124    type Response = SaveResultsWire;
125
126    fn render_body(&self) -> MetadataResult<String> {
127        let mut out = String::with_capacity(self.components.len() * 256);
128        render_metadata_components(self.type_name, self.components, &mut out);
129        Ok(out)
130    }
131}
132
133struct UpdateMetadataOp<'a, S: AsRef<str>> {
134    type_name: &'a str,
135    components: &'a [S],
136}
137
138impl<S: AsRef<str>> SoapOperation for UpdateMetadataOp<'_, S> {
139    const NAME: &'static str = "updateMetadata";
140    type Response = SaveResultsWire;
141
142    fn render_body(&self) -> MetadataResult<String> {
143        let mut out = String::with_capacity(self.components.len() * 256);
144        render_metadata_components(self.type_name, self.components, &mut out);
145        Ok(out)
146    }
147}
148
149struct UpsertMetadataOp<'a, S: AsRef<str>> {
150    type_name: &'a str,
151    components: &'a [S],
152}
153
154#[derive(Deserialize)]
155struct UpsertResultsWire {
156    #[serde(default, rename = "result")]
157    results: Vec<UpsertResult>,
158}
159
160impl<S: AsRef<str>> SoapOperation for UpsertMetadataOp<'_, S> {
161    const NAME: &'static str = "upsertMetadata";
162    type Response = UpsertResultsWire;
163
164    fn render_body(&self) -> MetadataResult<String> {
165        let mut out = String::with_capacity(self.components.len() * 256);
166        render_metadata_components(self.type_name, self.components, &mut out);
167        Ok(out)
168    }
169}
170
171struct DeleteMetadataOp<'a, S: AsRef<str>> {
172    type_name: &'a str,
173    full_names: &'a [S],
174}
175
176#[derive(Deserialize)]
177struct DeleteResultsWire {
178    #[serde(default, rename = "result")]
179    results: Vec<DeleteResult>,
180}
181
182impl<S: AsRef<str>> SoapOperation for DeleteMetadataOp<'_, S> {
183    const NAME: &'static str = "deleteMetadata";
184    type Response = DeleteResultsWire;
185
186    fn render_body(&self) -> MetadataResult<String> {
187        let mut out = String::with_capacity(64 + self.full_names.len() * 64);
188        render_type_and_full_names(self.type_name, self.full_names, &mut out);
189        Ok(out)
190    }
191}
192
193struct ReadMetadataOp<'a, T, S: AsRef<str>> {
194    type_name: &'a str,
195    full_names: &'a [S],
196    _marker: PhantomData<fn() -> T>,
197}
198
199#[derive(Deserialize)]
200#[serde(bound(deserialize = "T: serde::de::DeserializeOwned"))]
201struct ReadMetadataResponseWire<T> {
202    result: ReadResultWire<T>,
203}
204
205#[derive(Deserialize)]
206// Without an explicit deserialize bound, serde adds `T: Default` because
207// of `#[serde(default)]` on `records`. That bound bleeds into callers
208// who only need `Deserialize`. Pin the bound to just deserialization —
209// `Vec<T>::default()` works for any `T` regardless.
210#[serde(bound(deserialize = "T: serde::de::DeserializeOwned"))]
211struct ReadResultWire<T> {
212    #[serde(default = "Vec::new")]
213    records: Vec<T>,
214}
215
216impl<T, S> SoapOperation for ReadMetadataOp<'_, T, S>
217where
218    T: DeserializeOwned,
219    S: AsRef<str>,
220{
221    const NAME: &'static str = "readMetadata";
222    type Response = ReadMetadataResponseWire<T>;
223
224    fn render_body(&self) -> MetadataResult<String> {
225        let mut out = String::with_capacity(64 + self.full_names.len() * 64);
226        render_type_and_full_names(self.type_name, self.full_names, &mut out);
227        Ok(out)
228    }
229}
230
231struct RenameMetadataOp<'a> {
232    type_name: &'a str,
233    old_full_name: &'a str,
234    new_full_name: &'a str,
235}
236
237#[derive(Deserialize)]
238struct RenameMetadataResponseWire {
239    result: SaveResult,
240}
241
242impl SoapOperation for RenameMetadataOp<'_> {
243    const NAME: &'static str = "renameMetadata";
244    type Response = RenameMetadataResponseWire;
245
246    fn render_body(&self) -> MetadataResult<String> {
247        Ok(format!(
248            "<met:type>{}</met:type>\
249             <met:oldFullName>{}</met:oldFullName>\
250             <met:newFullName>{}</met:newFullName>",
251            xml_escape(self.type_name),
252            xml_escape(self.old_full_name),
253            xml_escape(self.new_full_name),
254        ))
255    }
256}
257
258// ---------------------------------------------------------------------------
259// Public API on MetadataClient
260// ---------------------------------------------------------------------------
261
262impl MetadataClient {
263    /// Create one or more metadata components synchronously.
264    ///
265    /// All components must be of the same `type_name`. Each entry in
266    /// `components` is the inner XML of one `<metadata>` element — the
267    /// SDK wraps each in `<metadata xsi:type="met:{type_name}">…</metadata>`
268    /// and handles the SOAP envelope. Inside the wrapper, the
269    /// metadata namespace is the default, so caller XML can use bare
270    /// element names like `<fullName>Foo</fullName>`.
271    ///
272    /// ```no_run
273    /// # use cirrus_metadata::{MetadataClient, SaveResult, MetadataError};
274    /// # async fn example(md: &MetadataClient) -> Result<(), MetadataError> {
275    /// let class = r#"
276    ///     <fullName>MyClass</fullName>
277    ///     <apiVersion>66.0</apiVersion>
278    ///     <status>Active</status>
279    ///     <content>cHVibGljIGNsYXNzIE15Q2xhc3Mge30=</content>
280    /// "#;
281    /// let results: Vec<SaveResult> = md.create_metadata("ApexClass", &[class]).await?;
282    /// for r in &results {
283    ///     assert!(r.success, "create failed: {:?}", r.errors);
284    /// }
285    /// # Ok(())
286    /// # }
287    /// ```
288    ///
289    /// Returns one [`SaveResult`] per component. Partial success is
290    /// possible — inspect each entry's `success` field and per-entry
291    /// `errors`.
292    pub async fn create_metadata<S: AsRef<str>>(
293        &self,
294        type_name: &str,
295        components: &[S],
296    ) -> MetadataResult<Vec<SaveResult>> {
297        check_component_cap(components.len(), "create_metadata")?;
298        let op = CreateMetadataOp {
299            type_name,
300            components,
301        };
302        let resp = self.call(&op).await?;
303        Ok(resp.results)
304    }
305
306    /// Update one or more existing metadata components.
307    ///
308    /// Same input shape as [`Self::create_metadata`] — each
309    /// component's `<fullName>` identifies which existing component
310    /// to update. Returns one [`SaveResult`] per component.
311    pub async fn update_metadata<S: AsRef<str>>(
312        &self,
313        type_name: &str,
314        components: &[S],
315    ) -> MetadataResult<Vec<SaveResult>> {
316        check_component_cap(components.len(), "update_metadata")?;
317        let op = UpdateMetadataOp {
318            type_name,
319            components,
320        };
321        let resp = self.call(&op).await?;
322        Ok(resp.results)
323    }
324
325    /// Create or update one or more metadata components.
326    ///
327    /// Same input shape as [`Self::create_metadata`]. The returned
328    /// [`UpsertResult::created`] flag distinguishes per-component
329    /// inserts (`true`) from updates (`false`). Available in
330    /// API v31+.
331    pub async fn upsert_metadata<S: AsRef<str>>(
332        &self,
333        type_name: &str,
334        components: &[S],
335    ) -> MetadataResult<Vec<UpsertResult>> {
336        check_component_cap(components.len(), "upsert_metadata")?;
337        let op = UpsertMetadataOp {
338            type_name,
339            components,
340        };
341        let resp = self.call(&op).await?;
342        Ok(resp.results)
343    }
344
345    /// Delete one or more metadata components.
346    ///
347    /// Returns one [`DeleteResult`] per `full_names` entry. Partial
348    /// success is possible — inspect each entry.
349    pub async fn delete_metadata<S: AsRef<str>>(
350        &self,
351        type_name: &str,
352        full_names: &[S],
353    ) -> MetadataResult<Vec<DeleteResult>> {
354        check_component_cap(full_names.len(), "delete_metadata")?;
355        let op = DeleteMetadataOp {
356            type_name,
357            full_names,
358        };
359        let resp = self.call(&op).await?;
360        Ok(resp.results)
361    }
362
363    /// Read one or more metadata components synchronously.
364    ///
365    /// The caller supplies a typed `T: Deserialize` shape that maps
366    /// over one `<records>` element. Component XML uses the metadata
367    /// namespace as default on the wire, so quick-xml's serde
368    /// deserialize sees field names like `fullName`, `apiVersion`,
369    /// `status`, etc.
370    ///
371    /// ```no_run
372    /// # use cirrus_metadata::{MetadataClient, MetadataError};
373    /// # use serde::Deserialize;
374    /// #[derive(Deserialize)]
375    /// #[serde(rename_all = "camelCase")]
376    /// struct ApexClassRecord {
377    ///     full_name: String,
378    ///     api_version: Option<String>,
379    ///     status: Option<String>,
380    ///     content: Option<String>,
381    /// }
382    ///
383    /// # async fn example(md: &MetadataClient) -> Result<(), MetadataError> {
384    /// let classes: Vec<ApexClassRecord> = md
385    ///     .read_metadata::<ApexClassRecord, _>("ApexClass", &["Foo", "Bar"])
386    ///     .await?;
387    /// # Ok(())
388    /// # }
389    /// ```
390    pub async fn read_metadata<T, S>(
391        &self,
392        type_name: &str,
393        full_names: &[S],
394    ) -> MetadataResult<Vec<T>>
395    where
396        T: DeserializeOwned,
397        S: AsRef<str>,
398    {
399        check_component_cap(full_names.len(), "read_metadata")?;
400        let op = ReadMetadataOp::<T, S> {
401            type_name,
402            full_names,
403            _marker: PhantomData,
404        };
405        let resp = self.call(&op).await?;
406        Ok(resp.result.records)
407    }
408
409    /// Rename a single metadata component.
410    ///
411    /// Returns a single [`SaveResult`] — unlike the array-returning
412    /// CRUD calls, `renameMetadata` takes one component at a time.
413    pub async fn rename_metadata(
414        &self,
415        type_name: &str,
416        old_full_name: &str,
417        new_full_name: &str,
418    ) -> MetadataResult<SaveResult> {
419        let op = RenameMetadataOp {
420            type_name,
421            old_full_name,
422            new_full_name,
423        };
424        let resp = self.call(&op).await?;
425        Ok(resp.result)
426    }
427}
428
429#[cfg(test)]
430#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn create_op_wraps_components_with_xsi_type_and_default_ns() {
436        let op = CreateMetadataOp {
437            type_name: "ApexClass",
438            components: &["<fullName>Foo</fullName>"],
439        };
440        let body = op.render_body().unwrap();
441        assert!(body.contains(r#"<met:metadata xsi:type="met:ApexClass""#));
442        assert!(body.contains(r#"xmlns="http://soap.sforce.com/2006/04/metadata""#));
443        assert!(body.contains("<fullName>Foo</fullName>"));
444        assert!(body.contains("</met:metadata>"));
445    }
446
447    #[test]
448    fn create_op_emits_one_wrapper_per_component() {
449        let op = CreateMetadataOp {
450            type_name: "ApexClass",
451            components: &["<fullName>A</fullName>", "<fullName>B</fullName>"],
452        };
453        let body = op.render_body().unwrap();
454        assert_eq!(
455            body.matches(r#"<met:metadata xsi:type="met:ApexClass""#)
456                .count(),
457            2
458        );
459        assert_eq!(body.matches("</met:metadata>").count(), 2);
460    }
461
462    #[test]
463    fn read_op_emits_type_and_full_names() {
464        // Dummy stand-in for T — only renders body XML, doesn't
465        // exercise deserialization.
466        #[derive(Deserialize)]
467        struct Empty {}
468        let op = ReadMetadataOp::<Empty, _> {
469            type_name: "ApexClass",
470            full_names: &["Foo", "Bar"],
471            _marker: PhantomData,
472        };
473        let body = op.render_body().unwrap();
474        assert_eq!(
475            body,
476            "<met:type>ApexClass</met:type>\
477             <met:fullNames>Foo</met:fullNames>\
478             <met:fullNames>Bar</met:fullNames>"
479        );
480    }
481
482    #[test]
483    fn delete_op_shares_body_shape_with_read() {
484        let op = DeleteMetadataOp {
485            type_name: "ApexTrigger",
486            full_names: &["AccountTrigger"],
487        };
488        let body = op.render_body().unwrap();
489        assert_eq!(
490            body,
491            "<met:type>ApexTrigger</met:type>\
492             <met:fullNames>AccountTrigger</met:fullNames>"
493        );
494    }
495
496    #[test]
497    fn rename_op_emits_type_and_both_full_names() {
498        let op = RenameMetadataOp {
499            type_name: "ApexClass",
500            old_full_name: "OldName",
501            new_full_name: "NewName",
502        };
503        let body = op.render_body().unwrap();
504        assert_eq!(
505            body,
506            "<met:type>ApexClass</met:type>\
507             <met:oldFullName>OldName</met:oldFullName>\
508             <met:newFullName>NewName</met:newFullName>"
509        );
510    }
511
512    #[test]
513    fn render_escapes_special_chars_in_type_and_names() {
514        let op = DeleteMetadataOp {
515            type_name: "Weird<>",
516            full_names: &["a&b"],
517        };
518        let body = op.render_body().unwrap();
519        assert!(body.contains("<met:type>Weird&lt;&gt;</met:type>"));
520        assert!(body.contains("<met:fullNames>a&amp;b</met:fullNames>"));
521    }
522
523    #[test]
524    fn check_component_cap_rejects_empty_input() {
525        let err = check_component_cap(0, "create_metadata").unwrap_err();
526        assert!(err.to_string().contains("at least one"));
527    }
528
529    #[test]
530    fn check_component_cap_rejects_more_than_ten() {
531        let err = check_component_cap(11, "delete_metadata").unwrap_err();
532        let msg = err.to_string();
533        assert!(msg.contains("10"));
534        assert!(msg.contains("11"));
535    }
536
537    #[test]
538    fn check_component_cap_accepts_one_to_ten() {
539        for n in 1..=10 {
540            assert!(check_component_cap(n, "x").is_ok(), "should accept {n}");
541        }
542    }
543}