cirrus_metadata/lib.rs
1//! # `cirrus-metadata`
2//!
3//! A Rust client for the Salesforce **Metadata API** (SOAP). Built on top of
4//! [`cirrus_auth`] for credentials, so any `AuthSession` configured for the
5//! REST client (`cirrus`) works here too.
6//!
7//! Covered surface: the file-based deploy/retrieve flow, synchronous CRUD
8//! (`createMetadata` / `readMetadata` / `updateMetadata` /
9//! `upsertMetadata` / `deleteMetadata` / `renameMetadata`), the utility
10//! surface (`listMetadata` / `describeMetadata` / `describeValueType`),
11//! and a typed [`PackageManifest`] builder.
12//!
13//! ## Why SOAP?
14//!
15//! The Metadata API's REST surface only covers four `deployRequest`
16//! endpoints. Everything else — `retrieve`, `listMetadata`,
17//! `describeMetadata`, `createMetadata`, `readMetadata`, etc. — is
18//! SOAP-only. SOAP is not deprecated; it's the canonical surface.
19//!
20//! ## Design principles
21//!
22//! - **No user-facing types.** The 200+ concrete metadata types
23//! (`CustomObject`, `ApexClass`, …) are caller-supplied XML or
24//! `serde_json::Value`. Only platform-contract envelopes are typed.
25//! - **No legacy surface.** Operations Salesforce labels deprecated
26//! (`create()`, `update()`, `delete()` pre-API-31) are not exposed.
27//! - **Auth is pluggable.** Any [`cirrus_auth::AuthSession`] works.
28//! - **Same credentials as `cirrus`.** Both crates wrap the same
29//! `AuthSession` trait; one [`SharedAuth`] drives both clients.
30//!
31//! ## Quick start
32//!
33//! ```no_run
34//! use cirrus_metadata::{MetadataClient, auth::StaticTokenAuth};
35//! use std::sync::Arc;
36//!
37//! # async fn example() -> Result<(), cirrus_metadata::MetadataError> {
38//! let auth = Arc::new(StaticTokenAuth::new(
39//! "00D...!AQ...",
40//! "https://my-org.my.salesforce.com",
41//! ));
42//!
43//! let md = MetadataClient::builder()
44//! .auth(auth)
45//! .build()?;
46//!
47//! # let _ = md;
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! [`SharedAuth`]: cirrus_auth::SharedAuth
53
54mod envelope;
55mod error;
56pub mod handlers;
57mod package_manifest;
58pub mod result;
59pub mod retry;
60mod transport;
61
62/// Re-export of the [`cirrus_auth`] crate as `cirrus_metadata::auth`.
63///
64/// Users who add `cirrus-metadata` without `cirrus` get the auth flows
65/// transparently. The re-exported types are byte-identical to
66/// `cirrus::auth::*` since both crates re-export the same source.
67pub use cirrus_auth as auth;
68
69pub use auth::{AuthError, AuthSession, SharedAuth};
70pub use error::{MetadataError, MetadataResult, SoapFault};
71pub use handlers::file_based::WaitConfig;
72pub use package_manifest::{MetadataType, PackageManifest};
73pub use result::{
74 AsyncRequestState, AsyncResult, CancelDeployResult, CodeCoverageResult, CodeCoverageWarning,
75 DeleteResult, DeployDetails, DeployMessage, DeployOptions, DeployProblemType, DeployResult,
76 DeployStatus, DescribeMetadataObject, DescribeMetadataResult, DescribeValueTypeResult,
77 FileProperties, ListMetadataQuery, ManageableState, MetadataApiError, PicklistEntry,
78 RetrieveMessage, RetrieveRequest, RetrieveResult, RetrieveStatus, RunTestFailure,
79 RunTestSuccess, RunTestsResult, SaveResult, TestLevel, UpsertResult, ValueTypeField,
80};
81pub use retry::RetryPolicy;
82pub use transport::SoapOperation;
83
84/// Default Metadata API version when the caller doesn't override it.
85///
86/// SOAP endpoint paths use bare version numbers without the `v` prefix
87/// (`/services/Soap/m/66.0`).
88pub const DEFAULT_API_VERSION: &str = "66.0";
89
90/// Default User-Agent header sent on every request.
91pub(crate) const DEFAULT_USER_AGENT: &str = concat!(
92 "cirrus-metadata/",
93 env!("CARGO_PKG_VERSION"),
94 " (Rust SDK for Salesforce Metadata API)"
95);
96
97use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
98
99/// The Metadata API client.
100///
101/// Holds an HTTP client, an [`AuthSession`] for credentials, the API
102/// version to target, and a [`RetryPolicy`] for transient-failure
103/// handling. Cheap to clone — the auth session is `Arc`-shared and the
104/// HTTP client is internally reference-counted.
105#[derive(Clone)]
106pub struct MetadataClient {
107 pub(crate) http: reqwest::Client,
108 pub(crate) auth: SharedAuth,
109 pub(crate) api_version: String,
110 pub(crate) retry_policy: RetryPolicy,
111}
112
113impl std::fmt::Debug for MetadataClient {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 // Mirror cirrus::Cirrus: omit `auth` (may carry secrets) and
116 // the reqwest client (no useful Debug).
117 f.debug_struct("MetadataClient")
118 .field("api_version", &self.api_version)
119 .field("instance_url", &self.auth.instance_url())
120 .field("retry_policy", &self.retry_policy)
121 .finish_non_exhaustive()
122 }
123}
124
125impl MetadataClient {
126 /// Returns a builder for constructing a [`MetadataClient`].
127 pub fn builder() -> MetadataClientBuilder {
128 MetadataClientBuilder::default()
129 }
130
131 /// Returns the configured Metadata API version (e.g. `"66.0"`).
132 pub fn api_version(&self) -> &str {
133 &self.api_version
134 }
135
136 /// Returns a reference to the underlying `reqwest` client. Useful
137 /// for callers who want to compose additional requests against the
138 /// same connection pool.
139 pub fn http_client(&self) -> &reqwest::Client {
140 &self.http
141 }
142
143 /// Returns the auth session backing this client.
144 pub fn auth(&self) -> &SharedAuth {
145 &self.auth
146 }
147
148 /// Returns the configured retry policy.
149 pub fn retry_policy(&self) -> &RetryPolicy {
150 &self.retry_policy
151 }
152
153 /// Returns the fully-resolved SOAP endpoint URL for this client,
154 /// e.g. `https://my-org.my.salesforce.com/services/Soap/m/66.0`.
155 ///
156 /// The instance URL is read from the configured [`AuthSession`] on
157 /// every call, so it reflects the *current* session — relevant for
158 /// flows that can change instance URL on refresh (e.g. some token
159 /// exchange scenarios).
160 pub fn endpoint_url(&self) -> String {
161 format!(
162 "{}/services/Soap/m/{}",
163 self.auth.instance_url(),
164 self.api_version
165 )
166 }
167
168 /// Returns a pre-configured `reqwest::RequestBuilder` for the SOAP
169 /// endpoint, with `Content-Type` and `SOAPAction` already set.
170 ///
171 /// The bearer token is **not** injected — the Metadata API expects
172 /// it inside the envelope's `<SessionHeader>`, not on the
173 /// `Authorization` header. Fetch it via
174 /// `client.auth().access_token().await?` and splice it into your
175 /// envelope.
176 ///
177 /// Use this only when you need to bypass the typed
178 /// [`SoapOperation`] path entirely (e.g. to record raw traffic).
179 pub fn request_builder(&self) -> reqwest::RequestBuilder {
180 self.http
181 .post(self.endpoint_url())
182 .header(reqwest::header::CONTENT_TYPE, "text/xml; charset=UTF-8")
183 .header("SOAPAction", "\"\"")
184 }
185
186 /// Dispatch a typed SOAP operation.
187 ///
188 /// This is the entry point handlers use; it builds the envelope,
189 /// POSTs, retries transient failures per the configured
190 /// [`RetryPolicy`], refreshes the auth token on
191 /// `INVALID_SESSION_ID` faults, and deserializes the response into
192 /// `O::Response`. Returns [`MetadataError::Soap`] for server-side
193 /// faults and [`MetadataError::Http`] / [`MetadataError::Http4xx5xx`]
194 /// for transport-level failures.
195 pub async fn call<O: SoapOperation>(&self, op: &O) -> MetadataResult<O::Response> {
196 transport::soap_call(self, op).await
197 }
198}
199
200/// Builder for [`MetadataClient`].
201///
202/// Required: an [`AuthSession`] via [`auth`](Self::auth). Everything
203/// else has a sensible default.
204#[derive(Default)]
205pub struct MetadataClientBuilder {
206 auth: Option<SharedAuth>,
207 api_version: Option<String>,
208 user_agent: Option<String>,
209 http_client: Option<reqwest::Client>,
210 retry_policy: Option<RetryPolicy>,
211}
212
213impl MetadataClientBuilder {
214 /// Sets the auth session (any [`AuthSession`] wrapped in `Arc`).
215 /// Required.
216 pub fn auth(mut self, auth: SharedAuth) -> Self {
217 self.auth = Some(auth);
218 self
219 }
220
221 /// Sets the Metadata API version, e.g. `"66.0"`. Defaults to
222 /// [`DEFAULT_API_VERSION`]. Note: SOAP endpoint paths use the bare
223 /// number without a `v` prefix.
224 pub fn api_version(mut self, version: impl Into<String>) -> Self {
225 self.api_version = Some(version.into());
226 self
227 }
228
229 /// Overrides the default User-Agent header. Ignored if
230 /// [`http_client`](Self::http_client) is set.
231 pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
232 self.user_agent = Some(ua.into());
233 self
234 }
235
236 /// Supplies a pre-configured `reqwest::Client`. Useful for sharing
237 /// a connection pool across multiple SDK clients or for installing
238 /// custom middleware. When provided, the builder's `user_agent`
239 /// setting is ignored — configure that on the supplied client.
240 pub fn http_client(mut self, client: reqwest::Client) -> Self {
241 self.http_client = Some(client);
242 self
243 }
244
245 /// Sets the [`RetryPolicy`] for transient-failure handling.
246 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
247 self.retry_policy = Some(policy);
248 self
249 }
250
251 /// Finalizes the builder.
252 pub fn build(self) -> MetadataResult<MetadataClient> {
253 let auth = self.auth.ok_or(MetadataError::MissingField("auth"))?;
254
255 let http = if let Some(c) = self.http_client {
256 c
257 } else {
258 let ua = self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);
259 let mut headers = HeaderMap::new();
260 headers.insert(
261 USER_AGENT,
262 HeaderValue::from_str(ua)
263 .map_err(|e| MetadataError::InvalidHeader(e.to_string()))?,
264 );
265 reqwest::Client::builder()
266 .default_headers(headers)
267 .build()
268 .map_err(MetadataError::HttpClient)?
269 };
270
271 Ok(MetadataClient {
272 http,
273 auth,
274 api_version: self
275 .api_version
276 .unwrap_or_else(|| DEFAULT_API_VERSION.to_string()),
277 retry_policy: self.retry_policy.unwrap_or_default(),
278 })
279 }
280}
281
282#[cfg(test)]
283#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
284mod tests {
285 use super::*;
286 use std::sync::Arc;
287
288 #[test]
289 fn builder_requires_auth() {
290 let err = MetadataClient::builder().build().unwrap_err();
291 assert!(matches!(err, MetadataError::MissingField("auth")));
292 }
293
294 #[test]
295 fn endpoint_url_uses_bare_version_no_v_prefix() {
296 let auth = Arc::new(auth::StaticTokenAuth::new(
297 "tok",
298 "https://my-org.my.salesforce.com",
299 ));
300 let md = MetadataClient::builder().auth(auth).build().unwrap();
301 assert_eq!(
302 md.endpoint_url(),
303 "https://my-org.my.salesforce.com/services/Soap/m/66.0"
304 );
305 }
306
307 #[test]
308 fn endpoint_url_honors_custom_api_version() {
309 let auth = Arc::new(auth::StaticTokenAuth::new("tok", "https://x.example.com"));
310 let md = MetadataClient::builder()
311 .auth(auth)
312 .api_version("58.0")
313 .build()
314 .unwrap();
315 assert!(md.endpoint_url().ends_with("/services/Soap/m/58.0"));
316 }
317
318 #[test]
319 fn debug_redacts_auth_and_client() {
320 let auth = Arc::new(auth::StaticTokenAuth::new(
321 "secret-token",
322 "https://x.example.com",
323 ));
324 let md = MetadataClient::builder().auth(auth).build().unwrap();
325 let dbg = format!("{md:?}");
326 assert!(!dbg.contains("secret-token"));
327 }
328}