arcade-mcp/toolkits/hubspot/arcade_hubspot/models.py
Renato Byrro de227c5f51
Hubspot toolkit (#363)
Reviewer:

1. DM me to get a client ID/secret for the Hubspot app and login
credentials for a sample Hubspot account
2. For now, you'll need to run the engine on the branch
`nate/token-introspection` for the auth flow to work with Hubspot
3. Add the following to `auth.providers` in your engine yaml:

```yaml
- id: arcade-hubspot
  description: 'Hubspot provider'
  enabled: true
  type: oauth2
  provider_id: hubspot
  client_id: ${env:HUBSPOT_CLIENT_ID}
  client_secret: ${env:HUBSPOT_CLIENT_SECRET}
```
2025-04-22 17:43:15 -03:00

228 lines
7.5 KiB
Python

import asyncio
import json
from dataclasses import dataclass
from typing import Any, Optional, cast
import httpx
from arcade_hubspot.constants import (
HUBSPOT_CRM_BASE_URL,
HUBSPOT_DEFAULT_API_VERSION,
HUBSPOT_MAX_CONCURRENT_REQUESTS,
)
from arcade_hubspot.enums import HubspotObject
from arcade_hubspot.exceptions import HubspotToolExecutionError, NotFoundError
from arcade_hubspot.properties import get_object_properties
from arcade_hubspot.utils import clean_data, prepare_api_search_response, remove_none_values
@dataclass
class HubspotCrmClient:
auth_token: str
base_url: str = HUBSPOT_CRM_BASE_URL
max_concurrent_requests: int = HUBSPOT_MAX_CONCURRENT_REQUESTS
_semaphore: asyncio.Semaphore | None = None
def __post_init__(self) -> None:
self._semaphore = self._semaphore or asyncio.Semaphore(self.max_concurrent_requests)
def _raise_for_status(self, response: httpx.Response) -> None:
if response.status_code < 300:
return
try:
data = response.json()
error_message = data["message"]
developer_message = json.dumps(data["errors"])
except Exception:
error_message = response.text
developer_message = None
if response.status_code == 404:
raise NotFoundError(error_message, developer_message)
raise HubspotToolExecutionError(error_message, developer_message)
async def get(
self,
endpoint: str,
params: Optional[dict] = None,
headers: Optional[dict] = None,
api_version: str = HUBSPOT_DEFAULT_API_VERSION,
) -> dict:
headers = headers or {}
headers["Authorization"] = f"Bearer {self.auth_token}"
kwargs = {
"url": f"{self.base_url}/{api_version}/{endpoint}",
"headers": headers,
}
if isinstance(params, dict):
kwargs["params"] = params
async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr]
response = await client.get(**kwargs) # type: ignore[arg-type]
self._raise_for_status(response)
return cast(dict, response.json())
async def post(
self,
endpoint: str,
data: Optional[dict] = None,
json_data: Optional[dict] = None,
headers: Optional[dict] = None,
api_version: str = HUBSPOT_DEFAULT_API_VERSION,
) -> dict:
headers = headers or {}
headers["Authorization"] = f"Bearer {self.auth_token}"
headers["Content-Type"] = "application/json"
kwargs = {
"url": f"{self.base_url}/{api_version}/{endpoint}",
"headers": headers,
}
if data and json_data:
raise ValueError("Cannot provide both data and json_data")
if data:
kwargs["data"] = data
elif json_data:
kwargs["json"] = json_data
async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr]
response = await client.post(**kwargs) # type: ignore[arg-type]
self._raise_for_status(response)
return cast(dict, response.json())
async def get_associated_objects(
self,
parent_object: HubspotObject,
parent_id: str,
associated_object: HubspotObject,
limit: int = 10,
after: Optional[str] = None,
properties: Optional[list[str]] = None,
) -> list[dict]:
endpoint = (
f"objects/{parent_object.value}/{parent_id}/associations/{associated_object.value}"
)
params = {
"limit": limit,
}
if after:
params["after"] = after # type: ignore[assignment]
response = await self.get(endpoint, params=params, api_version="v4")
if not response["results"]:
return []
return await self.batch_get_objects(
object_type=associated_object,
object_ids=[object_data["toObjectId"] for object_data in response["results"]],
properties=properties or get_object_properties(associated_object),
)
async def get_object_by_id(
self,
object_type: HubspotObject,
object_id: str,
properties: Optional[list[str]] = None,
) -> dict:
endpoint = f"objects/{object_type.plural}/{object_id}"
params = {}
if properties:
params["properties"] = properties
return clean_data(await self.get(endpoint, params=params), object_type)
async def batch_get_objects(
self,
object_type: HubspotObject,
object_ids: list[str],
properties: Optional[list[str]] = None,
) -> list[dict]:
endpoint = f"objects/{object_type.plural}/batch/read"
data: dict[str, Any] = {"inputs": [{"id": object_id} for object_id in object_ids]}
if properties:
data["properties"] = properties
response = await self.post(endpoint, json_data=data)
return [clean_data(object_data, object_type) for object_data in response["results"]]
async def search_by_keywords(
self,
object_type: HubspotObject,
keywords: str,
limit: int = 10,
next_page_token: Optional[str] = None,
associations: Optional[list[HubspotObject]] = None,
) -> dict:
if not keywords:
raise HubspotToolExecutionError("`keywords` must be a non-empty string")
associations = associations or []
endpoint = f"objects/{object_type.plural}/search"
request_data = {
"query": keywords,
"limit": limit,
"sorts": [{"propertyName": "hs_lastmodifieddate", "direction": "DESCENDING"}],
"properties": get_object_properties(object_type),
}
if next_page_token:
request_data["after"] = next_page_token
data = prepare_api_search_response(
data=await self.post(endpoint, json_data=request_data),
object_type=object_type,
)
for object_ in data[object_type.plural]:
for association in associations:
results = await self.get_associated_objects(
parent_object=object_type,
parent_id=object_["id"],
associated_object=association,
limit=10,
)
if results:
object_[association.plural] = results
return data
async def create_contact(
self,
company_id: str,
first_name: str,
last_name: Optional[str] = None,
email: Optional[str] = None,
phone: Optional[str] = None,
mobile_phone: Optional[str] = None,
job_title: Optional[str] = None,
) -> dict:
request_data = {
"associations": [
{
"types": [
{
"associationCategory": "HUBSPOT_DEFINED",
"associationTypeId": "1",
}
],
"to": {"id": company_id},
},
],
"properties": remove_none_values({
"firstname": first_name,
"lastname": last_name,
"email": email,
"phone": phone,
"mobilephone": mobile_phone,
"jobtitle": job_title,
}),
}
endpoint = "objects/contacts"
return await self.post(endpoint, json_data=request_data)