Skip to main content

cirrus_metadata/handlers/
utility.rs

1//! Utility (synchronous) Metadata API handlers.
2//!
3//! These calls return immediately — no polling, no zip files. They're
4//! the discovery layer:
5//!
6//! - [`MetadataClient::list_metadata`] — enumerate components of a
7//!   given type, useful for building a `package.xml` for a subsequent
8//!   `retrieve` call.
9//! - [`MetadataClient::describe_metadata`] — catalog every metadata
10//!   type the org supports plus its directory/suffix conventions.
11//! - [`MetadataClient::describe_value_type`] — schema for one specific
12//!   metadata type (field list, foreign keys, picklist options).
13
14use crate::MetadataClient;
15use crate::envelope::xml_escape;
16use crate::error::{MetadataError, MetadataResult};
17use crate::result::{
18    DescribeMetadataResult, DescribeValueTypeResult, FileProperties, ListMetadataQuery,
19};
20use crate::transport::SoapOperation;
21use serde::Deserialize;
22
23/// Salesforce server limit — at most 3 [`ListMetadataQuery`] entries
24/// per `listMetadata` call.
25pub const MAX_LIST_METADATA_QUERIES_PER_CALL: usize = 3;
26
27// ---------------------------------------------------------------------------
28// Operations
29// ---------------------------------------------------------------------------
30
31struct ListMetadataOp<'a> {
32    queries: Vec<ListMetadataQuery>,
33    as_of_version: &'a str,
34}
35
36/// Wire wrapper for `<listMetadataResponse>...</listMetadataResponse>`.
37///
38/// The response has zero or more `<result>` siblings, one per matching
39/// component. `#[serde(rename = "result")]` on a `Vec` picks them all up.
40#[derive(Deserialize)]
41struct ListMetadataResponseWire {
42    #[serde(default, rename = "result")]
43    results: Vec<FileProperties>,
44}
45
46impl SoapOperation for ListMetadataOp<'_> {
47    const NAME: &'static str = "listMetadata";
48    type Response = ListMetadataResponseWire;
49
50    fn render_body(&self) -> MetadataResult<String> {
51        let mut out = String::with_capacity(64 + self.queries.len() * 64);
52        for q in &self.queries {
53            out.push_str("<met:queries>");
54            if let Some(folder) = &q.folder {
55                out.push_str("<met:folder>");
56                out.push_str(&xml_escape(folder));
57                out.push_str("</met:folder>");
58            }
59            out.push_str("<met:type>");
60            out.push_str(&xml_escape(&q.type_name));
61            out.push_str("</met:type>");
62            out.push_str("</met:queries>");
63        }
64        // Salesforce parses asOfVersion as XSD double; "66.0" is the
65        // canonical form. We accept any string the caller provides and
66        // let the server validate.
67        out.push_str("<met:asOfVersion>");
68        out.push_str(&xml_escape(self.as_of_version));
69        out.push_str("</met:asOfVersion>");
70        Ok(out)
71    }
72}
73
74struct DescribeMetadataOp<'a> {
75    as_of_version: &'a str,
76}
77
78#[derive(Deserialize)]
79struct DescribeMetadataResponseWire {
80    result: DescribeMetadataResult,
81}
82
83impl SoapOperation for DescribeMetadataOp<'_> {
84    const NAME: &'static str = "describeMetadata";
85    type Response = DescribeMetadataResponseWire;
86
87    fn render_body(&self) -> MetadataResult<String> {
88        Ok(format!(
89            "<met:asOfVersion>{}</met:asOfVersion>",
90            xml_escape(self.as_of_version),
91        ))
92    }
93}
94
95struct DescribeValueTypeOp {
96    type_name: String,
97}
98
99#[derive(Deserialize)]
100struct DescribeValueTypeResponseWire {
101    result: DescribeValueTypeResult,
102}
103
104impl SoapOperation for DescribeValueTypeOp {
105    const NAME: &'static str = "describeValueType";
106    type Response = DescribeValueTypeResponseWire;
107
108    fn render_body(&self) -> MetadataResult<String> {
109        Ok(format!(
110            "<met:type>{}</met:type>",
111            xml_escape(&self.type_name),
112        ))
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Public API on MetadataClient
118// ---------------------------------------------------------------------------
119
120impl MetadataClient {
121    /// Enumerate metadata components of one or more types.
122    ///
123    /// Each [`ListMetadataQuery`] picks one metadata type (and
124    /// optionally a folder for folder-based types). The Salesforce
125    /// server limits this to **3 queries per call** — pass more and
126    /// we return [`MetadataError::InvalidArgument`] before hitting the
127    /// wire. For broader enumeration, batch into multiple calls.
128    ///
129    /// `as_of_version` controls the API version used to evaluate the
130    /// queries (e.g. `"66.0"`); this matters when types/fields are added
131    /// or removed across releases. Pass [`Self::api_version`] to use the
132    /// client's configured version.
133    ///
134    /// Returns `Vec<FileProperties>` — one entry per matched
135    /// component. Empty when nothing matches.
136    pub async fn list_metadata(
137        &self,
138        queries: Vec<ListMetadataQuery>,
139        as_of_version: &str,
140    ) -> MetadataResult<Vec<FileProperties>> {
141        if queries.len() > MAX_LIST_METADATA_QUERIES_PER_CALL {
142            return Err(MetadataError::InvalidArgument(format!(
143                "listMetadata accepts at most {MAX_LIST_METADATA_QUERIES_PER_CALL} queries per \
144                 call; got {}",
145                queries.len()
146            )));
147        }
148        let op = ListMetadataOp {
149            queries,
150            as_of_version,
151        };
152        let resp = self.call(&op).await?;
153        Ok(resp.results)
154    }
155
156    /// Catalog the metadata types this org supports.
157    ///
158    /// Returns one [`DescribeMetadataObject`] per type, with the
159    /// directory name, file suffix, and child-type relationships.
160    /// This is the canonical source for `package.xml` `<types><name>`
161    /// values and for the zip directory layout — handlers building
162    /// deploy zips should reference this rather than hard-coding the
163    /// list.
164    ///
165    /// `as_of_version` is the API version of the catalog (e.g. `"66.0"`).
166    /// Pass the version your tooling targets so newly-added types in
167    /// newer versions don't surface unexpectedly.
168    ///
169    /// [`DescribeMetadataObject`]: crate::result::DescribeMetadataObject
170    pub async fn describe_metadata(
171        &self,
172        as_of_version: &str,
173    ) -> MetadataResult<DescribeMetadataResult> {
174        let op = DescribeMetadataOp { as_of_version };
175        let resp = self.call(&op).await?;
176        Ok(resp.result)
177    }
178
179    /// Describe the schema of one specific metadata type.
180    ///
181    /// `qualified_type_name` is the SOAP-namespace-qualified type
182    /// name. For the standard Metadata API namespace, that's
183    /// `"{http://soap.sforce.com/2006/04/metadata}ApexClass"` (or any
184    /// other type). The Tooling API uses
185    /// `"{urn:metadata.tooling.soap.sforce.com}<Type>"`.
186    ///
187    /// Returns field-level metadata — types, requirement flags,
188    /// foreign-key relationships, picklist options. Useful for
189    /// validating component XML before deploy or for generating
190    /// typed bindings.
191    pub async fn describe_value_type(
192        &self,
193        qualified_type_name: &str,
194    ) -> MetadataResult<DescribeValueTypeResult> {
195        let op = DescribeValueTypeOp {
196            type_name: qualified_type_name.to_string(),
197        };
198        let resp = self.call(&op).await?;
199        Ok(resp.result)
200    }
201}
202
203#[cfg(test)]
204#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn list_metadata_op_emits_queries_and_version() {
210        let op = ListMetadataOp {
211            queries: vec![
212                ListMetadataQuery {
213                    type_name: "ApexClass".into(),
214                    folder: None,
215                },
216                ListMetadataQuery {
217                    type_name: "Dashboard".into(),
218                    folder: Some("SharedDashboards".into()),
219                },
220            ],
221            as_of_version: "66.0",
222        };
223        let body = op.render_body().unwrap();
224        assert!(body.contains("<met:queries><met:type>ApexClass</met:type></met:queries>"));
225        assert!(body.contains(
226            "<met:queries><met:folder>SharedDashboards</met:folder><met:type>Dashboard</met:type></met:queries>"
227        ));
228        assert!(body.contains("<met:asOfVersion>66.0</met:asOfVersion>"));
229    }
230
231    #[test]
232    fn list_metadata_op_escapes_folder_and_type() {
233        let op = ListMetadataOp {
234            queries: vec![ListMetadataQuery {
235                type_name: "Type<>".into(),
236                folder: Some("Folder&Co".into()),
237            }],
238            as_of_version: "66.0",
239        };
240        let body = op.render_body().unwrap();
241        assert!(body.contains("<met:folder>Folder&amp;Co</met:folder>"));
242        assert!(body.contains("<met:type>Type&lt;&gt;</met:type>"));
243    }
244
245    #[test]
246    fn describe_metadata_op_emits_version() {
247        let op = DescribeMetadataOp {
248            as_of_version: "58.0",
249        };
250        let body = op.render_body().unwrap();
251        assert_eq!(body, "<met:asOfVersion>58.0</met:asOfVersion>");
252    }
253
254    #[test]
255    fn describe_value_type_emits_qualified_type() {
256        let op = DescribeValueTypeOp {
257            type_name: "{http://soap.sforce.com/2006/04/metadata}ApexClass".into(),
258        };
259        let body = op.render_body().unwrap();
260        // Curly braces aren't XML-reserved, so they pass through
261        // unescaped — sanity-check that.
262        assert_eq!(
263            body,
264            "<met:type>{http://soap.sforce.com/2006/04/metadata}ApexClass</met:type>"
265        );
266    }
267}