#![allow(clippy::doc_markdown)]
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordUiRepresentation {
pub layout_user_states: HashMap<String, Value>,
pub layouts: HashMap<String, Value>,
pub object_infos: HashMap<String, Value>,
pub records: HashMap<String, RecordRepresentation>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordRepresentation {
pub api_name: String,
pub id: Option<String>,
pub fields: HashMap<String, crate::api::ui::types::FieldValueRepresentation>,
pub child_relationships: HashMap<String, Value>,
pub record_type_id: Option<String>,
pub system_modstamp: Option<String>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchResultRepresentation {
pub has_errors: bool,
pub results: Vec<Value>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecordDefaultsRepresentation {
pub layout: Value,
pub object_info: Value,
pub record: RecordRepresentation,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateRecordInput {
pub api_name: String,
pub fields: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateRecordInput {
pub fields: HashMap<String, Value>,
}
impl<A: crate::auth::Authenticator> crate::api::ui::UiHandler<A> {
pub async fn record_ui(
&self,
ids: &[&str],
layout_types: Option<&[crate::api::ui::types::LayoutType]>,
modes: Option<&[crate::api::ui::types::Mode]>,
) -> crate::error::Result<RecordUiRepresentation> {
for id in ids {
crate::types::validator::validate_identifier(id, "record id")?;
}
let mut path = String::with_capacity(10 + ids.len() * 19);
path.push_str("record-ui/");
for (i, id) in ids.iter().enumerate() {
if i > 0 {
path.push(',');
}
path.push_str(id);
}
let mut lt_str = String::new();
let mut mode_str = String::new();
let mut params_array = [("", ""); 2];
let mut params_len = 0;
if let Some(lts) = layout_types {
lt_str.reserve(lts.len() * 10);
for (i, lt) in lts.iter().enumerate() {
if i > 0 {
lt_str.push(',');
}
lt_str.push_str(lt.as_str());
}
params_array[params_len] = ("layoutTypes", <_str);
params_len += 1;
}
if let Some(ms) = modes {
mode_str.reserve(ms.len() * 10);
for (i, m) in ms.iter().enumerate() {
if i > 0 {
mode_str.push(',');
}
mode_str.push_str(m.as_str());
}
params_array[params_len] = ("modes", &mode_str);
params_len += 1;
}
let query = if params_len == 0 {
None
} else {
Some(¶ms_array[..params_len])
};
self.get(&path, query, "Failed to fetch record UI").await
}
pub async fn get_record(
&self,
id: &str,
fields: Option<&[&str]>,
) -> crate::error::Result<RecordRepresentation> {
crate::types::validator::validate_identifier(id, "record id")?;
let path = format!("records/{id}");
let mut fields_str = String::new();
let mut params_array = [("", ""); 1];
let mut params_len = 0;
if let Some(fs) = fields {
fields_str.reserve(fs.len() * 20);
for (i, f) in fs.iter().enumerate() {
if i > 0 {
fields_str.push(',');
}
fields_str.push_str(f);
}
params_array[params_len] = ("fields", &fields_str);
params_len += 1;
}
let query = if params_len == 0 {
None
} else {
Some(¶ms_array[..params_len])
};
self.get(&path, query, "Failed to fetch record").await
}
pub async fn get_records_batch(
&self,
ids: &[&str],
fields: Option<&[&str]>,
) -> crate::error::Result<BatchResultRepresentation> {
for id in ids {
crate::types::validator::validate_identifier(id, "record id")?;
}
let mut path = String::with_capacity(14 + ids.len() * 19);
path.push_str("records/batch/");
for (i, id) in ids.iter().enumerate() {
if i > 0 {
path.push(',');
}
path.push_str(id);
}
let mut fields_str = String::new();
let mut params_array = [("", ""); 1];
let mut params_len = 0;
if let Some(fs) = fields {
fields_str.reserve(fs.len() * 20);
for (i, f) in fs.iter().enumerate() {
if i > 0 {
fields_str.push(',');
}
fields_str.push_str(f);
}
params_array[params_len] = ("fields", &fields_str);
params_len += 1;
}
let query = if params_len == 0 {
None
} else {
Some(¶ms_array[..params_len])
};
self.get(&path, query, "Failed to fetch records batch")
.await
}
pub async fn create_record(
&self,
input: &CreateRecordInput,
) -> crate::error::Result<RecordRepresentation> {
self.post("records", input, "Failed to create record").await
}
pub async fn update_record(
&self,
id: &str,
input: &UpdateRecordInput,
) -> crate::error::Result<RecordRepresentation> {
crate::types::validator::validate_identifier(id, "record id")?;
let path = format!("records/{id}");
self.patch(&path, input, "Failed to update record").await
}
pub async fn delete_record(&self, id: &str) -> crate::error::Result<()> {
crate::types::validator::validate_identifier(id, "record id")?;
let path = format!("records/{id}");
self.delete_empty(&path, "Failed to delete record").await
}
pub async fn create_defaults(
&self,
object: &str,
) -> crate::error::Result<RecordDefaultsRepresentation> {
crate::types::validator::validate_sobject_name(object)?;
let path = format!("record-defaults/create/{object}");
self.get(&path, None, "Failed to fetch create defaults")
.await
}
pub async fn clone_defaults(
&self,
id: &str,
) -> crate::error::Result<RecordDefaultsRepresentation> {
crate::types::validator::validate_identifier(id, "record id")?;
let path = format!("record-defaults/clone/{id}");
self.get(&path, None, "Failed to fetch clone defaults")
.await
}
}
#[cfg(test)]
mod tests {
use crate::test_support::Must;
use super::*;
use crate::api::ui::types::{LayoutType, Mode};
use crate::client::builder;
use crate::test_support::MockAuthenticator;
use serde_json::json;
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
const VALID_ID: &str = "001000000000001AAA";
const VALID_ID2: &str = "001000000000002AAA";
async fn make_client(server: &MockServer) -> crate::client::ForceClient<MockAuthenticator> {
let auth = MockAuthenticator::new("test_token", &server.uri());
builder().authenticate(auth).build().await.must()
}
fn minimal_record_json(id: &str) -> serde_json::Value {
json!({
"apiName": "Account",
"id": id,
"fields": {
"Name": {
"displayValue": "Acme",
"value": "Acme"
}
},
"childRelationships": {},
"recordTypeId": null,
"systemModstamp": null
})
}
#[tokio::test]
async fn test_record_ui_success_single_id() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let response_body = json!({
"layoutUserStates": {},
"layouts": {},
"objectInfos": {},
"records": {
VALID_ID: minimal_record_json(VALID_ID)
}
});
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/record-ui/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
.expect(1)
.mount(&server)
.await;
let result = client.ui().record_ui(&[VALID_ID], None, None).await.must();
assert!(result.records.contains_key(VALID_ID));
}
#[tokio::test]
async fn test_record_ui_with_layout_types_and_modes() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let response_body = json!({
"layoutUserStates": {},
"layouts": {},
"objectInfos": {},
"records": {
VALID_ID: minimal_record_json(VALID_ID)
}
});
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/record-ui/{VALID_ID}"
)))
.and(query_param("layoutTypes", "Full"))
.and(query_param("modes", "View"))
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
.expect(1)
.mount(&server)
.await;
let result = client
.ui()
.record_ui(&[VALID_ID], Some(&[LayoutType::Full]), Some(&[Mode::View]))
.await
.must();
assert!(result.records.contains_key(VALID_ID));
}
#[tokio::test]
async fn test_record_ui_multiple_ids() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let joined = format!("{VALID_ID},{VALID_ID2}");
let response_body = json!({
"layoutUserStates": {},
"layouts": {},
"objectInfos": {},
"records": {
VALID_ID: minimal_record_json(VALID_ID),
VALID_ID2: minimal_record_json(VALID_ID2)
}
});
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/record-ui/{joined}"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
.expect(1)
.mount(&server)
.await;
let result = client
.ui()
.record_ui(&[VALID_ID, VALID_ID2], None, None)
.await
.must();
assert_eq!(result.records.len(), 2);
}
#[tokio::test]
async fn test_record_ui_not_found() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/record-ui/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(404).set_body_json(json!([{
"errorCode": "NOT_FOUND",
"message": "Record not found"
}])))
.expect(1)
.mount(&server)
.await;
let result = client.ui().record_ui(&[VALID_ID], None, None).await;
let Err(err) = result else {
panic!("Expected an error");
};
assert!(
matches!(
err,
crate::error::ForceError::Api(_) | crate::error::ForceError::Http(_)
),
"Expected Api or Http error, got: {err}"
);
}
#[tokio::test]
async fn test_get_record_success_no_fields() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/records/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_record_json(VALID_ID)))
.expect(1)
.mount(&server)
.await;
let record = client.ui().get_record(VALID_ID, None).await.must();
assert_eq!(record.api_name, "Account");
assert_eq!(record.id.as_deref(), Some(VALID_ID));
}
#[tokio::test]
async fn test_get_record_with_fields_param() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/records/{VALID_ID}"
)))
.and(query_param("fields", "Account.Name,Account.Phone"))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_record_json(VALID_ID)))
.expect(1)
.mount(&server)
.await;
let record = client
.ui()
.get_record(VALID_ID, Some(&["Account.Name", "Account.Phone"]))
.await
.must();
assert_eq!(record.api_name, "Account");
}
#[tokio::test]
async fn test_get_record_not_found() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/records/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(404).set_body_json(json!([{
"errorCode": "NOT_FOUND",
"message": "Record not found"
}])))
.expect(1)
.mount(&server)
.await;
let result = client.ui().get_record(VALID_ID, None).await;
let Err(err) = result else {
panic!("Expected an error");
};
assert!(
matches!(
err,
crate::error::ForceError::Api(_) | crate::error::ForceError::Http(_)
),
"Expected Api or Http error, got: {err}"
);
}
#[tokio::test]
async fn test_get_records_batch_success() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let joined = format!("{VALID_ID},{VALID_ID2}");
let response_body = json!({
"hasErrors": false,
"results": [
minimal_record_json(VALID_ID),
minimal_record_json(VALID_ID2)
]
});
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/records/batch/{joined}"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
.expect(1)
.mount(&server)
.await;
let result = client
.ui()
.get_records_batch(&[VALID_ID, VALID_ID2], None)
.await
.must();
assert!(!result.has_errors);
assert_eq!(result.results.len(), 2);
}
#[tokio::test]
async fn test_get_records_batch_with_fields() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let response_body = json!({
"hasErrors": false,
"results": [minimal_record_json(VALID_ID)]
});
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/records/batch/{VALID_ID}"
)))
.and(query_param("fields", "Account.Name"))
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
.expect(1)
.mount(&server)
.await;
let result = client
.ui()
.get_records_batch(&[VALID_ID], Some(&["Account.Name"]))
.await
.must();
assert!(!result.has_errors);
}
#[tokio::test]
async fn test_create_record_success() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("POST"))
.and(path("/services/data/v60.0/ui-api/records"))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_record_json(VALID_ID)))
.expect(1)
.mount(&server)
.await;
let input = CreateRecordInput {
api_name: "Account".to_string(),
fields: {
let mut m = HashMap::new();
m.insert("Name".to_string(), json!("Acme Corp"));
m
},
};
let record = client.ui().create_record(&input).await.must();
assert_eq!(record.api_name, "Account");
}
#[tokio::test]
async fn test_create_record_bad_request() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("POST"))
.and(path("/services/data/v60.0/ui-api/records"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"errorCode": "REQUIRED_FIELD_MISSING",
"message": "Required fields are missing: [Name]"
}])))
.expect(1)
.mount(&server)
.await;
let input = CreateRecordInput {
api_name: "Account".to_string(),
fields: HashMap::new(),
};
let result = client.ui().create_record(&input).await;
let Err(err) = result else {
panic!("Expected an error");
};
assert!(
matches!(
err,
crate::error::ForceError::Api(_) | crate::error::ForceError::Http(_)
),
"Expected Api or Http error, got: {err}"
);
}
#[tokio::test]
async fn test_update_record_success() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("PATCH"))
.and(path(format!(
"/services/data/v60.0/ui-api/records/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(minimal_record_json(VALID_ID)))
.expect(1)
.mount(&server)
.await;
let input = UpdateRecordInput {
fields: {
let mut m = HashMap::new();
m.insert("Phone".to_string(), json!("+1-555-0100"));
m
},
};
let record = client.ui().update_record(VALID_ID, &input).await.must();
assert_eq!(record.api_name, "Account");
}
#[tokio::test]
async fn test_update_record_not_found() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("PATCH"))
.and(path(format!(
"/services/data/v60.0/ui-api/records/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(404).set_body_json(json!([{
"errorCode": "NOT_FOUND",
"message": "Record not found"
}])))
.expect(1)
.mount(&server)
.await;
let input = UpdateRecordInput {
fields: HashMap::new(),
};
let result = client.ui().update_record(VALID_ID, &input).await;
let Err(err) = result else {
panic!("Expected an error");
};
assert!(
matches!(
err,
crate::error::ForceError::Api(_) | crate::error::ForceError::Http(_)
),
"Expected Api or Http error, got: {err}"
);
}
#[tokio::test]
async fn test_delete_record_success() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("DELETE"))
.and(path(format!(
"/services/data/v60.0/ui-api/records/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
client.ui().delete_record(VALID_ID).await.must();
}
#[tokio::test]
async fn test_delete_record_not_found() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("DELETE"))
.and(path(format!(
"/services/data/v60.0/ui-api/records/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(404).set_body_json(json!([{
"errorCode": "NOT_FOUND",
"message": "Record not found"
}])))
.expect(1)
.mount(&server)
.await;
let result = client.ui().delete_record(VALID_ID).await;
let Err(err) = result else {
panic!("Expected an error");
};
assert!(
matches!(
err,
crate::error::ForceError::Api(_) | crate::error::ForceError::Http(_)
),
"Expected Api or Http error, got: {err}"
);
}
#[tokio::test]
async fn test_create_defaults_success() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let defaults_record = json!({
"apiName": "Account",
"id": null,
"fields": {},
"childRelationships": {},
"recordTypeId": null,
"systemModstamp": null
});
let response = json!({
"layout": {},
"objectInfo": {},
"record": defaults_record
});
Mock::given(method("GET"))
.and(path(
"/services/data/v60.0/ui-api/record-defaults/create/Account",
))
.respond_with(ResponseTemplate::new(200).set_body_json(&response))
.expect(1)
.mount(&server)
.await;
let result = client.ui().create_defaults("Account").await.must();
assert_eq!(result.record.api_name, "Account");
}
#[tokio::test]
async fn test_create_defaults_unknown_object() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path(
"/services/data/v60.0/ui-api/record-defaults/create/NoSuchObject",
))
.respond_with(ResponseTemplate::new(404).set_body_json(json!([{
"errorCode": "NOT_FOUND",
"message": "The requested resource does not exist"
}])))
.expect(1)
.mount(&server)
.await;
let result = client.ui().create_defaults("NoSuchObject").await;
let Err(err) = result else {
panic!("Expected an error");
};
assert!(
matches!(
err,
crate::error::ForceError::Api(_) | crate::error::ForceError::Http(_)
),
"Expected Api or Http error, got: {err}"
);
}
#[tokio::test]
async fn test_clone_defaults_success() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let clone_record = json!({
"apiName": "Account",
"id": null,
"fields": {
"Name": {
"displayValue": "Acme (Copy)",
"value": "Acme (Copy)"
}
},
"childRelationships": {},
"recordTypeId": null,
"systemModstamp": null
});
let response = json!({
"layout": {},
"objectInfo": {},
"record": clone_record
});
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/record-defaults/clone/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(&response))
.expect(1)
.mount(&server)
.await;
let result = client.ui().clone_defaults(VALID_ID).await.must();
assert_eq!(result.record.api_name, "Account");
assert!(result.record.id.is_none());
}
#[tokio::test]
async fn test_clone_defaults_not_found() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path(format!(
"/services/data/v60.0/ui-api/record-defaults/clone/{VALID_ID}"
)))
.respond_with(ResponseTemplate::new(404).set_body_json(json!([{
"errorCode": "NOT_FOUND",
"message": "Record not found"
}])))
.expect(1)
.mount(&server)
.await;
let result = client.ui().clone_defaults(VALID_ID).await;
let Err(err) = result else {
panic!("Expected an error");
};
assert!(
matches!(
err,
crate::error::ForceError::Api(_) | crate::error::ForceError::Http(_)
),
"Expected Api or Http error, got: {err}"
);
}
#[test]
fn test_record_representation_deserialize() {
let json_str = r#"{
"apiName": "Contact",
"id": "003000000000001AAA",
"fields": {
"FirstName": {"displayValue": "Jane", "value": "Jane"}
},
"childRelationships": {},
"recordTypeId": null,
"systemModstamp": "2024-01-01T00:00:00.000Z"
}"#;
let record: RecordRepresentation = serde_json::from_str(json_str).must();
assert_eq!(record.api_name, "Contact");
assert_eq!(record.id.as_deref(), Some("003000000000001AAA"));
assert!(record.fields.contains_key("FirstName"));
assert_eq!(
record.system_modstamp.as_deref(),
Some("2024-01-01T00:00:00.000Z")
);
}
#[test]
fn test_create_record_input_serialize() {
let mut fields = HashMap::new();
fields.insert("Name".to_string(), json!("Test"));
let input = CreateRecordInput {
api_name: "Account".to_string(),
fields,
};
let serialized = serde_json::to_value(&input).must();
assert_eq!(serialized["apiName"], "Account");
assert_eq!(serialized["fields"]["Name"], "Test");
}
#[test]
fn test_update_record_input_serialize() {
let mut fields = HashMap::new();
fields.insert("Phone".to_string(), json!("+1-555-9999"));
let input = UpdateRecordInput { fields };
let serialized = serde_json::to_value(&input).must();
assert_eq!(serialized["fields"]["Phone"], "+1-555-9999");
}
#[test]
fn test_batch_result_representation_deserialize() {
let json_str = r#"{"hasErrors": false, "results": []}"#;
let result: BatchResultRepresentation = serde_json::from_str(json_str).must();
assert!(!result.has_errors);
assert!(result.results.is_empty());
}
#[test]
fn test_record_ui_representation_deserialize() {
let json_str = r#"{
"layoutUserStates": {},
"layouts": {},
"objectInfos": {},
"records": {}
}"#;
let ui: RecordUiRepresentation = serde_json::from_str(json_str).must();
assert!(ui.records.is_empty());
}
}