arcade-mcp/toolkits/salesforce/arcade_salesforce/models.py
Renato Byrro 52807bfd0e
Update Salesforce provider id (#373)
Updating the auth provider's `id` to simply `salesforce`. We don't want
it to be confused with a future well-known `provider_id` of
`arcade-salesforce`.

Also, what the toolkit was referring to as `org_domain` is actually the
organization's SUBdomain. We changed all references to "subdomain" to be
more precise. The documentation has been updated accordingly.
2025-04-22 16:32:05 -03:00

495 lines
17 KiB
Python

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_subdomain: 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_subdomain is None:
self.org_subdomain = os.getenv("SALESFORCE_ORG_SUBDOMAIN")
if self.org_subdomain is None:
raise ValueError(
"Either `org_subdomain` argument or `SALESFORCE_ORG_SUBDOMAIN` 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_subdomain}.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),
)