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}
```
228 lines
7.5 KiB
Python
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)
|