use crate::Cirrus;
use crate::error::CirrusResult;
use serde::Serialize;
use serde::de::DeserializeOwned;
impl Cirrus {
pub fn apex(&self) -> ApexHandler<'_> {
ApexHandler { client: self }
}
}
#[derive(Debug)]
pub struct ApexHandler<'a> {
client: &'a Cirrus,
}
impl ApexHandler<'_> {
pub async fn get<R: DeserializeOwned>(&self, path: &str) -> CirrusResult<R> {
self.client.get(&apex_path(path)).await
}
pub async fn get_with_query<R, Q>(&self, path: &str, query: &Q) -> CirrusResult<R>
where
R: DeserializeOwned,
Q: Serialize + ?Sized,
{
self.client.get_with_query(&apex_path(path), query).await
}
pub async fn post<R, B>(&self, path: &str, body: &B) -> CirrusResult<R>
where
R: DeserializeOwned,
B: Serialize + ?Sized,
{
self.client.post(&apex_path(path), body).await
}
pub async fn put<R, B>(&self, path: &str, body: &B) -> CirrusResult<R>
where
R: DeserializeOwned,
B: Serialize + ?Sized,
{
self.client.put(&apex_path(path), body).await
}
pub async fn patch<R, B>(&self, path: &str, body: &B) -> CirrusResult<R>
where
R: DeserializeOwned,
B: Serialize + ?Sized,
{
self.client.patch(&apex_path(path), body).await
}
pub async fn delete<R: DeserializeOwned>(&self, path: &str) -> CirrusResult<R> {
self.client.delete(&apex_path(path)).await
}
}
fn apex_path(path: &str) -> String {
format!("/services/apexrest/{}", path.trim_start_matches('/'))
}
#[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()
}
#[test]
fn apex_path_normalizes_relative_input() {
assert_eq!(apex_path("MyEndpoint"), "/services/apexrest/MyEndpoint");
}
#[test]
fn apex_path_normalizes_leading_slash_input() {
assert_eq!(apex_path("/MyEndpoint"), "/services/apexrest/MyEndpoint");
}
#[test]
fn apex_path_preserves_subpaths() {
assert_eq!(
apex_path("Cases/12345/comments"),
"/services/apexrest/Cases/12345/comments"
);
assert_eq!(
apex_path("/Cases/12345/comments"),
"/services/apexrest/Cases/12345/comments"
);
}
#[tokio::test]
async fn apex_get_targets_apexrest_path_without_version() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/apexrest/MyEndpoint"))
.and(header("authorization", "Bearer tok"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v: serde_json::Value = sf.apex().get("MyEndpoint").await.unwrap();
assert_eq!(v["ok"], true);
}
#[tokio::test]
async fn apex_get_accepts_leading_slash_input() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/apexrest/MyEndpoint"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"hit": true})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v: serde_json::Value = sf.apex().get("/MyEndpoint").await.unwrap();
assert_eq!(v["hit"], true);
}
#[tokio::test]
async fn apex_get_with_query_passes_params() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/apexrest/Cases"))
.and(query_param("status", "Open"))
.and(query_param("limit", "10"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([{"id": "500xx"}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v: serde_json::Value = sf
.apex()
.get_with_query("Cases", &[("status", "Open"), ("limit", "10")])
.await
.unwrap();
assert_eq!(v[0]["id"], "500xx");
}
#[tokio::test]
async fn apex_get_typed_response_deserializes_caller_struct() {
#[derive(serde::Deserialize)]
struct CaseSummary {
#[serde(rename = "Id")]
id: String,
#[serde(rename = "Subject")]
subject: String,
}
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/apexrest/Cases/500xx"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(json!({"Id": "500xx", "Subject": "Login issue"})),
)
.mount(&server)
.await;
let sf = fixture(server.uri());
let case: CaseSummary = sf.apex().get("Cases/500xx").await.unwrap();
assert_eq!(case.id, "500xx");
assert_eq!(case.subject, "Login issue");
}
#[tokio::test]
async fn apex_post_sends_json_body() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/services/apexrest/Cases"))
.and(body_json(json!({
"subject": "Login issue",
"priority": "High"
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({"id": "500xx"})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v: serde_json::Value = sf
.apex()
.post(
"Cases",
&json!({"subject": "Login issue", "priority": "High"}),
)
.await
.unwrap();
assert_eq!(v["id"], "500xx");
}
#[tokio::test]
async fn apex_put_sends_json_body() {
let server = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/services/apexrest/Settings/SomeKey"))
.and(body_json(json!({"value": "new"})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"updated": true})))
.mount(&server)
.await;
let sf = fixture(server.uri());
let v: serde_json::Value = sf
.apex()
.put("Settings/SomeKey", &json!({"value": "new"}))
.await
.unwrap();
assert_eq!(v["updated"], true);
}
#[tokio::test]
async fn apex_patch_handles_204_no_content() {
let server = MockServer::start().await;
Mock::given(method("PATCH"))
.and(path("/services/apexrest/Cases/500xx"))
.and(body_json(json!({"status": "Closed"})))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let sf = fixture(server.uri());
sf.apex()
.patch::<(), _>("Cases/500xx", &json!({"status": "Closed"}))
.await
.unwrap();
}
#[tokio::test]
async fn apex_delete_handles_204_no_content() {
let server = MockServer::start().await;
Mock::given(method("DELETE"))
.and(path("/services/apexrest/Cases/500xx"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
let sf = fixture(server.uri());
sf.apex().delete::<()>("Cases/500xx").await.unwrap();
}
#[tokio::test]
async fn apex_surfaces_standard_salesforce_error_array() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/apexrest/Missing"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!([{
"message": "Could not find a match for URL /Missing",
"errorCode": "NOT_FOUND"
}])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let err = sf
.apex()
.get::<serde_json::Value>("Missing")
.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 apex_handles_nested_subpath_with_record_id() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/services/apexrest/Cases/500xx0000000001/comments"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"author": "ryf", "text": "first"},
{"author": "other", "text": "second"}
])))
.mount(&server)
.await;
let sf = fixture(server.uri());
let comments: serde_json::Value = sf
.apex()
.get("/Cases/500xx0000000001/comments")
.await
.unwrap();
assert_eq!(comments.as_array().unwrap().len(), 2);
assert_eq!(comments[0]["author"], "ryf");
}
}