use crate::Cirrus;
use crate::error::CirrusResult;
use crate::response::{
DescribeGlobal, ExecuteAnonymousResult, QueryResult, SObjectCreateResult, SearchResult,
};
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
impl Cirrus {
pub fn tooling(&self) -> ToolingHandler<'_> {
ToolingHandler { client: self }
}
}
#[derive(Debug)]
pub struct ToolingHandler<'a> {
client: &'a Cirrus,
}
impl<'a> ToolingHandler<'a> {
pub async fn describe_global(&self) -> CirrusResult<DescribeGlobal> {
self.client.get("tooling/sobjects").await
}
pub fn sobject(&self, name: &'a str) -> ToolingSObjectHandler<'a> {
ToolingSObjectHandler {
client: self.client,
name,
}
}
pub async fn query(&self, soql: &str) -> CirrusResult<QueryResult<Value>> {
self.query_as(soql).await
}
pub async fn query_as<R: DeserializeOwned>(&self, soql: &str) -> CirrusResult<QueryResult<R>> {
let query = [("q", soql)];
self.client.get_with_query("tooling/query", &query).await
}
pub fn query_stream(&self, soql: &str) -> crate::pagination::Records<Value> {
self.query_stream_as(soql)
}
pub fn query_stream_as<R: DeserializeOwned + Send + Unpin + 'static>(
&self,
soql: &str,
) -> crate::pagination::Records<R> {
let client = self.client.clone();
let soql = soql.to_string();
let initial = Box::pin(async move {
let query = [("q", soql.as_str())];
client
.get_with_query::<QueryResult<R>, _>("tooling/query", &query)
.await
});
crate::pagination::Records::new(self.client.clone(), initial)
}
pub async fn search(&self, sosl: &str) -> CirrusResult<SearchResult<Value>> {
self.search_as(sosl).await
}
pub async fn search_as<R: DeserializeOwned>(
&self,
sosl: &str,
) -> CirrusResult<SearchResult<R>> {
let query = [("q", sosl)];
self.client.get_with_query("tooling/search", &query).await
}
pub async fn execute_anonymous(&self, apex: &str) -> CirrusResult<ExecuteAnonymousResult> {
let query = [("anonymousBody", apex)];
self.client
.get_with_query("tooling/executeAnonymous", &query)
.await
}
}
#[derive(Debug)]
pub struct ToolingSObjectHandler<'a> {
client: &'a Cirrus,
name: &'a str,
}
impl<'a> ToolingSObjectHandler<'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 path = format!("tooling/sobjects/{}/describe", self.name);
self.client.get(&path).await
}
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 path = format!("tooling/sobjects/{}/{}", self.name, id);
self.client.get(&path).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 path = format!("tooling/sobjects/{}/{}", self.name, id);
let joined = fields.join(",");
let query = [("fields", joined.as_str())];
self.client.get_with_query(&path, &query).await
}
pub async fn create<B>(&self, body: &B) -> CirrusResult<SObjectCreateResult>
where
B: Serialize + ?Sized,
{
let path = format!("tooling/sobjects/{}", self.name);
self.client.post(&path, body).await
}
pub async fn update<B>(&self, id: &str, body: &B) -> CirrusResult<()>
where
B: Serialize + ?Sized,
{
let path = format!("tooling/sobjects/{}/{}", self.name, id);
self.client.patch(&path, body).await
}
pub async fn delete(&self, id: &str) -> CirrusResult<()> {
let path = format!("tooling/sobjects/{}/{}", self.name, id);
self.client.delete(&path).await
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
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()
}
#[tokio::test]
async fn describe_global_targets_tooling_sobjects_root() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/tooling/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": false, "keyPrefix": "01p",
"label": "Apex Class", "labelPlural": "Apex Classes",
"layoutable": false, "mergeable": false, "mruEnabled": false,
"name": "ApexClass", "queryable": true, "replicateable": false,
"retrieveable": true, "searchable": false, "triggerable": false,
"undeletable": false, "updateable": true,
"urls": {
"sobject": "/services/data/v66.0/tooling/sobjects/ApexClass",
"describe": "/services/data/v66.0/tooling/sobjects/ApexClass/describe",
"rowTemplate": "/services/data/v66.0/tooling/sobjects/ApexClass/{ID}"
}
}]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let dg = sf.tooling().describe_global().await.unwrap();
assert_eq!(dg.sobjects.len(), 1);
assert_eq!(dg.sobjects[0].name, "ApexClass");
}
#[tokio::test]
async fn sobject_describe_targets_tooling_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/services/data/v66.0/tooling/sobjects/ApexClass/describe",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"name": "ApexClass",
"label": "Apex Class",
"fields": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v = sf.tooling().sobject("ApexClass").describe().await.unwrap();
assert_eq!(v["name"], "ApexClass");
}
#[tokio::test]
async fn sobject_retrieve_returns_record() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/services/data/v66.0/tooling/sobjects/ApexClass/01p000000000001",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"Id": "01p000000000001",
"Name": "MyClass",
"Body": "public class MyClass {}"
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v = sf
.tooling()
.sobject("ApexClass")
.retrieve("01p000000000001")
.await
.unwrap();
assert_eq!(v["Name"], "MyClass");
}
#[tokio::test]
async fn sobject_retrieve_with_fields_passes_csv_query_param() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path(
"/services/data/v66.0/tooling/sobjects/ApexClass/01p000000000001",
))
.and(query_param("fields", "Id,Name,Body"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"Id": "01p000000000001",
"Name": "MyClass",
"Body": "public class MyClass {}"
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v = sf
.tooling()
.sobject("ApexClass")
.retrieve_with_fields("01p000000000001", &["Id", "Name", "Body"])
.await
.unwrap();
assert_eq!(v["Name"], "MyClass");
}
#[tokio::test]
async fn sobject_create_posts_and_returns_create_result() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/data/v66.0/tooling/sobjects/ApexClass"))
.and(body_json(json!({
"Name": "Hello",
"Body": "public class Hello {}"
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"id": "01p000000000002",
"success": true,
"errors": []
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let res = sf
.tooling()
.sobject("ApexClass")
.create(&json!({
"Name": "Hello",
"Body": "public class Hello {}"
}))
.await
.unwrap();
assert!(res.success);
assert_eq!(res.id, "01p000000000002");
}
#[tokio::test]
async fn sobject_update_patches_and_returns_unit() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path(
"/services/data/v66.0/tooling/sobjects/ApexClass/01p000000000001",
))
.and(body_json(
json!({"Body": "public class MyClass { /* v2 */ }"}),
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let sf = fixture(server.uri());
sf.tooling()
.sobject("ApexClass")
.update(
"01p000000000001",
&json!({"Body": "public class MyClass { /* v2 */ }"}),
)
.await
.unwrap();
}
#[tokio::test]
async fn sobject_delete_returns_unit_on_204() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path(
"/services/data/v66.0/tooling/sobjects/ApexClass/01p000000000001",
))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let sf = fixture(server.uri());
sf.tooling()
.sobject("ApexClass")
.delete("01p000000000001")
.await
.unwrap();
}
#[tokio::test]
async fn query_targets_tooling_query_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/tooling/query"))
.and(query_param("q", "SELECT Id, Name FROM ApexClass"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"totalSize": 1,
"done": true,
"records": [
{"attributes": {"type": "ApexClass"}, "Id": "01p000000000001", "Name": "MyClass"}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let qr = sf
.tooling()
.query("SELECT Id, Name FROM ApexClass")
.await
.unwrap();
assert_eq!(qr.total_size, 1);
assert_eq!(qr.records[0]["Name"], "MyClass");
}
#[tokio::test]
async fn query_typed_records_into_caller_struct() {
#[derive(serde::Deserialize)]
struct Cls {
#[serde(rename = "Name")]
name: String,
}
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/tooling/query"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"totalSize": 1,
"done": true,
"records": [
{"attributes": {"type": "ApexClass"}, "Name": "MyClass"}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let qr = sf
.tooling()
.query_as::<Cls>("SELECT Name FROM ApexClass LIMIT 1")
.await
.unwrap();
assert_eq!(qr.records[0].name, "MyClass");
}
#[tokio::test]
async fn search_targets_tooling_search_endpoint() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/tooling/search"))
.and(query_param(
"q",
"FIND {MyClass} IN ALL FIELDS RETURNING ApexClass(Id, Name)",
))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"searchRecords": [
{"attributes": {"type": "ApexClass"}, "Id": "01p000000000001", "Name": "MyClass"}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let sr = sf
.tooling()
.search("FIND {MyClass} IN ALL FIELDS RETURNING ApexClass(Id, Name)")
.await
.unwrap();
assert_eq!(sr.search_records.len(), 1);
assert_eq!(sr.search_records[0]["Name"], "MyClass");
}
#[tokio::test]
async fn search_typed_records_into_caller_struct() {
#[derive(serde::Deserialize)]
struct ClsHit {
#[serde(rename = "Id")]
id: String,
}
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/tooling/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"searchRecords": [
{"attributes": {"type": "ApexClass"}, "Id": "01p000000000001"}
]
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let sr = sf
.tooling()
.search_as::<ClsHit>("FIND {anything} RETURNING ApexClass(Id)")
.await
.unwrap();
assert_eq!(sr.search_records[0].id, "01p000000000001");
}
#[tokio::test]
async fn execute_anonymous_success_path() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/tooling/executeAnonymous"))
.and(query_param("anonymousBody", "System.debug('hello world');"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"compiled": true,
"compileProblem": null,
"success": true,
"line": -1,
"column": -1,
"exceptionMessage": null,
"exceptionStackTrace": null
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let res = sf
.tooling()
.execute_anonymous("System.debug('hello world');")
.await
.unwrap();
assert!(res.compiled);
assert!(res.success);
assert_eq!(res.line, -1);
assert_eq!(res.column, -1);
assert!(res.compile_problem.is_none());
assert!(res.exception_message.is_none());
}
#[tokio::test]
async fn execute_anonymous_compile_error_populates_compile_problem() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/tooling/executeAnonymous"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"compiled": false,
"compileProblem": "Variable does not exist: foo",
"success": false,
"line": 1,
"column": 5,
"exceptionMessage": null,
"exceptionStackTrace": null
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let res = sf.tooling().execute_anonymous("foo.bar();").await.unwrap();
assert!(!res.compiled);
assert!(!res.success);
assert_eq!(
res.compile_problem.as_deref(),
Some("Variable does not exist: foo"),
);
assert_eq!(res.line, 1);
assert_eq!(res.column, 5);
}
#[tokio::test]
async fn execute_anonymous_runtime_error_populates_exception_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/tooling/executeAnonymous"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"compiled": true,
"compileProblem": null,
"success": false,
"line": 2,
"column": 1,
"exceptionMessage": "System.NullPointerException: Attempt to de-reference a null object",
"exceptionStackTrace": "AnonymousBlock: line 2, column 1"
})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let res = sf
.tooling()
.execute_anonymous("Account a; a.Name = 'x';")
.await
.unwrap();
assert!(res.compiled);
assert!(!res.success);
assert!(
res.exception_message
.as_deref()
.unwrap_or_default()
.contains("NullPointerException")
);
assert!(res.exception_stack_trace.is_some());
}
#[tokio::test]
async fn errors_use_standard_salesforce_error_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/data/v66.0/tooling/query"))
.respond_with(ResponseTemplate::new(400).set_body_json(json!([{
"message": "unexpected token: SELECTT",
"errorCode": "MALFORMED_QUERY"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf
.tooling()
.query("SELECTT Id FROM ApexClass")
.await
.unwrap_err();
match err {
crate::CirrusError::Api { status, errors, .. } => {
assert_eq!(status, 400);
assert_eq!(errors[0].error_code, "MALFORMED_QUERY");
}
other => panic!("expected Api error, got {other:?}"),
}
}
}