Skip to main content

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
69/// Re-export of [`reqwest`].
70///
71/// Several public APIs accept or return `reqwest` types
72/// ([`MetadataClient::request_builder`],
73/// [`MetadataClientBuilder::http_client`]). Using this re-export
74/// instead of a separate `reqwest` dependency keeps the caller's
75/// `reqwest` version aligned with the SDK's.
76pub use reqwest;
77
78pub use auth::{AuthError, AuthSession, SharedAuth};
79pub use error::{MetadataError, MetadataResult, SoapFault};
80pub use handlers::file_based::WaitConfig;
81pub use package_manifest::{MetadataType, PackageManifest};
82pub use result::{
83    AsyncRequestState, AsyncResult, CancelDeployResult, CodeCoverageResult, CodeCoverageWarning,
84    DeleteResult, DeployDetails, DeployMessage, DeployOptions, DeployProblemType, DeployResult,
85    DeployStatus, DescribeMetadataObject, DescribeMetadataResult, DescribeValueTypeResult,
86    FileProperties, ListMetadataQuery, ManageableState, MetadataApiError, PicklistEntry,
87    RetrieveMessage, RetrieveRequest, RetrieveResult, RetrieveStatus, RunTestFailure,
88    RunTestSuccess, RunTestsResult, SaveResult, TestLevel, UpsertResult, ValueTypeField,
89};
90pub use retry::RetryPolicy;
91pub use transport::SoapOperation;
92
93/// Default Metadata API version when the caller doesn't override it.
94///
95/// SOAP endpoint paths use bare version numbers without the `v` prefix
96/// (`/services/Soap/m/66.0`).
97pub const DEFAULT_API_VERSION: &str = "66.0";
98
99/// Default User-Agent header sent on every request.
100pub(crate) const DEFAULT_USER_AGENT: &str = concat!(
101    "cirrus-metadata/",
102    env!("CARGO_PKG_VERSION"),
103    " (Rust SDK for Salesforce Metadata API)"
104);
105
106use reqwest::header::{HeaderMap, HeaderValue, USER_AGENT};
107
108/// The Metadata API client.
109///
110/// Holds an HTTP client, an [`AuthSession`] for credentials, the API
111/// version to target, and a [`RetryPolicy`] for transient-failure
112/// handling. Cheap to clone — the auth session is `Arc`-shared and the
113/// HTTP client is internally reference-counted.
114#[derive(Clone)]
115pub struct MetadataClient {
116    pub(crate) http: reqwest::Client,
117    pub(crate) auth: SharedAuth,
118    pub(crate) api_version: String,
119    pub(crate) retry_policy: RetryPolicy,
120}
121
122impl std::fmt::Debug for MetadataClient {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        // Mirror cirrus::Cirrus: omit `auth` (may carry secrets) and
125        // the reqwest client (no useful Debug).
126        f.debug_struct("MetadataClient")
127            .field("api_version", &self.api_version)
128            .field("instance_url", &self.auth.instance_url())
129            .field("retry_policy", &self.retry_policy)
130            .finish_non_exhaustive()
131    }
132}
133
134impl MetadataClient {
135    /// Returns a builder for constructing a [`MetadataClient`].
136    pub fn builder() -> MetadataClientBuilder {
137        MetadataClientBuilder::default()
138    }
139
140    /// Returns the configured Metadata API version (e.g. `"66.0"`).
141    pub fn api_version(&self) -> &str {
142        &self.api_version
143    }
144
145    /// Returns a reference to the underlying `reqwest` client. Useful
146    /// for callers who want to compose additional requests against the
147    /// same connection pool.
148    pub fn http_client(&self) -> &reqwest::Client {
149        &self.http
150    }
151
152    /// Returns the auth session backing this client.
153    pub fn auth(&self) -> &SharedAuth {
154        &self.auth
155    }
156
157    /// Returns the configured retry policy.
158    pub fn retry_policy(&self) -> &RetryPolicy {
159        &self.retry_policy
160    }
161
162    /// Returns the fully-resolved SOAP endpoint URL for this client,
163    /// e.g. `https://my-org.my.salesforce.com/services/Soap/m/66.0`.
164    ///
165    /// The instance URL is read from the configured [`AuthSession`] on
166    /// every call, so it reflects the *current* session — relevant for
167    /// flows that can change instance URL on refresh (e.g. some token
168    /// exchange scenarios).
169    pub fn endpoint_url(&self) -> String {
170        format!(
171            "{}/services/Soap/m/{}",
172            self.auth.instance_url(),
173            self.api_version
174        )
175    }
176
177    /// Returns a pre-configured `reqwest::RequestBuilder` for the SOAP
178    /// endpoint, with `Content-Type` and `SOAPAction` already set.
179    ///
180    /// The bearer token is **not** injected — the Metadata API expects
181    /// it inside the envelope's `<SessionHeader>`, not on the
182    /// `Authorization` header. Fetch it via
183    /// `client.auth().access_token().await?` and splice it into your
184    /// envelope.
185    ///
186    /// **Security note:** because the token travels in the request
187    /// *body*, redacting the `Authorization` header is not enough —
188    /// any middleware or proxy that logs request bodies will capture
189    /// the session token in plaintext. This applies to every request
190    /// this client sends, including the typed [`call`](Self::call)
191    /// path.
192    ///
193    /// Use this only when you need to bypass the typed
194    /// [`SoapOperation`] path entirely (e.g. to record raw traffic).
195    pub fn request_builder(&self) -> reqwest::RequestBuilder {
196        self.http
197            .post(self.endpoint_url())
198            .header(reqwest::header::CONTENT_TYPE, "text/xml; charset=UTF-8")
199            .header("SOAPAction", "\"\"")
200    }
201
202    /// Dispatch a typed SOAP operation.
203    ///
204    /// This is the entry point handlers use; it builds the envelope,
205    /// POSTs, retries transient failures per the configured
206    /// [`RetryPolicy`], refreshes the auth token on
207    /// `INVALID_SESSION_ID` faults, and deserializes the response into
208    /// `O::Response`. Returns [`MetadataError::Soap`] for server-side
209    /// faults and [`MetadataError::Http`] / [`MetadataError::Http4xx5xx`]
210    /// for transport-level failures.
211    ///
212    /// The session token travels inside the SOAP envelope (the request
213    /// *body*) — see the security note on
214    /// [`request_builder`](Self::request_builder) before wiring
215    /// body-logging middleware around this client.
216    pub async fn call<O: SoapOperation>(&self, op: &O) -> MetadataResult<O::Response> {
217        transport::soap_call(self, op).await
218    }
219}
220
221/// Builder for [`MetadataClient`].
222///
223/// Required: an [`AuthSession`] via [`auth`](Self::auth). Everything
224/// else has a sensible default.
225#[derive(Default)]
226pub struct MetadataClientBuilder {
227    auth: Option<SharedAuth>,
228    api_version: Option<String>,
229    user_agent: Option<String>,
230    http_client: Option<reqwest::Client>,
231    retry_policy: Option<RetryPolicy>,
232}
233
234impl MetadataClientBuilder {
235    /// Sets the auth session (any [`AuthSession`] wrapped in `Arc`).
236    /// Required.
237    pub fn auth(mut self, auth: SharedAuth) -> Self {
238        self.auth = Some(auth);
239        self
240    }
241
242    /// Sets the Metadata API version, e.g. `"66.0"`. Defaults to
243    /// [`DEFAULT_API_VERSION`]. Note: SOAP endpoint paths use the bare
244    /// number without a `v` prefix.
245    pub fn api_version(mut self, version: impl Into<String>) -> Self {
246        self.api_version = Some(version.into());
247        self
248    }
249
250    /// Overrides the default User-Agent header. Ignored if
251    /// [`http_client`](Self::http_client) is set.
252    pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
253        self.user_agent = Some(ua.into());
254        self
255    }
256
257    /// Supplies a pre-configured `reqwest::Client`. Useful for sharing
258    /// a connection pool across multiple SDK clients or for installing
259    /// custom middleware. When provided, the builder's `user_agent`
260    /// setting is ignored — configure that on the supplied client.
261    pub fn http_client(mut self, client: reqwest::Client) -> Self {
262        self.http_client = Some(client);
263        self
264    }
265
266    /// Sets the [`RetryPolicy`] for transient-failure handling.
267    pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
268        self.retry_policy = Some(policy);
269        self
270    }
271
272    /// Finalizes the builder.
273    pub fn build(self) -> MetadataResult<MetadataClient> {
274        let auth = self.auth.ok_or(MetadataError::MissingField("auth"))?;
275
276        let http = if let Some(c) = self.http_client {
277            c
278        } else {
279            let ua = self.user_agent.as_deref().unwrap_or(DEFAULT_USER_AGENT);
280            let mut headers = HeaderMap::new();
281            headers.insert(
282                USER_AGENT,
283                HeaderValue::from_str(ua)
284                    .map_err(|e| MetadataError::InvalidHeader(e.to_string()))?,
285            );
286            reqwest::Client::builder()
287                .default_headers(headers)
288                .build()
289                .map_err(MetadataError::HttpClient)?
290        };
291
292        Ok(MetadataClient {
293            http,
294            auth,
295            api_version: self
296                .api_version
297                .unwrap_or_else(|| DEFAULT_API_VERSION.to_string()),
298            retry_policy: self.retry_policy.unwrap_or_default(),
299        })
300    }
301}
302
303#[cfg(test)]
304#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
305mod tests {
306    use super::*;
307    use std::sync::Arc;
308
309    #[test]
310    fn builder_requires_auth() {
311        let err = MetadataClient::builder().build().unwrap_err();
312        assert!(matches!(err, MetadataError::MissingField("auth")));
313    }
314
315    #[test]
316    fn endpoint_url_uses_bare_version_no_v_prefix() {
317        let auth = Arc::new(auth::StaticTokenAuth::new(
318            "tok",
319            "https://my-org.my.salesforce.com",
320        ));
321        let md = MetadataClient::builder().auth(auth).build().unwrap();
322        assert_eq!(
323            md.endpoint_url(),
324            "https://my-org.my.salesforce.com/services/Soap/m/66.0"
325        );
326    }
327
328    #[test]
329    fn endpoint_url_honors_custom_api_version() {
330        let auth = Arc::new(auth::StaticTokenAuth::new("tok", "https://x.example.com"));
331        let md = MetadataClient::builder()
332            .auth(auth)
333            .api_version("58.0")
334            .build()
335            .unwrap();
336        assert!(md.endpoint_url().ends_with("/services/Soap/m/58.0"));
337    }
338
339    #[test]
340    fn debug_redacts_auth_and_client() {
341        let auth = Arc::new(auth::StaticTokenAuth::new(
342            "secret-token",
343            "https://x.example.com",
344        ));
345        let md = MetadataClient::builder().auth(auth).build().unwrap();
346        let dbg = format!("{md:?}");
347        assert!(!dbg.contains("secret-token"));
348    }
349}