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