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