#![allow(clippy::doc_markdown)]
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ObjectInfoRepresentation {
pub api_name: String,
pub label: String,
pub label_plural: String,
pub key_prefix: Option<String>,
pub fields: HashMap<String, FieldInfoRepresentation>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldInfoRepresentation {
pub api_name: String,
pub label: String,
pub data_type: String,
pub required: bool,
pub updateable: bool,
pub createable: bool,
pub reference_to_infos: Vec<ReferenceToInfoRepresentation>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReferenceToInfoRepresentation {
pub api_name: String,
pub name_fields: Vec<String>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchObjectInfoRepresentation {
pub has_errors: bool,
pub results: Vec<Value>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
impl<A: crate::auth::Authenticator> crate::api::ui::UiHandler<A> {
pub async fn object_info(
&self,
object: &str,
) -> crate::error::Result<ObjectInfoRepresentation> {
crate::types::validator::validate_sobject_name(object)?;
let path = format!("object-info/{object}");
self.get(&path, None, "Failed to fetch object info").await
}
pub async fn object_infos_batch(
&self,
objects: &[&str],
) -> crate::error::Result<BatchObjectInfoRepresentation> {
for object in objects {
crate::types::validator::validate_sobject_name(object)?;
}
let capacity = 18 + objects.iter().map(|s| s.len() + 1).sum::<usize>();
let mut path = String::with_capacity(capacity);
path.push_str("object-info/batch/");
for (i, obj) in objects.iter().enumerate() {
if i > 0 {
path.push(',');
}
path.push_str(obj);
}
self.get(&path, None, "Failed to fetch batch object infos")
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client::builder;
use crate::test_support::{MockAuthenticator, Must};
use serde_json::json;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
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 account_object_info_json() -> serde_json::Value {
json!({
"apiName": "Account",
"label": "Account",
"labelPlural": "Accounts",
"keyPrefix": "001",
"fields": {
"Name": {
"apiName": "Name",
"label": "Account Name",
"dataType": "String",
"required": true,
"updateable": true,
"createable": true,
"referenceToInfos": []
},
"OwnerId": {
"apiName": "OwnerId",
"label": "Owner ID",
"dataType": "Reference",
"required": false,
"updateable": true,
"createable": true,
"referenceToInfos": [
{
"apiName": "User",
"nameFields": ["Name"]
}
]
}
}
})
}
#[tokio::test]
async fn test_object_info_success() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path("/services/data/v60.0/ui-api/object-info/Account"))
.respond_with(ResponseTemplate::new(200).set_body_json(account_object_info_json()))
.expect(1)
.mount(&server)
.await;
let info = client.ui().object_info("Account").await.must();
assert_eq!(info.api_name, "Account");
assert_eq!(info.label, "Account");
assert_eq!(info.label_plural, "Accounts");
assert_eq!(info.key_prefix.as_deref(), Some("001"));
assert!(info.fields.contains_key("Name"));
assert!(info.fields.contains_key("OwnerId"));
}
#[tokio::test]
async fn test_object_info_not_found() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path("/services/data/v60.0/ui-api/object-info/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().object_info("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_object_infos_batch_success() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let response_body = json!({
"hasErrors": false,
"results": [
account_object_info_json(),
{
"apiName": "Contact",
"label": "Contact",
"labelPlural": "Contacts",
"keyPrefix": "003",
"fields": {}
}
]
});
Mock::given(method("GET"))
.and(path(
"/services/data/v60.0/ui-api/object-info/batch/Account,Contact",
))
.respond_with(ResponseTemplate::new(200).set_body_json(&response_body))
.expect(1)
.mount(&server)
.await;
let result = client
.ui()
.object_infos_batch(&["Account", "Contact"])
.await
.must();
assert!(!result.has_errors);
assert_eq!(result.results.len(), 2);
}
#[tokio::test]
async fn test_object_infos_batch_error() {
let server = MockServer::start().await;
let client = make_client(&server).await;
Mock::given(method("GET"))
.and(path(
"/services/data/v60.0/ui-api/object-info/batch/Account,BadObject",
))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"errorCode": "INVALID_TYPE",
"message": "sObject type 'BadObject' is not supported"
}])))
.expect(1)
.mount(&server)
.await;
let result = client
.ui()
.object_infos_batch(&["Account", "BadObject"])
.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_object_info_representation_deserialize() {
let json_str = r#"{
"apiName": "Opportunity",
"label": "Opportunity",
"labelPlural": "Opportunities",
"keyPrefix": "006",
"fields": {
"Name": {
"apiName": "Name",
"label": "Opportunity Name",
"dataType": "String",
"required": true,
"updateable": true,
"createable": true,
"referenceToInfos": []
}
}
}"#;
let info: ObjectInfoRepresentation = serde_json::from_str(json_str).must();
assert_eq!(info.api_name, "Opportunity");
assert_eq!(info.label_plural, "Opportunities");
assert_eq!(info.key_prefix.as_deref(), Some("006"));
assert!(info.fields.contains_key("Name"));
let name_field = &info.fields["Name"];
assert!(name_field.required);
assert!(name_field.updateable);
assert!(name_field.createable);
assert!(name_field.reference_to_infos.is_empty());
}
#[test]
fn test_field_info_representation_deserialize() {
let json_str = r#"{
"apiName": "AccountId",
"label": "Account ID",
"dataType": "Reference",
"required": false,
"updateable": false,
"createable": true,
"referenceToInfos": [
{
"apiName": "Account",
"nameFields": ["Name"]
}
]
}"#;
let field: FieldInfoRepresentation = serde_json::from_str(json_str).must();
assert_eq!(field.api_name, "AccountId");
assert_eq!(field.data_type, "Reference");
assert!(!field.required);
assert!(!field.updateable);
assert!(field.createable);
assert_eq!(field.reference_to_infos.len(), 1);
let ref_info = &field.reference_to_infos[0];
assert_eq!(ref_info.api_name, "Account");
assert_eq!(ref_info.name_fields, vec!["Name"]);
}
#[tokio::test]
async fn test_object_info_invalid_sobject_name() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let result = client.ui().object_info("Account; DROP TABLE").await;
assert!(result.is_err());
assert!(
match result {
Err(e) => e,
Ok(_) => panic!("Expected error"),
}
.to_string()
.contains("contains invalid characters")
);
}
#[tokio::test]
async fn test_object_infos_batch_invalid_sobject_name() {
let server = MockServer::start().await;
let client = make_client(&server).await;
let result = client
.ui()
.object_infos_batch(&["Account", "Contact; DROP TABLE"])
.await;
assert!(result.is_err());
assert!(
match result {
Err(e) => e,
Ok(_) => panic!("Expected error"),
}
.to_string()
.contains("contains invalid characters")
);
}
}