import asyncio import json from dataclasses import dataclass from typing import Optional, cast import httpx from arcade_asana.constants import ASANA_API_VERSION, ASANA_BASE_URL, ASANA_MAX_CONCURRENT_REQUESTS from arcade_asana.decorators import clean_asana_response from arcade_asana.exceptions import AsanaToolExecutionError @dataclass class AsanaClient: auth_token: str base_url: str = ASANA_BASE_URL api_version: str = ASANA_API_VERSION max_concurrent_requests: int = ASANA_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 _build_url(self, endpoint: str, api_version: str | None = None) -> str: api_version = api_version or self.api_version return f"{self.base_url.rstrip('/')}/{api_version.strip('/')}/{endpoint.lstrip('/')}" def _build_error_messages(self, response: httpx.Response) -> tuple[str, str]: try: data = response.json() errors = data["errors"] if len(errors) == 1: error_message = errors[0]["message"] developer_message = ( f"{errors[0]['message']} | {errors[0]['help']} " f"(HTTP status code: {response.status_code})" ) else: errors_concat = "', '".join([error["message"] for error in errors]) error_message = f"Multiple errors occurred: '{errors_concat}'" developer_message = ( f"Multiple errors occurred: {json.dumps(errors)} " f"(HTTP status code: {response.status_code})" ) except Exception as e: error_message = "Failed to parse Asana error response" developer_message = f"Failed to parse Asana error response: {type(e).__name__}: {e!s}" return error_message, developer_message def _raise_for_status(self, response: httpx.Response) -> None: if response.status_code < 300: return error_message, developer_message = self._build_error_messages(response) raise AsanaToolExecutionError(error_message, developer_message) def _set_request_body(self, kwargs: dict, data: dict | None, json_data: dict | None) -> dict: 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 return kwargs @clean_asana_response async def get( self, endpoint: str, params: Optional[dict] = None, headers: Optional[dict] = None, api_version: str | None = None, ) -> dict: default_headers = { "Authorization": f"Bearer {self.auth_token}", "Accept": "application/json", } headers = {**default_headers, **(headers or {})} kwargs = { "url": self._build_url(endpoint, api_version), "headers": headers, } if params: 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()) @clean_asana_response async def post( self, endpoint: str, data: Optional[dict] = None, json_data: Optional[dict] = None, files: Optional[dict] = None, headers: Optional[dict] = None, api_version: str | None = None, ) -> dict: default_headers = { "Authorization": f"Bearer {self.auth_token}", "Accept": "application/json", } if files is None and json_data is not None: default_headers["Content-Type"] = "application/json" headers = {**default_headers, **(headers or {})} kwargs = { "url": self._build_url(endpoint, api_version), "headers": headers, } if files is not None: kwargs["files"] = files if data is not None: kwargs["data"] = data else: kwargs = self._set_request_body(kwargs, data, 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()) @clean_asana_response async def put( self, endpoint: str, data: Optional[dict] = None, json_data: Optional[dict] = None, headers: Optional[dict] = None, api_version: str | None = None, ) -> dict: headers = headers or {} headers["Authorization"] = f"Bearer {self.auth_token}" headers["Content-Type"] = "application/json" headers["Accept"] = "application/json" kwargs = { "url": self._build_url(endpoint, api_version), "headers": headers, } kwargs = self._set_request_body(kwargs, data, json_data) async with self._semaphore, httpx.AsyncClient() as client: # type: ignore[union-attr] response = await client.put(**kwargs) # type: ignore[arg-type] self._raise_for_status(response) return cast(dict, response.json()) async def get_current_user(self) -> dict: response = await self.get("/users/me") return cast(dict, response["data"])