Salesforce toolkit (#360)

Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
This commit is contained in:
Renato Byrro 2025-04-18 14:28:27 -03:00 committed by GitHub
parent eb6b7f3987
commit e1c293ee4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1846 additions and 0 deletions

View file

@ -0,0 +1,18 @@
files: ^./
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: "v4.4.0"
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-toml
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.7
hooks:
- id: ruff
args: [--fix]
- id: ruff-format

View file

@ -0,0 +1,46 @@
target-version = "py310"
line-length = 100
fix = true
[lint]
select = [
# flake8-2020
"YTT",
# flake8-bandit
"S",
# flake8-bugbear
"B",
# flake8-builtins
"A",
# flake8-comprehensions
"C4",
# flake8-debugger
"T10",
# flake8-simplify
"SIM",
# isort
"I",
# mccabe
"C90",
# pycodestyle
"E", "W",
# pyflakes
"F",
# pygrep-hooks
"PGH",
# pyupgrade
"UP",
# ruff
"RUF",
# tryceratops
"TRY",
]
[lint.per-file-ignores]
"*" = ["TRY003", "B904"]
"**/tests/*" = ["S101", "E501"]
"**/evals/*" = ["S101", "E501"]
[format]
preview = true
skip-magic-trailing-comma = false

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Arcade
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,58 @@
.PHONY: help
help:
@echo "🛠️ dropbox Commands:\n"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.PHONY: install
install: ## Install the poetry environment and install the pre-commit hooks
@echo "📦 Checking if Poetry is installed"
@if ! command -v poetry >/dev/null 2>&1; then \
echo "📦 Poetry not found. Checking if pip is available"; \
if ! command -v pip >/dev/null 2>&1; then \
echo "❌ pip is not installed. Please install pip first."; \
exit 1; \
fi; \
echo "📦 Installing Poetry with pip"; \
pip install poetry==1.8.5; \
else \
echo "📦 Poetry is already installed"; \
fi
@echo "🚀 Installing package in development mode with all extras"
poetry install --all-extras
.PHONY: build
build: clean-build ## Build wheel file using poetry
@echo "🚀 Creating wheel file"
poetry build
.PHONY: clean-build
clean-build: ## clean build artifacts
@echo "🗑️ Cleaning dist directory"
rm -rf dist
.PHONY: test
test: ## Test the code with pytest
@echo "🚀 Testing code: Running pytest"
@poetry run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
.PHONY: coverage
coverage: ## Generate coverage report
@echo "coverage report"
coverage report
@echo "Generating coverage report"
coverage html
.PHONY: bump-version
bump-version: ## Bump the version in the pyproject.toml file
@echo "🚀 Bumping version in pyproject.toml"
poetry version patch
.PHONY: check
check: ## Run code quality tools.
@echo "🚀 Checking Poetry lock file consistency with 'pyproject.toml': Running poetry check"
@poetry check
@echo "🚀 Linting code: Running pre-commit"
@poetry run pre-commit run -a
@echo "🚀 Static type checking: Running mypy"
@poetry run mypy --config-file=pyproject.toml

View file

@ -0,0 +1,33 @@
import os
SALESFORCE_API_VERSION = "v63.0"
DEFAULT_MAX_CONCURRENT_REQUESTS = 3
try:
MAX_CONCURRENT_REQUESTS = int(
os.getenv("ARCADE_SALESFORCE_MAX_CONCURRENT_REQUESTS", DEFAULT_MAX_CONCURRENT_REQUESTS)
)
except ValueError:
MAX_CONCURRENT_REQUESTS = DEFAULT_MAX_CONCURRENT_REQUESTS
ASSOCIATION_REFERENCE_FIELDS = [
"AccountId",
"OwnerId",
"AssociatedToWhom",
"ContactId",
]
GLOBALLY_IGNORED_FIELDS = [
"attributes",
"CleanStatus",
"CreatedById",
"CreatedDate",
"IsDeleted",
"LastModifiedById",
"LastModifiedDate",
"LastReferencedDate",
"LastViewedDate",
"PhotoUrl",
"SystemModstamp",
"WhatId",
]

View file

@ -0,0 +1,21 @@
from enum import Enum
from typing import cast
class SalesforceObject(Enum):
ACCOUNT = "Account"
CALL = "Call"
CONTACT = "Contact"
EMAIL = "Email"
EVENT = "Event"
LEAD = "Lead"
NOTE = "Note"
OPPORTUNITY = "Opportunity"
TASK = "Task"
USER = "User"
@property
def plural(self) -> str:
if self == SalesforceObject.OPPORTUNITY:
return "Opportunities"
return cast(str, self.value) + "s"

View file

@ -0,0 +1,22 @@
from arcade.sdk.errors import ToolExecutionError
class SalesforceToolExecutionError(ToolExecutionError):
def __init__(self, errors: list[str], message: str = "Failed to execute Salesforce tool"):
self.message = message
self.errors = errors
exc_message = f"{message}. Errors: {errors}"
super().__init__(
message=exc_message,
developer_message=exc_message,
)
class ResourceNotFoundError(SalesforceToolExecutionError):
def __init__(self, errors: list[str]):
super().__init__(message="Resource not found", errors=errors)
class BadRequestError(SalesforceToolExecutionError):
def __init__(self, errors: list[str]):
super().__init__(message="Bad request", errors=errors)

View file

@ -0,0 +1,495 @@
import asyncio
import os
from dataclasses import dataclass
from typing import Any, cast
import httpx
from arcade_salesforce.constants import MAX_CONCURRENT_REQUESTS, SALESFORCE_API_VERSION
from arcade_salesforce.enums import SalesforceObject
from arcade_salesforce.exceptions import (
BadRequestError,
ResourceNotFoundError,
SalesforceToolExecutionError,
)
from arcade_salesforce.utils import (
build_soql_query,
clean_contact_data,
clean_lead_data,
clean_note_data,
clean_object_data,
clean_opportunity_data,
clean_task_data,
expand_associations,
get_ids_referenced,
get_object_type,
remove_none_values,
)
@dataclass
class SalesforceClient:
auth_token: str
org_domain: str | None = None
api_version: str = SALESFORCE_API_VERSION
max_concurrent_requests: int = MAX_CONCURRENT_REQUESTS
# Internal state properties
_state_object_fields: dict[SalesforceObject, list[str]] | None = None
_state_is_person_account_enabled: bool | None = None
_semaphore: asyncio.Semaphore | None = None
def __post_init__(self) -> None:
if self.org_domain is None:
self.org_domain = os.getenv("SALESFORCE_ORG_DOMAIN")
if self.org_domain is None:
raise ValueError(
"Either `org_domain` argument or `SALESFORCE_ORG_DOMAIN` env var must be set"
)
if self._state_object_fields is None:
self._state_object_fields = {}
if self._semaphore is None:
self._semaphore = asyncio.Semaphore(self.max_concurrent_requests)
@property
def _base_url(self) -> str:
return f"https://{self.org_domain}.my.salesforce.com/services/data/{self.api_version}"
@property
def object_fields(self) -> dict[SalesforceObject, list[str]]:
return cast(dict, self._state_object_fields)
def _endpoint_url(self, endpoint: str) -> str:
return f"{self._base_url}/{endpoint.lstrip('/')}"
def _build_headers(self, headers: dict | None = None) -> dict:
headers = headers or {}
headers["Authorization"] = f"Bearer {self.auth_token}"
return headers
def _raise_salesforce_error(self, response: httpx.Response) -> None:
"""Raise a ToolExecutionError based on the Salesforce API response status code."""
errors = [error.get("message") for error in response.json()]
if response.status_code == 404:
raise ResourceNotFoundError(errors)
elif response.status_code == 400:
raise BadRequestError(errors)
raise SalesforceToolExecutionError(errors)
async def get(
self,
endpoint: str,
params: dict | None = None,
headers: dict | None = None,
) -> dict:
"""Make a GET request to the Salesforce API.
Args:
endpoint: The Salesforce API endpoint to call.
params: The query parameters to include in the request.
headers: The headers to include in the request.
Returns:
The JSON-loaded representation of the response from the Salesforce API.
"""
kwargs: dict[str, Any] = {
"url": self._endpoint_url(endpoint),
"headers": self._build_headers(headers),
}
if params:
kwargs["params"] = params
async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr]
response = await client.get(**kwargs)
if response.status_code >= 300:
self._raise_salesforce_error(response)
return cast(dict, response.json())
async def post(
self,
endpoint: str,
data: dict | None = None,
json_data: dict | None = None,
headers: dict | None = None,
) -> dict:
"""Make a POST request to the Salesforce API.
Args:
endpoint: The Salesforce API endpoint to call.
data: The data for the request. (provide data or json_data, not both)
json_data: The JSON data for the request. (provide data or json_data, not both)
headers: The headers to include in the request.
Returns:
The JSON-loaded representation of the response from the Salesforce API.
"""
kwargs: dict[str, Any] = {
"url": self._endpoint_url(endpoint),
"headers": self._build_headers(headers),
}
if data:
kwargs["data"] = data
if json_data:
kwargs["json"] = json_data
async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr]
response = await client.post(**kwargs)
if response.status_code >= 300:
self._raise_salesforce_error(response)
return cast(dict, response.json())
async def get_object_fields(self, object_type: SalesforceObject) -> list[str]:
"""Get the fields available for a Salesforce object.
Args:
object_type: The Salesforce object to get the fields for.
Returns:
The list of fields available for a Salesforce object.
"""
if object_type not in self.object_fields:
response = await self._describe_object(object_type)
self.object_fields[object_type] = [field["name"] for field in response["fields"]]
return self.object_fields[object_type]
async def _describe_object(self, object_type: SalesforceObject) -> dict:
return await self.get(f"sobjects/{object_type.value}/describe/")
async def _get_related_objects(
self,
child_object_type: SalesforceObject,
parent_object_type: SalesforceObject,
parent_object_id: str,
limit: int | None = 10,
) -> list[dict]:
"""Get the objects that are associated with another Salesforce object.
Args:
child_object_type: The type of child object to retrieve.
parent_object_type: The type of parent object.
parent_object_id: The ID of the parent object.
limit: The maximum number of related objects to retrieve.
Returns:
The list of related objects.
"""
try:
response = await self.get(
f"sobjects/{parent_object_type.value}/{parent_object_id}/{child_object_type.plural.lower()}",
params={"limit": limit},
)
return cast(list, response["records"])
except ResourceNotFoundError:
return []
async def get_object_by_id(self, object_id: str) -> dict | None:
"""Get a Salesforce object by its ID.
Args:
object_id: The ID of the Salesforce object to retrieve.
Returns:
The Salesforce object.
"""
try:
response = await self.get(f"sobjects/{object_id}")
return clean_object_data(response)
except ResourceNotFoundError:
if "User" not in object_id:
return await self.get_object_by_id(f"User/{object_id}")
return None
async def get_account(self, account_id: str) -> dict[str, Any] | None:
"""Get an account by its ID.
Args:
account_id: The ID of the account to retrieve.
Returns:
The account.
"""
try:
return cast(dict, await self.get(f"sobjects/Account/{account_id}"))
except ResourceNotFoundError:
return None
async def get_account_contacts(self, account_id: str, limit: int | None = 10) -> list[dict]:
"""Get the contacts associated with an account.
Args:
account_id: The ID of the account to retrieve the contacts for.
limit: The maximum number of contacts to retrieve.
Returns:
The list of contacts with cleaned and standardized dictionaries.
"""
contacts = await self._get_related_objects(
SalesforceObject.CONTACT, SalesforceObject.ACCOUNT, account_id, limit
)
return [
clean_contact_data(contact)
for contact in await asyncio.gather(*[
self.enrich_contact_with_notes(contact, limit) for contact in contacts
])
]
async def enrich_contact_with_notes(self, contact: dict, limit: int | None = 10) -> dict:
"""Get the notes associated with a contact and add to the contact dictionary.
Args:
contact: The contact to retrieve the notes for.
limit: The maximum number of notes to retrieve.
Returns:
The contact with the notes added to the dictionary in the key `Notes`.
"""
notes = await self.get_notes(contact["Id"], limit)
if notes:
contact["Notes"] = notes
return contact
async def get_account_leads(self, account_id: str, limit: int | None = 10) -> list[dict]:
"""Get the leads associated with an account.
Args:
account_id: The ID of the account to retrieve the leads for.
limit: The maximum number of leads to retrieve.
Returns:
The list of leads with cleaned and standardized dictionaries.
"""
leads = await self._get_related_objects(
SalesforceObject.LEAD, SalesforceObject.ACCOUNT, account_id, limit
)
return [clean_lead_data(lead) for lead in leads]
async def get_notes(self, parent_id: str, limit: int | None = 10) -> list[dict]:
"""Get the notes associated with a Salesforce object.
Args:
parent_id: The ID of the Salesforce object to retrieve the notes for.
limit: The maximum number of notes to retrieve.
Returns:
The list of notes with cleaned and standardized dictionaries.
"""
query = build_soql_query(
"SELECT Id, Title, Body, OwnerId, CreatedById, CreatedDate "
"FROM Note "
"WHERE ParentId = '{parent_id}' "
"LIMIT {limit}",
parent_id=parent_id,
limit=limit,
)
response = await self.get("query", params={"q": query})
notes = response["records"]
return [clean_note_data(note) for note in notes]
# TODO: Add support for retrieving Currency, when enabled in the org account.
# If not enabled and we try to retrieve it, we get a 400 error.
# More inf: https://developer.salesforce.com/docs/atlas.en-us.254.0.object_reference.meta/object_reference/sforce_api_objects_opportunity.htm#i1455437
async def get_account_opportunities(
self,
account_id: str,
limit: int | None = 10,
) -> list[dict]:
"""Get the opportunities associated with an account.
Args:
account_id: The ID of the account to retrieve the opportunities for.
limit: The maximum number of opportunities to retrieve.
Returns:
The list of opportunities with cleaned and standardized dictionaries.
"""
query = build_soql_query(
"SELECT Id, Name, Type, StageName, OwnerId, CreatedById, LastModifiedById, "
"Description, Amount, Probability, ExpectedRevenue, CloseDate, ContactId "
"FROM Opportunity "
"WHERE AccountId = '{account_id}' "
"LIMIT {limit}",
account_id=account_id,
limit=limit,
)
response = await self.get("query", params={"q": query})
opportunities = response["records"]
return [clean_opportunity_data(opportunity) for opportunity in opportunities]
async def get_account_tasks(
self,
account_id: str,
limit: int | None = 10,
) -> list[dict]:
"""Get the tasks associated with an account.
Args:
account_id: The ID of the account to retrieve the tasks for.
limit: The maximum number of tasks to retrieve.
Returns:
The list of tasks with cleaned and standardized dictionaries.
"""
tasks = await self._get_related_objects(
SalesforceObject.TASK, SalesforceObject.ACCOUNT, account_id, limit
)
return [clean_task_data(task) for task in tasks]
async def enrich_account(
self,
account_id: str | None = None,
account_data: dict[str, Any] | None = None,
limit_per_association: int | None = 10,
) -> dict:
"""Enrich account dictionary with contacts, leads, opportunities, and tasks.
Provide `account_id` or `account_data`, but not both.
Args:
account_id: The ID of the account to retrieve the associations for.
account_data: The account data to enrich.
limit_per_association: The maximum number of associations to retrieve.
Returns:
The account with the associations added to the dictionary.
"""
if (account_id and account_data) or (not account_id and not account_data):
raise ValueError("Must provide exactly one of `account_id` or `account_data`")
if account_data is None:
account_data = await self.get_account(cast(str, account_id))
if not account_data:
raise ResourceNotFoundError([f"Account not found with ID: {account_id}"])
if not account_id:
account_id = cast(str, account_data["Id"])
associations = await asyncio.gather(
self.get_account_contacts(account_id, limit=limit_per_association),
self.get_account_leads(account_id, limit=limit_per_association),
self.get_account_opportunities(account_id, limit=limit_per_association),
self.get_account_tasks(account_id, limit=limit_per_association),
)
for association in associations:
for item in association:
try:
obj_type = SalesforceObject(get_object_type(item)).plural
except ValueError:
obj_type = get_object_type(item) + "s"
if obj_type not in account_data:
account_data[obj_type] = []
account_data[obj_type].append(item)
return await self.expand_account_associations(account_data)
async def expand_account_associations(self, account: dict) -> dict:
"""Expand with metadata about objects referenced by ID in an account dictionary.
This method will, for example, expand an `OwnerId` or `ContactId` referenced in an account
dictionary with the owner or contact name, for example.
Args:
account: The account dictionary to expand.
Returns:
The account with the associations expanded.
"""
objects_by_id = {
obj["Id"]: obj
for obj_type in SalesforceObject
for obj in account.get(obj_type.plural, [])
}
objects_by_id[account["Id"]] = account
referenced_ids = get_ids_referenced(
account,
*[account.get(obj_type.plural, []) for obj_type in SalesforceObject],
)
missing_referenced_ids = [ref for ref in referenced_ids if ref not in objects_by_id]
if missing_referenced_ids:
missing_objects = await asyncio.gather(*[
self.get_object_by_id(missing_id) for missing_id in missing_referenced_ids
])
objects_by_id.update({obj["Id"]: obj for obj in missing_objects if obj is not None})
account = expand_associations(account, objects_by_id)
for object_type in SalesforceObject:
if object_type.plural not in account:
continue
expanded_items = []
for item in account[object_type.plural]:
if "AccountId" in item:
del item["AccountId"]
expanded_items.append(expand_associations(item, objects_by_id))
if object_type == SalesforceObject.CONTACT:
for contact in expanded_items:
if "Notes" in contact:
contact["Notes"] = [
expand_associations(note, objects_by_id) for note in contact["Notes"]
]
account[object_type.plural] = expanded_items
return account
async def create_contact(
self,
account_id: str,
last_name: str,
first_name: str | None = None,
email: str | None = None,
phone: str | None = None,
mobile_phone: str | None = None,
title: str | None = None,
department: str | None = None,
description: str | None = None,
) -> dict:
"""Create a contact in Salesforce.
Args:
account_id: The ID of the account to associate the contact with.
last_name: The last name of the contact.
first_name: The first name of the contact.
email: The email of the contact.
phone: The phone number of the contact.
mobile_phone: The mobile phone number of the contact.
title: The title of the contact.
department: The department of the contact.
description: The description of the contact.
Returns:
The created contact.
"""
data = {
"AccountId": account_id,
"FirstName": first_name,
"LastName": last_name,
"Email": email,
"Phone": phone,
"MobilePhone": mobile_phone,
"Title": title,
"Department": department,
"Description": description,
}
return await self.post(
f"sobjects/{SalesforceObject.CONTACT.value}",
json_data=remove_none_values(data),
)

View file

@ -0,0 +1,11 @@
from arcade_salesforce.tools.crm.account import (
get_account_data_by_id,
get_account_data_by_keywords,
)
from arcade_salesforce.tools.crm.contact import create_contact
__all__ = [
"create_contact",
"get_account_data_by_id",
"get_account_data_by_keywords",
]

View file

@ -0,0 +1,126 @@
import asyncio
from typing import Annotated
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import OAuth2
from arcade.sdk.errors import ToolExecutionError
from arcade_salesforce.enums import SalesforceObject
from arcade_salesforce.models import SalesforceClient
from arcade_salesforce.utils import clean_account_data
# TODO: We only return up to 10 items of each related object (e.g. contacts). Need to implement
# separate tools for each related object, so that we can return more items, when needed.
@tool(
requires_auth=OAuth2(
id="arcade-salesforce",
scopes=[
"read_account",
"read_contact",
"read_lead",
"read_note",
"read_opportunity",
"read_task",
],
)
)
async def get_account_data_by_keywords(
context: ToolContext,
query: Annotated[
str,
"The query to search for accounts. MUST be longer than one character. It will match the "
"keywords against all account fields, such as name, website, phone, address, etc. "
"E.g. 'Acme'",
],
# Note: Salesforce supports up to 200 results, but since we're enriching each account with
# related objects, we limit to 10, so that the response is not too lengthy for LLMs.
limit: Annotated[
int,
"The maximum number of accounts to return. Defaults to 10. Maximum allowed is 10.",
] = 10,
page: Annotated[int, "The page number to return. Defaults to 1 (first page of results)."] = 1,
) -> Annotated[
dict,
"The accounts matching the query with related info: contacts, leads, notes, calls, "
"opportunities, tasks, emails, and events (up to 10 items of each type)",
]:
"""Searches for accounts in Salesforce and returns them with related info: contacts, leads,
notes, calls, opportunities, tasks, emails, and events (up to 10 items of each type).
An account is an organization (such as a customer, supplier, or partner, though more commonly
a customer). In some Salesforce account setups, an account can also represent a person.
"""
if len(query) < 2:
raise ToolExecutionError("The `query` argument must have two or more characters.")
limit = min(limit, 10)
client = SalesforceClient(context.get_auth_token_or_empty())
params = {
"q": query,
"sobjects": [
{
"name": "Account",
"fields": await client.get_object_fields(SalesforceObject.ACCOUNT),
}
],
"in": "ALL",
"overallLimit": limit,
"offset": (page - 1) * limit,
}
response = await client.post("parameterizedSearch", json_data=params)
search_results = response["searchRecords"]
accounts = await asyncio.gather(*[
client.enrich_account(
account_data=account,
limit_per_association=10,
)
for account in search_results
])
return {"accounts": [clean_account_data(account) for account in accounts]}
@tool(
requires_auth=OAuth2(
id="arcade-salesforce",
scopes=[
"read_account",
"read_contact",
"read_lead",
"read_note",
"read_opportunity",
"read_task",
],
)
)
async def get_account_data_by_id(
context: ToolContext,
account_id: Annotated[
str,
"The ID of the account to get data for.",
],
) -> Annotated[
dict,
"The account with related info: contacts, leads, notes, calls, opportunities, tasks, emails, "
"and events (up to 10 items of each type)",
]:
"""Gets the account with related info: contacts, leads, notes, calls, opportunities, tasks,
emails, and events (up to 10 items of each type).
An account is an organization (such as a customer, supplier, or partner, though more commonly
a customer). In some Salesforce account setups, an account can also represent a person.
"""
client = SalesforceClient(context.get_auth_token_or_empty())
account = await client.get_account(account_id)
if not account:
return {"account": None, "error": f"No account found with id '{account_id}'"}
account = await client.enrich_account(account_data=account)
account = clean_account_data(account)
return {"account": account}

View file

@ -0,0 +1,64 @@
from typing import Annotated
from arcade.sdk import ToolContext, tool
from arcade.sdk.auth import OAuth2
from arcade.sdk.errors import ToolExecutionError
from arcade_salesforce.exceptions import SalesforceToolExecutionError
from arcade_salesforce.models import SalesforceClient
# TODO: Add support for referencing an account by keywords (name, website, etc). We can use the
# get_account_data_by_keywords tool to search for accounts. If more than one is returned, we can
# raise a RetryableToolError providing the matches for the user to choose.
@tool(
requires_auth=OAuth2(
id="arcade-salesforce",
scopes=["write_contact"],
)
)
async def create_contact(
context: ToolContext,
account_id: Annotated[str, "The ID of the account to create the contact for."],
last_name: Annotated[str, "The last name of the contact."],
first_name: Annotated[str | None, "The first name of the contact."] = None,
email: Annotated[str | None, "The email of the contact."] = None,
phone: Annotated[str | None, "The phone number of the contact."] = None,
mobile_phone: Annotated[str | None, "The mobile phone number of the contact."] = None,
title: Annotated[
str | None,
"The title of the contact. E.g. 'CEO', 'Sales Director', 'CTO', etc.",
] = None,
department: Annotated[
str | None,
"The department of the contact. E.g. 'Marketing', 'Sales', 'IT', etc.",
] = None,
description: Annotated[str | None, "The description of the contact."] = None,
) -> Annotated[
dict,
"The created contact.",
]:
"""Creates a contact in Salesforce."""
if not last_name:
raise ToolExecutionError("Last name is required by Salesforce to create a contact.")
client = SalesforceClient(context.get_auth_token_or_empty())
contact = await client.create_contact(
account_id=account_id,
first_name=first_name,
last_name=last_name,
email=email,
phone=phone,
mobile_phone=mobile_phone,
title=title,
department=department,
description=description,
)
if not contact.get("success"):
raise SalesforceToolExecutionError(
message="Failed to create contact",
errors=contact.get("errors", []),
)
return {"success": True, "contactId": contact.get("id")}

View file

@ -0,0 +1,279 @@
import string
from collections.abc import Callable
from typing import Any, cast
from arcade_salesforce.constants import ASSOCIATION_REFERENCE_FIELDS, GLOBALLY_IGNORED_FIELDS
from arcade_salesforce.enums import SalesforceObject
def remove_fields_globally_ignored(clean_func: Callable[[dict], dict]) -> Callable[[dict], dict]:
"""Remove fields that are irrelevant for our tools' responses.
The main purpose of cleaning and removing unnecessary fields from data is to make our
responses more concise and save on LLM tokens/context window.
"""
def global_cleaner(data: dict) -> dict:
cleaned_data = {}
for key, value in data.items():
if key in GLOBALLY_IGNORED_FIELDS or value is None:
continue
if isinstance(value, dict):
cleaned_data[key] = global_cleaner(value)
elif isinstance(value, list | tuple | set):
cleaned_items = []
for item in value:
if isinstance(item, dict):
cleaned_items.append(global_cleaner(item))
else:
cleaned_items.append(item)
cleaned_data[key] = cleaned_items # type: ignore[assignment]
else:
cleaned_data[key] = value
return cleaned_data
def wrapper(data: dict) -> dict:
return global_cleaner(clean_func(data))
return wrapper
def clean_object_data(data: dict) -> dict:
"""Clean Salesforce object dictionary based on the object type."""
obj_type = data["attributes"]["type"]
if obj_type == SalesforceObject.ACCOUNT.value:
return clean_account_data(data)
elif obj_type == SalesforceObject.CONTACT.value:
return clean_contact_data(data)
elif obj_type == SalesforceObject.LEAD.value:
return clean_lead_data(data)
elif obj_type == SalesforceObject.NOTE.value:
return clean_note_data(data)
elif obj_type == SalesforceObject.TASK.value:
return clean_task_data(data)
elif obj_type == SalesforceObject.USER.value:
return clean_user_data(data)
raise ValueError(f"Unknown object type: '{obj_type}' in object: {data}")
@remove_fields_globally_ignored
def clean_account_data(data: dict) -> dict:
data["AccountType"] = data["Type"]
del data["Type"]
data["ObjectType"] = SalesforceObject.ACCOUNT.value
ignore_fields = [
"BillingCity",
"BillingCountry",
"BillingCountryCode",
"BillingPostalCode",
"BillingState",
"BillingStateCode",
"BillingStreet",
"LastActivityDate",
"ShippingCity",
"ShippingCountry",
"ShippingCountryCode",
"ShippingPostalCode",
"ShippingState",
"ShippingStateCode",
"ShippingStreet",
]
return {k: v for k, v in data.items() if v is not None and k not in ignore_fields}
@remove_fields_globally_ignored
def clean_contact_data(data: dict) -> dict:
data["ObjectType"] = SalesforceObject.CONTACT.value
ignore_fields = ["IsEmailBounced", "IsPriorityRecord"]
return {k: v for k, v in data.items() if v is not None and k not in ignore_fields}
@remove_fields_globally_ignored
def clean_lead_data(data: dict) -> dict:
data["ObjectType"] = SalesforceObject.LEAD.value
return data
@remove_fields_globally_ignored
def clean_opportunity_data(data: dict) -> dict:
data["ObjectType"] = SalesforceObject.OPPORTUNITY.value
data["Amount"] = {
"Value": data["Amount"],
"ClosingProbability": data["Probability"]
if not isinstance(data["Probability"], int | float)
else data["Probability"] / 100,
"ExpectedRevenue": data["ExpectedRevenue"],
}
del data["Probability"]
del data["ExpectedRevenue"]
return data
@remove_fields_globally_ignored
def clean_note_data(data: dict) -> dict:
data["ObjectType"] = SalesforceObject.NOTE.value
return data
@remove_fields_globally_ignored
def clean_task_data(data: dict) -> dict:
data["ObjectType"] = data["TaskSubtype"]
data["AssociatedToWhom"] = data["WhoId"]
ignore_fields = [
"IsArchived",
"IsClosed",
"IsDeleted",
"IsHighPriority",
"IsRecurrence",
"IsReminderSet",
"TaskSubtype",
"WhoId",
]
if data["ObjectType"] == SalesforceObject.EMAIL.value:
data["Email"] = format_email(data["Description"])
del data["ActivityDate"]
del data["CompletedDateTime"]
del data["Description"]
del data["Subject"]
del data["OwnerId"]
del data["Priority"]
del data["Status"]
return {k: v for k, v in data.items() if v is not None and k not in ignore_fields}
@remove_fields_globally_ignored
def clean_user_data(data: dict) -> dict:
data["ObjectType"] = SalesforceObject.USER.value
ignore_fields = [
"attributes",
"CleanStatus",
"LastReferencedDate",
"LastViewedDate",
"SystemModstamp",
]
return {k: v for k, v in data.items() if v is not None and k not in ignore_fields}
def get_ids_referenced(*data_objects: dict) -> set[str]:
"""Build a set of IDs referenced in a list of dictionaries.
Args:
data_objects: The list of dictionaries to search for referenced IDs.
Returns:
The set of IDs referenced in the dictionaries.
"""
referenced_ids = set()
for data in data_objects:
for field in ASSOCIATION_REFERENCE_FIELDS:
if field in data:
referenced_ids.add(data[field])
return referenced_ids
def expand_associations(data: dict, objects_by_id: dict) -> dict:
"""Expand a Salesforce object referenced by ID with metadata about the object.
Args:
data: The dictionary to expand.
objects_by_id: The dictionary of objects metadata by ID.
Returns:
The expanded dictionary.
"""
for field in ASSOCIATION_REFERENCE_FIELDS:
if field not in data:
continue
associated_object = objects_by_id.get(data[field])
if isinstance(associated_object, dict):
del data[field]
data[field.rstrip("Id")] = simplified_object_data(associated_object)
return data
def simplified_object_data(data: dict) -> dict:
return {
"Id": data["Id"],
"Name": data["Name"],
"ObjectType": get_object_type(data),
}
def get_object_type(data: dict) -> str:
obj_type = data.get("ObjectType") or data["attributes"]["type"]
return cast(str, obj_type)
def build_soql_query(query: str, **kwargs: Any) -> str:
"""Build a SOQL query with sanitized arguments.
Args:
query: The SOQL query to build.
**kwargs: The arguments to sanitize and format into the query.
Returns:
The SOQL query with the arguments formatted.
"""
return query.format(**{key: sanitize_soql_argument(value) for key, value in kwargs.items()})
def sanitize_soql_argument(value: Any) -> str:
"""Sanitize an argument for a SOQL query.
The goal is to prevent SQL injection, since Salesforce does not support parameterized queries.
This function will only accept ASCII letters and digits in the value.
Args:
value: The value to sanitize.
Returns:
The sanitized value.
"""
allowed_chars = string.ascii_letters + string.digits
if not isinstance(value, str):
value = str(value)
return "".join([char for char in value if char in allowed_chars])
def format_email(description: str) -> dict:
"""Turns a Salesforce email description string into a more readable dictionary.
Args:
description: The email description to format.
Returns:
The formatted email as a dictionary.
"""
email = {
"To": description.split("To:")[1].split("\n")[0].strip(),
"CC": description.split("CC:")[1].split("\n")[0].strip(),
"BCC": description.split("BCC:")[1].split("\n")[0].strip(),
"Attachment": description.split("Attachment:")[1].split("\n")[0].strip(),
"Subject": description.split("Subject:")[1].split("\n")[0].strip(),
"Body": description.split("Body:\n")[1].strip(),
}
if email["Attachment"] == "--none--":
del email["Attachment"]
return email
def remove_none_values(data: dict) -> dict:
"""Remove None values from a dictionary.
Args:
data: The dictionary to clean.
Returns:
The cleaned dictionary.
"""
return {k: v for k, v in data.items() if v is not None}

View file

@ -0,0 +1,16 @@
from unittest.mock import patch
import pytest
from arcade.sdk import ToolAuthorizationContext, ToolContext
@pytest.fixture
def mock_context():
mock_auth = ToolAuthorizationContext(token="fake-token") # noqa: S106
return ToolContext(authorization=mock_auth)
@pytest.fixture
def mock_httpx_client():
with patch("arcade_salesforce.models.httpx") as mock_httpx:
yield mock_httpx.AsyncClient().__aenter__.return_value

View file

@ -0,0 +1,102 @@
from arcade.sdk import ToolCatalog
from arcade.sdk.eval import (
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade.sdk.eval.critic import BinaryCritic
import arcade_salesforce
from arcade_salesforce.tools import create_contact
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_salesforce)
@tool_eval()
def create_contact_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="create contact eval suite",
system_message=(
"You are an AI assistant with access to Salesforce tools. "
"Use them to help the user with their tasks."
),
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Create contact",
user_message="Create a contact for the account with ID 001gK000003DIn0QAG with name Jenifer Bear and email jenifer@acme.net.",
expected_tool_calls=[
ExpectedToolCall(
func=create_contact,
args={
"account_id": "001gK000003DIn0QAG",
"last_name": "Bear",
"first_name": "Jenifer",
"email": "jenifer@acme.net",
"phone": None,
"mobile_phone": None,
"title": None,
"department": None,
"description": None,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="account_id", weight=0.2),
BinaryCritic(critic_field="last_name", weight=0.1),
BinaryCritic(critic_field="first_name", weight=0.1),
BinaryCritic(critic_field="email", weight=0.1),
BinaryCritic(critic_field="phone", weight=0.1),
BinaryCritic(critic_field="mobile_phone", weight=0.1),
BinaryCritic(critic_field="title", weight=0.1),
BinaryCritic(critic_field="department", weight=0.1),
BinaryCritic(critic_field="description", weight=0.1),
],
)
suite.add_case(
name="Create contact with only last name",
user_message="Create a contact for the account with ID 001gK000003DIn0QAG with name Doe and email doe@acme.net.",
expected_tool_calls=[
ExpectedToolCall(
func=create_contact,
args={
"account_id": "001gK000003DIn0QAG",
"last_name": "Doe",
"first_name": None,
"email": "doe@acme.net",
"phone": None,
"mobile_phone": None,
"title": None,
"department": None,
"description": None,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="account_id", weight=0.2),
BinaryCritic(critic_field="last_name", weight=0.1),
BinaryCritic(critic_field="first_name", weight=0.1),
BinaryCritic(critic_field="email", weight=0.1),
BinaryCritic(critic_field="phone", weight=0.1),
BinaryCritic(critic_field="mobile_phone", weight=0.1),
BinaryCritic(critic_field="title", weight=0.1),
BinaryCritic(critic_field="department", weight=0.1),
BinaryCritic(critic_field="description", weight=0.1),
],
)
return suite

View file

@ -0,0 +1,222 @@
from arcade.sdk import ToolCatalog
from arcade.sdk.eval import (
EvalRubric,
EvalSuite,
ExpectedToolCall,
tool_eval,
)
from arcade.sdk.eval.critic import BinaryCritic
import arcade_salesforce
from arcade_salesforce.tools import get_account_data_by_id, get_account_data_by_keywords
# Evaluation rubric
rubric = EvalRubric(
fail_threshold=0.85,
warn_threshold=0.95,
)
catalog = ToolCatalog()
catalog.add_module(arcade_salesforce)
@tool_eval()
def get_account_data_by_keywords_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="get_account_data_by_keywords",
system_message=(
"You are an AI assistant with access to Salesforce tools. "
"Use them to help the user with their tasks."
),
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Get account data by keywords",
user_message="Get information about the Acme account.",
expected_tool_calls=[
ExpectedToolCall(
func=get_account_data_by_keywords,
args={
"query": "Acme",
"page": 1,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="query", weight=0.5),
BinaryCritic(critic_field="page", weight=0.5),
],
)
suite.add_case(
name="Get account data by keywords with limit",
user_message="Get information of up to 3 accounts with the word 'Acme' in their name.",
expected_tool_calls=[
ExpectedToolCall(
func=get_account_data_by_keywords,
args={
"query": "Acme",
"limit": 3,
"page": 1,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="query", weight=1 / 3),
BinaryCritic(critic_field="limit", weight=1 / 3),
BinaryCritic(critic_field="page", weight=1 / 3),
],
)
suite.add_case(
name="Get summary of account activity",
user_message="Get a summary of the latest activities in the Acme account.",
expected_tool_calls=[
ExpectedToolCall(
func=get_account_data_by_keywords,
args={
"query": "Acme",
"page": 1,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="query", weight=0.5),
BinaryCritic(critic_field="page", weight=0.5),
],
)
suite.add_case(
name="Interactions with contacts of an account",
user_message="Get me the interactions with the contacts of the Acme account.",
expected_tool_calls=[
ExpectedToolCall(
func=get_account_data_by_keywords,
args={
"query": "Acme",
"page": 1,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="query", weight=0.5),
BinaryCritic(critic_field="page", weight=0.5),
],
)
suite.add_case(
name="Emails or calls with contacts of an account",
user_message="Were there any emails or calls with contacts of the Acme account this week?",
expected_tool_calls=[
ExpectedToolCall(
func=get_account_data_by_keywords,
args={
"query": "Acme",
"page": 1,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="query", weight=0.5),
BinaryCritic(critic_field="page", weight=0.5),
],
)
suite.add_case(
name="Get account status",
user_message="What's the status of the Acme account?",
expected_tool_calls=[
ExpectedToolCall(
func=get_account_data_by_keywords,
args={
"query": "Acme",
"page": 1,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="query", weight=0.5),
BinaryCritic(critic_field="page", weight=0.5),
],
)
suite.add_case(
name="Get overdue tasks",
user_message="Are there any tasks overdue for the Acme account?",
expected_tool_calls=[
ExpectedToolCall(
func=get_account_data_by_keywords,
args={
"query": "Acme",
"page": 1,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="query", weight=0.5),
BinaryCritic(critic_field="page", weight=0.5),
],
)
suite.add_case(
name="Get account closing likelihood",
user_message="What's the likelihood of the Acme account closing a deal?",
expected_tool_calls=[
ExpectedToolCall(
func=get_account_data_by_keywords,
args={
"query": "Acme",
"page": 1,
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="query", weight=0.5),
BinaryCritic(critic_field="page", weight=0.5),
],
)
return suite
@tool_eval()
def get_account_data_by_id_eval_suite() -> EvalSuite:
suite = EvalSuite(
name="get_account_data_by_id",
system_message=(
"You are an AI assistant with access to Salesforce tools. "
"Use them to help the user with their tasks."
),
catalog=catalog,
rubric=rubric,
)
suite.add_case(
name="Get account data by ID",
user_message="Get information about the account with ID 001gK000003DIn0QAG.",
expected_tool_calls=[
ExpectedToolCall(
func=get_account_data_by_id,
args={
"account_id": "001gK000003DIn0QAG",
},
),
],
rubric=rubric,
critics=[
BinaryCritic(critic_field="account_id", weight=1.0),
],
)
return suite

View file

@ -0,0 +1,42 @@
[tool.poetry]
name = "arcade_salesforce"
version = "0.1.0"
description = "Arcade tools designed for LLMs to interact with Salesforce"
authors = ["Arcade <dev@arcade.dev>"]
[tool.poetry.dependencies]
python = "^3.10"
arcade-ai = ">=1.0.5,<2.0"
httpx = "^0.27.2"
[tool.poetry.dev-dependencies]
pytest = "^8.3.0"
pytest-cov = "^4.0.0"
pytest-asyncio = "^0.24.0"
pytest-mock = "^3.11.1"
mypy = "^1.5.1"
pre-commit = "^3.4.0"
tox = "^4.11.1"
ruff = "^0.7.4"
[build-system]
requires = ["poetry-core>=1.0.0,<2.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.mypy]
files = ["arcade_salesforce/**/*.py"]
python_version = "3.10"
disallow_untyped_defs = "True"
disallow_any_unimported = "True"
no_implicit_optional = "True"
check_untyped_defs = "True"
warn_return_any = "True"
warn_unused_ignores = "True"
show_error_codes = "True"
ignore_missing_imports = "True"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.report]
skip_empty = true

View file

@ -0,0 +1,270 @@
from unittest.mock import MagicMock, patch
import httpx
import pytest
from arcade_salesforce.constants import SALESFORCE_API_VERSION
from arcade_salesforce.enums import SalesforceObject
from arcade_salesforce.exceptions import ResourceNotFoundError, SalesforceToolExecutionError
from arcade_salesforce.models import SalesforceClient
@pytest.mark.asyncio
async def test_get_account_success(mock_context, mock_httpx_client):
account_id = "001gK000003DIn0QAG"
account = {
"attributes": {
"type": "Account",
},
"Id": account_id,
"Name": "Acme, Inc.",
}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = account
mock_httpx_client.get.return_value = mock_response
client = SalesforceClient(
auth_token=mock_context.authorization.token,
org_domain="org_domain",
)
response = await client.get_account(account_id)
assert response == account
mock_httpx_client.get.assert_called_once_with(
url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/Account/{account_id}",
headers={"Authorization": f"Bearer {mock_context.authorization.token}"},
)
@pytest.mark.asyncio
async def test_get_account_not_found_error(mock_context, mock_httpx_client):
account_id = "001gK000003DIn0QAG"
response_data = [{"message": "Account not found"}]
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 404
mock_response.json.return_value = response_data
mock_httpx_client.get.return_value = mock_response
client = SalesforceClient(
auth_token=mock_context.authorization.token,
org_domain="org_domain",
)
response = await client.get_account(account_id)
assert response is None
@pytest.mark.asyncio
async def test_get_account_bad_request_error(mock_context, mock_httpx_client):
account_id = "001gK000003DIn0QAG"
response_data = [{"message": "Bad request"}]
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 400
mock_response.json.return_value = response_data
mock_httpx_client.get.return_value = mock_response
client = SalesforceClient(
auth_token=mock_context.authorization.token,
org_domain="org_domain",
)
with pytest.raises(SalesforceToolExecutionError) as exc_info:
await client.get_account(account_id)
assert exc_info.value.errors == ["Bad request"]
@pytest.mark.asyncio
async def test_get_account_internal_server_error(mock_context, mock_httpx_client):
account_id = "001gK000003DIn0QAG"
response_data = [{"message": "Internal server error"}]
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 500
mock_response.json.return_value = response_data
mock_httpx_client.get.return_value = mock_response
client = SalesforceClient(
auth_token=mock_context.authorization.token,
org_domain="org_domain",
)
with pytest.raises(SalesforceToolExecutionError) as exc_info:
await client.get_account(account_id)
assert exc_info.value.errors == ["Internal server error"]
@pytest.mark.asyncio
async def test_create_contact(mock_context, mock_httpx_client):
account_id = "001gK000003DIn0QAG"
client = SalesforceClient(
auth_token=mock_context.authorization.token,
org_domain="org_domain",
)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_httpx_client.post.return_value = mock_response
response = await client.create_contact(
account_id=account_id,
last_name="Doe",
first_name="John",
email="john.doe@acme.net",
)
assert response == mock_response.json.return_value
mock_httpx_client.post.assert_called_once_with(
url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/Contact",
headers={"Authorization": f"Bearer {mock_context.authorization.token}"},
json={
"AccountId": account_id,
"FirstName": "John",
"LastName": "Doe",
"Email": "john.doe@acme.net",
},
)
@pytest.mark.asyncio
async def test_get_related_objects_success(mock_context, mock_httpx_client):
client = SalesforceClient(
auth_token=mock_context.authorization.token,
org_domain="org_domain",
)
parent_object_id = "001gK000003DIn0QAG"
parent_object = SalesforceObject.ACCOUNT
child_object = SalesforceObject.CONTACT
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {"records": []}
mock_httpx_client.get.return_value = mock_response
response = await client._get_related_objects(
child_object_type=child_object,
parent_object_type=parent_object,
parent_object_id=parent_object_id,
limit=10,
)
assert response == []
mock_httpx_client.get.assert_called_once_with(
url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/{parent_object.value}/{parent_object_id}/{child_object.plural.lower()}",
headers={"Authorization": f"Bearer {mock_context.authorization.token}"},
params={"limit": 10},
)
@pytest.mark.asyncio
async def test_get_related_objects_not_found_error(mock_context, mock_httpx_client):
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 404
mock_response.json.return_value = [{"message": "Not found"}]
mock_httpx_client.get.return_value = mock_response
client = SalesforceClient(
auth_token=mock_context.authorization.token,
org_domain="org_domain",
)
parent_object_id = "001gK000003DIn0QAG"
parent_object = SalesforceObject.ACCOUNT
child_object = SalesforceObject.CONTACT
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {"records": []}
mock_httpx_client.get.return_value = mock_response
response = await client._get_related_objects(
child_object_type=child_object,
parent_object_type=parent_object,
parent_object_id=parent_object_id,
limit=10,
)
assert response == []
mock_httpx_client.get.assert_called_once_with(
url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/sobjects/{parent_object.value}/{parent_object_id}/{child_object.plural.lower()}",
headers={"Authorization": f"Bearer {mock_context.authorization.token}"},
params={"limit": 10},
)
@pytest.mark.asyncio
@patch("arcade_salesforce.models.build_soql_query")
async def test_get_notes_success(mock_build_soql_query, mock_context, mock_httpx_client):
client = SalesforceClient(
auth_token=mock_context.authorization.token,
org_domain="org_domain",
)
note_data = {
"Id": "003gK000003DIn0QAG",
"Title": "Note 1",
"Body": "Note 1 body",
"OwnerId": "005gK000003DIn0QAG",
"CreatedById": "005gK000003DIn0QAG",
"CreatedDate": "2025-01-01T00:00:00Z",
"attributes": {
"type": "Note",
},
}
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 200
mock_response.json.return_value = {"records": [note_data]}
mock_httpx_client.get.return_value = mock_response
parent_id = "003gK000003DIn0QAG"
response = await client.get_notes(parent_id=parent_id, limit=10)
assert response == [
{
"Id": note_data["Id"],
"Title": note_data["Title"],
"Body": note_data["Body"],
"OwnerId": note_data["OwnerId"],
"ObjectType": SalesforceObject.NOTE.value,
}
]
mock_httpx_client.get.assert_called_once_with(
url=f"https://org_domain.my.salesforce.com/services/data/{SALESFORCE_API_VERSION}/query",
headers={"Authorization": f"Bearer {mock_context.authorization.token}"},
params={"q": mock_build_soql_query.return_value},
)
mock_build_soql_query.assert_called_once_with(
"SELECT Id, Title, Body, OwnerId, CreatedById, CreatedDate "
"FROM Note "
"WHERE ParentId = '{parent_id}' "
"LIMIT {limit}",
parent_id=parent_id,
limit=10,
)
@pytest.mark.asyncio
@patch("arcade_salesforce.models.build_soql_query")
async def test_get_notes_not_found_error(mock_build_soql_query, mock_context, mock_httpx_client):
client = SalesforceClient(
auth_token=mock_context.authorization.token,
org_domain="org_domain",
)
mock_response = MagicMock(spec=httpx.Response)
mock_response.status_code = 404
mock_response.json.return_value = [{"message": "Not found"}]
mock_httpx_client.get.return_value = mock_response
parent_id = "003gK000003DIn0QAG"
with pytest.raises(ResourceNotFoundError) as exc_info:
await client.get_notes(parent_id=parent_id, limit=10)
assert exc_info.value.errors == ["Not found"]