use crate::Cirrus;
use crate::error::CirrusResult;
use serde::{Deserialize, Serialize};
impl Cirrus {
pub fn metadata(&self) -> MetadataHandler<'_> {
MetadataHandler { client: self }
}
}
#[derive(Debug)]
pub struct MetadataHandler<'a> {
client: &'a Cirrus,
}
impl MetadataHandler<'_> {
pub async fn deploy(
&self,
options: &DeployOptions,
zip: bytes::Bytes,
) -> CirrusResult<DeployRequest> {
let payload = DeployRequestBody {
deploy_options: options,
};
let json_bytes =
serde_json::to_vec(&payload).map_err(crate::error::CirrusError::Serialization)?;
self.client
.send_multipart(
reqwest::Method::POST,
"metadata/deployRequest",
"json",
json_bytes,
"file",
"deploy.zip",
"application/zip",
zip,
)
.await
}
pub async fn check_deploy_status(
&self,
deploy_id: &str,
include_details: bool,
) -> CirrusResult<DeployRequest> {
let url = self
.client
.versioned_segments(&["metadata", "deployRequest", deploy_id])?;
if include_details {
self.client
.send_at::<_, _, ()>(
reqwest::Method::GET,
&url,
Some(&[("includeDetails", "true")]),
None,
)
.await
} else {
self.client
.send_at(reqwest::Method::GET, &url, None::<&()>, None::<&()>)
.await
}
}
pub async fn cancel_deploy(&self, deploy_id: &str) -> CirrusResult<DeployRequest> {
let url = self
.client
.versioned_segments(&["metadata", "deployRequest", deploy_id])?;
let body = CancelBody {
deploy_result: CancelStatus {
status: "Canceling",
},
};
self.client
.send_at(reqwest::Method::PATCH, &url, None::<&()>, Some(&body))
.await
}
pub async fn deploy_recent_validation(
&self,
validated_deploy_request_id: &str,
) -> CirrusResult<DeployRequest> {
let url = self.client.versioned_segments(&[
"metadata",
"deployRequest",
validated_deploy_request_id,
])?;
let body = RecentValidationBody {
validated_deploy_request_id,
};
self.client
.send_at(reqwest::Method::POST, &url, None::<&()>, Some(&body))
.await
}
}
#[derive(Serialize)]
struct DeployRequestBody<'a> {
#[serde(rename = "deployOptions")]
deploy_options: &'a DeployOptions,
}
#[derive(Serialize)]
struct CancelBody {
#[serde(rename = "deployResult")]
deploy_result: CancelStatus,
}
#[derive(Serialize)]
struct CancelStatus {
status: &'static str,
}
#[derive(Serialize)]
struct RecentValidationBody<'a> {
#[serde(rename = "validatedDeployRequestId")]
validated_deploy_request_id: &'a str,
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DeployOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub allow_missing_files: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_update_package: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub check_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ignore_warnings: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub perform_retrieve: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub purge_on_delete: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollback_on_error: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub run_tests: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub single_package: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub test_level: Option<TestLevel>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TestLevel {
NoTestRun,
RunSpecifiedTests,
RunRelevantTests,
RunLocalTests,
RunAllTestsInOrg,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeployRequest {
pub id: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub validated_deploy_request_id: Option<String>,
#[serde(default)]
pub deploy_options: Option<serde_json::Value>,
#[serde(default)]
pub deploy_result: Option<DeployResultDetails>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeployResultDetails {
pub id: String,
#[serde(default)]
pub done: bool,
#[serde(default)]
pub success: bool,
#[serde(default)]
pub status: Option<DeployStatus>,
#[serde(default)]
pub check_only: bool,
#[serde(default)]
pub ignore_warnings: bool,
#[serde(default)]
pub rollback_on_error: bool,
#[serde(default, alias = "isRunTestsEnabled")]
pub run_tests_enabled: Option<bool>,
#[serde(default)]
pub number_components_deployed: i32,
#[serde(default)]
pub number_components_total: i32,
#[serde(default)]
pub number_component_errors: i32,
#[serde(default)]
pub number_tests_completed: i32,
#[serde(default)]
pub number_tests_total: i32,
#[serde(default)]
pub number_test_errors: i32,
#[serde(default)]
pub state_detail: Option<String>,
#[serde(default)]
pub error_status_code: Option<String>,
#[serde(default)]
pub error_message: Option<String>,
#[serde(default)]
pub created_by: Option<String>,
#[serde(default)]
pub created_by_name: Option<String>,
#[serde(default)]
pub created_date: Option<String>,
#[serde(default)]
pub start_date: Option<String>,
#[serde(default)]
pub last_modified_date: Option<String>,
#[serde(default)]
pub completed_date: Option<String>,
#[serde(default)]
pub canceled_by: Option<String>,
#[serde(default)]
pub canceled_by_name: Option<String>,
#[serde(default)]
pub details: Option<DeployResultInnerDetails>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeployResultInnerDetails {
#[serde(default)]
pub component_failures: Vec<DeployMessage>,
#[serde(default)]
pub component_successes: Vec<DeployMessage>,
#[serde(default)]
pub retrieve_result: Option<serde_json::Value>,
#[serde(default)]
pub run_test_results: Option<RunTestResults>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeployMessage {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub component_type: Option<String>,
#[serde(default)]
pub full_name: Option<String>,
#[serde(default)]
pub file_name: Option<String>,
#[serde(default)]
pub success: bool,
#[serde(default)]
pub changed: bool,
#[serde(default)]
pub created: bool,
#[serde(default)]
pub deleted: bool,
#[serde(default)]
pub created_date: Option<String>,
#[serde(default)]
pub problem: Option<String>,
#[serde(default)]
pub problem_type: Option<String>,
#[serde(default)]
pub line_number: Option<i32>,
#[serde(default)]
pub column_number: Option<i32>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RunTestResults {
#[serde(default)]
pub num_run: i32,
#[serde(default)]
pub num_failures: i32,
#[serde(default)]
pub total_time: f64,
#[serde(default)]
pub successes: Vec<serde_json::Value>,
#[serde(default)]
pub failures: Vec<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum DeployStatus {
Pending,
InProgress,
FinalizingDeploy,
FinalizingDeployFailed,
Succeeded,
SucceededPartial,
Failed,
Canceling,
Canceled,
#[serde(other)]
Unknown,
}
impl DeployStatus {
pub fn is_terminal(self) -> bool {
matches!(
self,
Self::Succeeded
| Self::SucceededPartial
| Self::Failed
| Self::Canceled
| Self::FinalizingDeployFailed
)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::Cirrus;
use crate::auth::StaticTokenAuth;
use serde_json::json;
use std::sync::Arc;
use wiremock::matchers::{body_json, header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn fixture(uri: String) -> Cirrus {
let auth = Arc::new(StaticTokenAuth::new("tok", uri));
Cirrus::builder().auth(auth).build().unwrap()
}
#[test]
fn unknown_deploy_status_deserializes_to_unknown_not_error() {
let status: DeployStatus = serde_json::from_value(json!("BrandNewPhase")).unwrap();
assert_eq!(status, DeployStatus::Unknown);
assert!(!status.is_terminal());
}
#[tokio::test]
async fn deploy_initiates_with_multipart_and_returns_request() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/metadata/deployRequest"))
.and(header("authorization", "Bearer tok"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "0Afxx00000001VPCAY",
"deployOptions": {
"checkOnly": false,
"singlePackage": false,
"allowMissingFiles": false,
"performRetrieve": false,
"autoUpdatePackage": false,
"rollbackOnError": true,
"ignoreWarnings": false,
"purgeOnDelete": false,
"runAllTests": false
},
"deployResult": {
"id": "0Afxx00000001VPCAY",
"success": false,
"checkOnly": false,
"ignoreWarnings": false,
"rollbackOnError": true,
"status": "Pending",
"runTestsEnabled": false,
"done": false
}
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let zip: bytes::Bytes = b"PK\x03\x04fake-zip-bytes".to_vec().into();
let options = DeployOptions {
check_only: Some(true),
rollback_on_error: Some(true),
test_level: Some(TestLevel::RunLocalTests),
..Default::default()
};
let req = sf.metadata().deploy(&options, zip).await.unwrap();
assert_eq!(req.id, "0Afxx00000001VPCAY");
let result = req.deploy_result.expect("deploy_result populated");
assert_eq!(result.status, Some(DeployStatus::Pending));
assert!(!result.done);
let opts = req.deploy_options.expect("echoed options");
assert_eq!(opts["runAllTests"], json!(false));
}
#[tokio::test]
async fn deploy_options_serializes_only_set_fields() {
let options = DeployOptions {
check_only: Some(true),
test_level: Some(TestLevel::RunSpecifiedTests),
run_tests: Some(vec!["MyTestClass".into()]),
..Default::default()
};
let body = DeployRequestBody {
deploy_options: &options,
};
let v = serde_json::to_value(&body).unwrap();
let opts = &v["deployOptions"];
assert_eq!(opts["checkOnly"], json!(true));
assert_eq!(opts["testLevel"], json!("RunSpecifiedTests"));
assert_eq!(opts["runTests"], json!(["MyTestClass"]));
assert!(opts.get("allowMissingFiles").is_none());
assert!(opts.get("rollbackOnError").is_none());
assert!(opts.get("singlePackage").is_none());
}
#[tokio::test]
async fn check_deploy_status_with_details_parses_full_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/metadata/deployRequest/0Afxx00000000lWCAQ"))
.and(query_param("includeDetails", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "0Afxx00000000lWCAQ",
"url": "https://host/services/data/v66.0/metadata/deployRequest/0Afxx00000000lWCAQ?includeDetails=true",
"deployResult": {
"id": "0Afxx00000000lWCAQ",
"checkOnly": false,
"ignoreWarnings": false,
"rollbackOnError": false,
"status": "InProgress",
"numberComponentsDeployed": 10,
"numberComponentsTotal": 1032,
"numberComponentErrors": 0,
"numberTestsCompleted": 45,
"numberTestsTotal": 135,
"numberTestErrors": 0,
"details": {
"componentFailures": [],
"componentSuccesses": [],
"retrieveResult": null,
"runTestResults": {
"numRun": 0,
"successes": [],
"failures": []
}
},
"createdDate": "2017-10-10T08:22Z",
"startDate": "2017-10-10T08:22Z",
"lastModifiedDate": "2017-10-10T08:44Z",
"completedDate": "2017-10-10T08:44Z",
"errorStatusCode": null,
"errorMessage": null,
"stateDetail": "Processing Type: Apex Component",
"createdBy": "005xx0000001Sv1m",
"createdByName": "stephanie stevens",
"canceledBy": null,
"canceledByName": null,
"isRunTestsEnabled": false
},
"deployOptions": {
"allowMissingFiles": false,
"autoUpdatePackage": false,
"checkOnly": true,
"ignoreWarnings": false,
"performRetrieve": false,
"purgeOnDelete": false,
"rollbackOnError": false,
"runTests": null,
"singlePackage": true,
"testLevel": "RunAllTestsInOrg"
}
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let req = sf
.metadata()
.check_deploy_status("0Afxx00000000lWCAQ", true)
.await
.unwrap();
assert_eq!(req.id, "0Afxx00000000lWCAQ");
let r = req.deploy_result.unwrap();
assert_eq!(r.status, Some(DeployStatus::InProgress));
assert_eq!(r.number_components_total, 1032);
assert_eq!(
r.state_detail.as_deref(),
Some("Processing Type: Apex Component")
);
let details = r.details.expect("details populated");
assert!(details.component_failures.is_empty());
let tests = details.run_test_results.unwrap();
assert_eq!(tests.num_run, 0);
}
#[tokio::test]
async fn check_deploy_status_without_details_omits_query() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/services/data/v66.0/metadata/deployRequest/0Afxx00000000lWCAQ",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "0Afxx00000000lWCAQ",
"deployResult": {
"id": "0Afxx00000000lWCAQ",
"status": "Succeeded",
"done": true,
"success": true
}
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let req = sf
.metadata()
.check_deploy_status("0Afxx00000000lWCAQ", false)
.await
.unwrap();
let r = req.deploy_result.unwrap();
assert_eq!(r.status, Some(DeployStatus::Succeeded));
assert!(r.done);
assert!(r.success);
assert!(r.details.is_none());
}
#[tokio::test]
async fn cancel_deploy_patches_status_and_returns_snapshot() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path(
"/services/data/v66.0/metadata/deployRequest/0Afxx00000000lWCAQ",
))
.and(body_json(json!({
"deployResult": { "status": "Canceling" }
})))
.respond_with(ResponseTemplate::new(202).set_body_json(json!({
"id": "0Afxx00000000lWCAQ",
"url": "https://host/services/data/v66.0/metadata/deployRequest/0Afxx00000000lWCAQ",
"deployResult": {
"id": "0Afxx00000000lWCAQ",
"checkOnly": false,
"ignoreWarnings": false,
"rollbackOnError": false,
"status": "Canceling",
"numberComponentsDeployed": 10,
"numberComponentsTotal": 1032,
"numberComponentErrors": 0,
"numberTestsCompleted": 45,
"numberTestsTotal": 135,
"numberTestErrors": 0,
"details": {
"componentFailures": [],
"componentSuccesses": [],
"retrieveResult": null,
"runTestResults": {
"numRun": 0,
"successes": [],
"failures": []
}
},
"createdDate": "2017-10-10T08:22Z",
"startDate": "2017-10-10T08:22Z",
"lastModifiedDate": "2017-10-10T08:44Z",
"completedDate": "2017-10-10T08:44Z",
"errorStatusCode": null,
"errorMessage": null,
"stateDetail": "Processing Type: Apex Component",
"createdBy": "005xx0000001Sv1m",
"createdByName": "steve stevens",
"canceledBy": null,
"canceledByName": null,
"isRunTestsEnabled": null
}
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let req = sf
.metadata()
.cancel_deploy("0Afxx00000000lWCAQ")
.await
.unwrap();
let r = req.deploy_result.unwrap();
assert_eq!(r.status, Some(DeployStatus::Canceling));
}
#[tokio::test]
async fn deploy_recent_validation_posts_validated_id() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/services/data/v66.0/metadata/deployRequest/0Afxx00000000lWCAQ",
))
.and(body_json(json!({
"validatedDeployRequestId": "0Afxx00000000lWCAQ"
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"validatedDeployRequestId": "0Afxx00000000lWCAQ",
"id": "0Afxx00000000lWMEM",
"url": "https://host/services/data/v66.0/metadata/deployRequest/0Afxx00000000lWMEM",
"deployOptions": {
"allowMissingFiles": false,
"autoUpdatePackage": false,
"checkOnly": true,
"ignoreWarnings": false,
"performRetrieve": false,
"purgeOnDelete": false,
"rollbackOnError": false,
"runTests": null,
"singlePackage": true,
"testLevel": "RunAllTestsInOrg"
}
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let req = sf
.metadata()
.deploy_recent_validation("0Afxx00000000lWCAQ")
.await
.unwrap();
assert_eq!(req.id, "0Afxx00000000lWMEM");
assert_eq!(
req.validated_deploy_request_id.as_deref(),
Some("0Afxx00000000lWCAQ")
);
assert!(req.deploy_result.is_none());
}
#[tokio::test]
async fn deploy_recent_validation_surfaces_404_no_match() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(
"/services/data/v66.0/metadata/deployRequest/0Afxx99999999999",
))
.respond_with(ResponseTemplate::new(404).set_body_json(json!([{
"errorCode": "NOT_FOUND",
"message": "No matching deployment validation found"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf
.metadata()
.deploy_recent_validation("0Afxx99999999999")
.await
.unwrap_err();
match err {
crate::CirrusError::Api { status, errors, .. } => {
assert_eq!(status, 404);
assert_eq!(errors[0].error_code, "NOT_FOUND");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn deploy_surfaces_salesforce_error_array() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/metadata/deployRequest"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"errorCode": "INVALID_DEPLOY_OPTIONS",
"message": "rollbackOnError must be true for production"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf
.metadata()
.deploy(&DeployOptions::default(), bytes::Bytes::from_static(b"zip"))
.await
.unwrap_err();
match err {
crate::CirrusError::Api { status, errors, .. } => {
assert_eq!(status, 400);
assert_eq!(errors[0].error_code, "INVALID_DEPLOY_OPTIONS");
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[test]
fn deploy_status_is_terminal_matches_finished_states() {
assert!(DeployStatus::Succeeded.is_terminal());
assert!(DeployStatus::SucceededPartial.is_terminal());
assert!(DeployStatus::Failed.is_terminal());
assert!(DeployStatus::Canceled.is_terminal());
assert!(DeployStatus::FinalizingDeployFailed.is_terminal());
assert!(!DeployStatus::Pending.is_terminal());
assert!(!DeployStatus::InProgress.is_terminal());
assert!(!DeployStatus::FinalizingDeploy.is_terminal());
assert!(!DeployStatus::Canceling.is_terminal());
}
}