use crate::Cirrus;
use crate::error::{CirrusError, CirrusResult};
use crate::response::{DescribeGlobal, SObjectCreateResult};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::time::SystemTime;
impl Cirrus {
pub fn sobjects(&self) -> SObjectsHandler<'_> {
SObjectsHandler { client: self }
}
pub fn sobject<'a>(&'a self, name: &'a str) -> SObjectHandler<'a> {
SObjectHandler { client: self, name }
}
}
#[derive(Debug)]
pub struct SObjectsHandler<'a> {
client: &'a Cirrus,
}
impl SObjectsHandler<'_> {
pub async fn describe_global(&self) -> CirrusResult<DescribeGlobal> {
self.client.get("sobjects").await
}
pub async fn describe_global_if_modified_since(
&self,
since: SystemTime,
) -> CirrusResult<Option<DescribeGlobal>> {
let date = httpdate::fmt_http_date(since);
let (status, bytes) = self
.client
.send_with_headers(
reqwest::Method::GET,
"sobjects",
None,
&[("If-Modified-Since", &date)],
)
.await?;
if status == 304 {
return Ok(None);
}
Ok(Some(
serde_json::from_slice(&bytes).map_err(CirrusError::Serialization)?,
))
}
}
#[derive(Debug)]
pub struct SObjectHandler<'a> {
client: &'a Cirrus,
name: &'a str,
}
impl<'a> SObjectHandler<'a> {
pub fn name(&self) -> &'a str {
self.name
}
pub async fn describe(&self) -> CirrusResult<Value> {
self.describe_as().await
}
pub async fn describe_as<R: DeserializeOwned>(&self) -> CirrusResult<R> {
let url = self
.client
.versioned_segments(&["sobjects", self.name, "describe"])?;
self.client
.send_at(reqwest::Method::GET, &url, None::<&()>, None::<&()>)
.await
}
pub async fn describe_if_modified_since(
&self,
since: SystemTime,
) -> CirrusResult<Option<Value>> {
self.describe_if_modified_since_as(since).await
}
pub async fn describe_if_modified_since_as<R: DeserializeOwned>(
&self,
since: SystemTime,
) -> CirrusResult<Option<R>> {
let url = self
.client
.versioned_segments(&["sobjects", self.name, "describe"])?;
let date = httpdate::fmt_http_date(since);
let (status, bytes) = self
.client
.send_with_headers(
reqwest::Method::GET,
&url,
None,
&[("If-Modified-Since", &date)],
)
.await?;
if status == 304 {
return Ok(None);
}
Ok(Some(
serde_json::from_slice(&bytes).map_err(CirrusError::Serialization)?,
))
}
pub async fn retrieve(&self, id: &str) -> CirrusResult<Value> {
self.retrieve_as(id).await
}
pub async fn retrieve_as<R: DeserializeOwned>(&self, id: &str) -> CirrusResult<R> {
let url = self
.client
.versioned_segments(&["sobjects", self.name, id])?;
self.client
.send_at(reqwest::Method::GET, &url, None::<&()>, None::<&()>)
.await
}
pub async fn retrieve_with_fields(&self, id: &str, fields: &[&str]) -> CirrusResult<Value> {
self.retrieve_with_fields_as(id, fields).await
}
pub async fn retrieve_with_fields_as<R: DeserializeOwned>(
&self,
id: &str,
fields: &[&str],
) -> CirrusResult<R> {
let url = self
.client
.versioned_segments(&["sobjects", self.name, id])?;
let joined = fields.join(",");
let query = [("fields", joined.as_str())];
self.client
.send_at(reqwest::Method::GET, &url, Some(&query), None::<&()>)
.await
}
pub async fn create<B>(&self, body: &B) -> CirrusResult<SObjectCreateResult>
where
B: Serialize + ?Sized,
{
let url = self.client.versioned_segments(&["sobjects", self.name])?;
self.client
.send_at(reqwest::Method::POST, &url, None::<&()>, Some(body))
.await
}
pub async fn update<B>(&self, id: &str, body: &B) -> CirrusResult<()>
where
B: Serialize + ?Sized,
{
let url = self
.client
.versioned_segments(&["sobjects", self.name, id])?;
self.client
.send_at::<(), (), B>(reqwest::Method::PATCH, &url, None, Some(body))
.await
}
pub async fn delete(&self, id: &str) -> CirrusResult<()> {
let url = self
.client
.versioned_segments(&["sobjects", self.name, id])?;
self.client
.send_at::<(), (), ()>(reqwest::Method::DELETE, &url, None, None)
.await
}
pub async fn upsert<B>(
&self,
external_field: &str,
external_value: &str,
body: &B,
) -> CirrusResult<SObjectCreateResult>
where
B: Serialize + ?Sized,
{
let url = self.client.versioned_segments(&[
"sobjects",
self.name,
external_field,
external_value,
])?;
self.client
.send_at(reqwest::Method::PATCH, &url, None::<&()>, Some(body))
.await
}
pub async fn create_with_blob<B>(
&self,
spec: BlobUploadSpec<'_, B>,
) -> CirrusResult<SObjectCreateResult>
where
B: Serialize + ?Sized,
{
let url = self.client.versioned_segments(&["sobjects", self.name])?;
let json_bytes =
serde_json::to_vec(spec.metadata).map_err(crate::error::CirrusError::Serialization)?;
let content_type = spec.content_type.unwrap_or("application/octet-stream");
self.client
.send_multipart(
reqwest::Method::POST,
&url,
spec.json_part_name,
json_bytes,
spec.blob_field_name,
spec.filename,
content_type,
spec.blob,
)
.await
}
pub async fn update_with_blob<B>(
&self,
id: &str,
spec: BlobUploadSpec<'_, B>,
) -> CirrusResult<()>
where
B: Serialize + ?Sized,
{
let url = self
.client
.versioned_segments(&["sobjects", self.name, id])?;
let json_bytes =
serde_json::to_vec(spec.metadata).map_err(crate::error::CirrusError::Serialization)?;
let content_type = spec.content_type.unwrap_or("application/octet-stream");
self.client
.send_multipart(
reqwest::Method::PATCH,
&url,
spec.json_part_name,
json_bytes,
spec.blob_field_name,
spec.filename,
content_type,
spec.blob,
)
.await
}
}
#[derive(Debug)]
pub struct BlobUploadSpec<'a, B: ?Sized> {
pub json_part_name: &'a str,
pub metadata: &'a B,
pub blob_field_name: &'a str,
pub filename: &'a str,
pub content_type: Option<&'a str>,
pub blob: bytes::Bytes,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use crate::Cirrus;
use crate::auth::StaticTokenAuth;
use serde_json::json;
use std::sync::Arc;
use wiremock::matchers::{body_json, header, method, path, path_regex, 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()
}
#[tokio::test]
async fn describe_global_returns_envelope() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/sobjects"))
.and(header("authorization", "Bearer tok"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"encoding": "UTF-8",
"maxBatchSize": 200,
"sobjects": [{
"activateable": false, "custom": false, "customSetting": false,
"createable": true, "deletable": true, "deprecatedAndHidden": false,
"feedEnabled": true, "keyPrefix": "001",
"label": "Account", "labelPlural": "Accounts",
"layoutable": true, "mergeable": true, "mruEnabled": true,
"name": "Account", "queryable": true, "replicateable": true,
"retrieveable": true, "searchable": true, "triggerable": true,
"undeletable": true, "updateable": true,
"urls": {
"sobject": "/services/data/v66.0/sobjects/Account",
"describe": "/services/data/v66.0/sobjects/Account/describe",
"rowTemplate": "/services/data/v66.0/sobjects/Account/{ID}"
}
}]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let dg = sf.sobjects().describe_global().await.unwrap();
assert_eq!(dg.encoding, "UTF-8");
assert_eq!(dg.max_batch_size, 200);
assert_eq!(dg.sobjects.len(), 1);
assert_eq!(dg.sobjects[0].name, "Account");
}
#[tokio::test]
async fn describe_returns_value() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/sobjects/Account/describe"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "Account",
"label": "Account",
"fields": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v = sf.sobject("Account").describe().await.unwrap();
assert_eq!(v["name"], "Account");
}
#[tokio::test]
async fn retrieve_full_record() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/services/data/v66.0/sobjects/Account/001xx0000000001",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"Id": "001xx0000000001",
"Name": "Acme",
"Industry": "Tech"
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v = sf
.sobject("Account")
.retrieve("001xx0000000001")
.await
.unwrap();
assert_eq!(v["Name"], "Acme");
}
#[tokio::test]
async fn retrieve_with_fields_sets_query() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/services/data/v66.0/sobjects/Account/001xx0000000001",
))
.and(query_param("fields", "Name,Industry"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"Name": "Acme",
"Industry": "Tech"
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v = sf
.sobject("Account")
.retrieve_with_fields("001xx0000000001", &["Name", "Industry"])
.await
.unwrap();
assert_eq!(v["Name"], "Acme");
assert_eq!(v["Industry"], "Tech");
}
#[tokio::test]
async fn create_posts_body_and_returns_id() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/sobjects/Account"))
.and(body_json(json!({"Name": "Acme"})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "001xx0000000001",
"success": true,
"errors": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let result = sf
.sobject("Account")
.create(&json!({"Name": "Acme"}))
.await
.unwrap();
assert_eq!(result.id, "001xx0000000001");
assert!(result.success);
}
#[tokio::test]
async fn update_sends_patch_and_handles_204() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path(
"/services/data/v66.0/sobjects/Account/001xx0000000001",
))
.and(body_json(json!({"Industry": "Biotech"})))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let sf = fixture(server.uri());
sf.sobject("Account")
.update("001xx0000000001", &json!({"Industry": "Biotech"}))
.await
.unwrap();
}
#[tokio::test]
async fn delete_sends_delete_and_handles_204() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/services/data/v66.0/sobjects/Account/001xx0000000001",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let sf = fixture(server.uri());
sf.sobject("Account")
.delete("001xx0000000001")
.await
.unwrap();
}
#[tokio::test]
async fn upsert_patches_to_external_id_path() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path(
"/services/data/v66.0/sobjects/Account/External_Id__c/EXT-1",
))
.and(body_json(json!({"Name": "Acme"})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "001xx0000000001",
"success": true,
"errors": [],
"created": true
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let result = sf
.sobject("Account")
.upsert("External_Id__c", "EXT-1", &json!({"Name": "Acme"}))
.await
.unwrap();
assert_eq!(result.id, "001xx0000000001");
assert_eq!(result.created, Some(true));
}
#[tokio::test]
async fn upsert_percent_encodes_external_value() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path_regex(
r"^/services/data/v66\.0/sobjects/Account/External_Id__c/.+$",
))
.and(body_json(json!({"Name": "Edge"})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": "001xx0000000002",
"success": true,
"errors": [],
"created": false
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let result = sf
.sobject("Account")
.upsert("External_Id__c", "a/b=c d", &json!({"Name": "Edge"}))
.await
.unwrap();
assert_eq!(result.created, Some(false));
}
#[tokio::test]
async fn retrieve_typed() {
#[derive(serde::Deserialize)]
struct Account {
#[serde(rename = "Name")]
name: String,
}
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/services/data/v66.0/sobjects/Account/001xx0000000001",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"Name": "Acme"})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let acct: Account = sf
.sobject("Account")
.retrieve_as("001xx0000000001")
.await
.unwrap();
assert_eq!(acct.name, "Acme");
}
#[tokio::test]
async fn create_surfaces_validation_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/sobjects/Account"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"message": "Required fields are missing: [Name]",
"errorCode": "REQUIRED_FIELD_MISSING",
"fields": ["Name"]
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf.sobject("Account").create(&json!({})).await.unwrap_err();
match err {
crate::CirrusError::Api { status, errors, .. } => {
assert_eq!(status, 400);
assert_eq!(errors[0].error_code, "REQUIRED_FIELD_MISSING");
assert_eq!(errors[0].fields, vec!["Name".to_string()]);
}
other => panic!("expected Api error, got {other:?}"),
}
}
mod conditional {
use super::*;
use std::time::{Duration, SystemTime};
use wiremock::matchers::header_regex;
#[tokio::test]
async fn describe_global_if_modified_since_returns_some_on_200() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/sobjects"))
.and(header_regex(
"if-modified-since",
r"^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"encoding": "UTF-8",
"maxBatchSize": 200,
"sobjects": [{
"activateable": false, "custom": false, "customSetting": false,
"createable": true, "deletable": true, "deprecatedAndHidden": false,
"feedEnabled": true, "keyPrefix": "001",
"label": "Account", "labelPlural": "Accounts",
"layoutable": true, "mergeable": true, "mruEnabled": true,
"name": "Account", "queryable": true, "replicateable": true,
"retrieveable": true, "searchable": true, "triggerable": true,
"undeletable": true, "updateable": true,
"urls": {}
}]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let yesterday = SystemTime::now() - Duration::from_secs(86_400);
let result = sf
.sobjects()
.describe_global_if_modified_since(yesterday)
.await
.unwrap();
let dg = result.expect("expected Some(DescribeGlobal) on 200");
assert_eq!(dg.encoding, "UTF-8");
assert_eq!(dg.sobjects[0].name, "Account");
}
#[tokio::test]
async fn describe_global_if_modified_since_returns_none_on_304() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/sobjects"))
.and(header_regex("if-modified-since", r"GMT$"))
.respond_with(ResponseTemplate::new(304))
.mount(&server)
.await;
let sf = fixture(server.uri());
let yesterday = SystemTime::now() - Duration::from_secs(86_400);
let result = sf
.sobjects()
.describe_global_if_modified_since(yesterday)
.await
.unwrap();
assert!(result.is_none(), "expected None on 304");
}
#[tokio::test]
async fn describe_per_object_if_modified_since_returns_typed_some() {
#[derive(serde::Deserialize)]
struct DescribeSubset {
name: String,
custom: bool,
}
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/sobjects/Account/describe"))
.and(header_regex("if-modified-since", r"GMT$"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "Account",
"custom": false,
"fields": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let result: Option<DescribeSubset> = sf
.sobject("Account")
.describe_if_modified_since_as(SystemTime::now())
.await
.unwrap();
let d = result.unwrap();
assert_eq!(d.name, "Account");
assert!(!d.custom);
}
#[tokio::test]
async fn describe_per_object_if_modified_since_none_on_304() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/sobjects/Account/describe"))
.respond_with(ResponseTemplate::new(304))
.mount(&server)
.await;
let sf = fixture(server.uri());
let result = sf
.sobject("Account")
.describe_if_modified_since(SystemTime::now())
.await
.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn conditional_describe_surfaces_other_4xx_as_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/sobjects"))
.respond_with(ResponseTemplate::new(403).set_body_json(json!([{
"errorCode": "INSUFFICIENT_ACCESS",
"message": "no permission"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf
.sobjects()
.describe_global_if_modified_since(SystemTime::now())
.await
.unwrap_err();
assert!(matches!(err, crate::CirrusError::Api { status: 403, .. }));
}
}
mod blob_upload {
use super::*;
use crate::BlobUploadSpec;
use wiremock::matchers::{body_string_contains, header_regex};
#[tokio::test]
async fn create_with_blob_posts_multipart_to_sobjects_endpoint() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/sobjects/ContentVersion"))
.and(header("authorization", "Bearer tok"))
.and(header_regex(
"content-type",
r"^multipart/form-data; boundary=",
))
.and(body_string_contains(r#"name="entity_content""#))
.and(body_string_contains(r#"name="VersionData""#))
.and(body_string_contains(r#"filename="brochure.pdf""#))
.and(body_string_contains(r#""PathOnClient":"brochure.pdf""#))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "068D00000000pgOIAQ",
"errors": [],
"success": true
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let pdf = bytes::Bytes::from_static(b"%PDF-1.4 fake pdf bytes\n");
let result = sf
.sobject("ContentVersion")
.create_with_blob(BlobUploadSpec {
json_part_name: "entity_content",
metadata: &json!({
"Title": "Q1 Brochure",
"PathOnClient": "brochure.pdf",
}),
blob_field_name: "VersionData",
filename: "brochure.pdf",
content_type: Some("application/pdf"),
blob: pdf,
})
.await
.unwrap();
assert_eq!(result.id, "068D00000000pgOIAQ");
assert!(result.success);
}
#[tokio::test]
async fn create_with_blob_defaults_content_type_to_octet_stream() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/sobjects/Document"))
.and(body_string_contains("application/octet-stream"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "015D000000000",
"errors": [],
"success": true
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
sf.sobject("Document")
.create_with_blob(BlobUploadSpec {
json_part_name: "entity_document",
metadata: &json!({"Name": "test", "FolderId": "005xx", "Type": "pdf"}),
blob_field_name: "Body",
filename: "x.pdf",
content_type: None,
blob: bytes::Bytes::from_static(b"fake"),
})
.await
.unwrap();
}
#[tokio::test]
async fn create_with_blob_surfaces_salesforce_error_array() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/sobjects/Document"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"fields": ["FolderId"],
"message": "Folder ID: id value of incorrect type",
"errorCode": "MALFORMED_ID"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf
.sobject("Document")
.create_with_blob(BlobUploadSpec {
json_part_name: "entity_document",
metadata: &json!({"Name": "x", "FolderId": "bad", "Type": "pdf"}),
blob_field_name: "Body",
filename: "x.pdf",
content_type: Some("application/pdf"),
blob: bytes::Bytes::from_static(b"x"),
})
.await
.unwrap_err();
match err {
crate::CirrusError::Api { status, errors, .. } => {
assert_eq!(status, 400);
assert_eq!(errors[0].error_code, "MALFORMED_ID");
assert_eq!(errors[0].fields, vec!["FolderId".to_string()]);
}
other => panic!("expected Api error, got {other:?}"),
}
}
#[tokio::test]
async fn update_with_blob_uses_patch_and_targets_record_id_path() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/services/data/v66.0/sobjects/Document/015D000000000"))
.and(header_regex(
"content-type",
r"^multipart/form-data; boundary=",
))
.and(body_string_contains(r#"name="entity_content""#))
.and(body_string_contains(r#"name="Body""#))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let sf = fixture(server.uri());
sf.sobject("Document")
.update_with_blob(
"015D000000000",
BlobUploadSpec {
json_part_name: "entity_content",
metadata: &json!({"Name": "Updated"}),
blob_field_name: "Body",
filename: "updated.pdf",
content_type: Some("application/pdf"),
blob: bytes::Bytes::from_static(b"%PDF updated"),
},
)
.await
.unwrap();
}
}
}