autohive_integrations_sdk.integration
Autohive Integrations SDK — core module.
Provides the building blocks for creating Autohive integrations:
Integration— load config and register action/trigger/connected-account handlersExecutionContext— authenticated HTTP client passed to every handlerActionHandler— base class for action implementations (returnActionResult)ConnectedAccountHandler— base class for connected-account lookups (returnConnectedAccountInfo)ActionResult— standard return type wrapping action output data and optional billing costActionError— return type for expected application-level errors (bypasses output schema validation)FetchResponse— response object fromcontext.fetch()with.status,.headers, and.dataConnectedAccountInfo— structured account info returned by connected-account handlersHTTPError/RateLimitError— exceptions raised bycontext.fetch()for non-2xx responses
Typical usage::
from autohive_integrations_sdk import Integration, ActionHandler, ActionResult, ExecutionContext
integration = Integration.load()
@integration.action("my_action")
class MyAction(ActionHandler):
async def execute(self, inputs, context):
response = await context.fetch("https://api.example.com/resource")
return ActionResult(data=response.data)
1"""Autohive Integrations SDK — core module. 2 3Provides the building blocks for creating Autohive integrations: 4 5- `Integration` — load config and register action/trigger/connected-account handlers 6- `ExecutionContext` — authenticated HTTP client passed to every handler 7- `ActionHandler` — base class for action implementations (return `ActionResult`) 8- `ConnectedAccountHandler` — base class for connected-account lookups (return `ConnectedAccountInfo`) 9- `ActionResult` — standard return type wrapping action output data and optional billing cost 10- `ActionError` — return type for expected application-level errors (bypasses output schema validation) 11- `FetchResponse` — response object from ``context.fetch()`` with ``.status``, ``.headers``, and ``.data`` 12- `ConnectedAccountInfo` — structured account info returned by connected-account handlers 13- `HTTPError` / `RateLimitError` — exceptions raised by ``context.fetch()`` for non-2xx responses 14 15Typical usage:: 16 17 from autohive_integrations_sdk import Integration, ActionHandler, ActionResult, ExecutionContext 18 19 integration = Integration.load() 20 21 @integration.action("my_action") 22 class MyAction(ActionHandler): 23 async def execute(self, inputs, context): 24 response = await context.fetch("https://api.example.com/resource") 25 return ActionResult(data=response.data) 26""" 27 28# Standard Library Imports 29from abc import ABC, abstractmethod 30import asyncio 31from dataclasses import dataclass, field, asdict 32from datetime import timedelta 33from enum import Enum 34import json 35import json as jsonX # Keep alias to avoid conflict with 'json' parameter in fetch 36import logging 37import os 38import sys 39from pathlib import Path 40from typing import Dict, Any, List, Optional, Union, Type, TypeVar, Generic, ClassVar 41from urllib.parse import urlencode 42 43# Third-Party Imports 44import aiohttp 45from jsonschema import validate, Draft7Validator 46 47 48# Local Imports 49from autohive_integrations_sdk import __version__ 50 51 52# ---- Type Definitions ---- 53T = TypeVar('T') 54 55# ---- Auth Types ---- 56class AuthType(Enum): 57 """Authentication strategy used by an integration. 58 59 The platform stores the auth type alongside credentials and passes both 60 to ``ExecutionContext``. ``context.fetch()`` uses the type to decide 61 whether to auto-inject an ``Authorization`` header. 62 63 Members: 64 PlatformOauth2: Platform-managed OAuth 2.0 — the platform handles the 65 token lifecycle and injects ``Bearer <access_token>`` automatically. 66 PlatformTeams: Platform-managed Microsoft Teams auth. 67 ApiKey: A single API key provided by the user. 68 Basic: Username/password (HTTP Basic) credentials. 69 Custom: Free-form credential fields defined by the integration's 70 ``config.json`` auth schema. The integration is responsible for 71 reading individual fields from ``context.auth``. 72 """ 73 PlatformOauth2 = "PlatformOauth2" 74 PlatformTeams = "PlatformTeams" 75 ApiKey = "ApiKey" 76 Basic = "Basic" 77 Custom = "Custom" 78 79class ResultType(Enum): 80 """Type of result being returned""" 81 ACTION = "action" 82 ACTION_ERROR = "action_error" 83 CONNECTED_ACCOUNT = "connected_account" 84 ERROR = "error" 85 VALIDATION_ERROR = "validation_error" 86 87# ---- Exceptions ---- 88class ValidationError(Exception): 89 """Raised when SDK validation fails. 90 91 This covers several cases: 92 93 - Action inputs don't match the ``input_schema`` in ``config.json`` 94 - Action outputs don't match the ``output_schema`` 95 - Auth credentials don't match the ``auth.fields`` schema 96 - An action handler returns something other than ``ActionResult`` 97 - A handler name isn't registered 98 """ 99 def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): 100 self.schema = schema 101 """The schema that failed validation""" 102 self.inputs = inputs 103 """The data that failed validation""" 104 self.message = message 105 """The error message""" 106 self.source = source 107 """Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)""" 108 super().__init__(message) 109 110class ConfigurationError(Exception): 111 """Raised when integration configuration is invalid""" 112 pass 113 114class HTTPError(Exception): 115 """Raised by ``ExecutionContext.fetch()`` for non-2xx HTTP responses (except 429).""" 116 def __init__(self, status: int, message: str, response_data: Any = None): 117 self.status = status 118 """Status code""" 119 self.message = message 120 """Error message""" 121 self.response_data = response_data 122 """Response data""" 123 super().__init__(f"HTTP {status}: {message}") 124 125class RateLimitError(HTTPError): 126 """Raised by ``ExecutionContext.fetch()`` on HTTP 429 (Too Many Requests). 127 128 Attributes: 129 retry_after: Seconds to wait before retrying, taken from the 130 ``Retry-After`` response header (defaults to 60 if absent). 131 """ 132 def __init__(self, retry_after: int, *args, **kwargs): 133 self.retry_after = retry_after 134 """Seconds to wait before retrying.""" 135 super().__init__(*args, **kwargs) 136 137# ---- Result Classes ---- 138@dataclass 139class FetchResponse: 140 """Response object returned by ``ExecutionContext.fetch()``. 141 142 Wraps the full HTTP response so callers can inspect status codes and 143 headers in addition to the parsed body. 144 145 Attributes: 146 status: HTTP status code (e.g. ``200``, ``201``). 147 headers: Response headers as a plain ``dict``. 148 data: Parsed JSON (``dict``/``list``) when the response is 149 ``application/json``, otherwise the raw response text. 150 ``None`` for empty 200/201/204 responses. 151 """ 152 status: int 153 headers: Dict[str, str] 154 data: Any 155 156@dataclass 157class ActionResult: 158 """Result returned by action handlers. 159 160 This class encapsulates the data returned by an action along with optional 161 billing information for cost tracking. 162 163 Args: 164 data: The actual result data from the action 165 cost_usd: Optional USD cost for billing purposes 166 167 Example: 168 ```python 169 return ActionResult( 170 data={"message": "Success", "result": 42}, 171 cost_usd=0.05 172 ) 173 ``` 174 """ 175 data: Any 176 cost_usd: Optional[float] = None 177 178@dataclass 179class ActionError: 180 """Error result returned by action handlers for expected/application-level errors. 181 182 When returned from an action handler, output schema validation is skipped 183 and the error is returned to the caller as a ResultType.ERROR result. 184 185 Args: 186 message: Human-readable error message 187 cost_usd: Optional USD cost incurred before the error occurred 188 189 Example: 190 ```python 191 return ActionError( 192 message="User not found", 193 cost_usd=0.01 194 ) 195 ``` 196 """ 197 message: str 198 cost_usd: Optional[float] = None 199 200@dataclass 201class ConnectedAccountInfo: 202 """Account metadata returned by a ``ConnectedAccountHandler``. 203 204 The platform calls the connected-account handler after a user links 205 their external account. The returned info is displayed in the 206 Autohive UI (avatar, name, email, etc.). 207 208 All fields are optional — populate whichever ones the external API provides. 209 """ 210 email: Optional[str] = None 211 first_name: Optional[str] = None 212 last_name: Optional[str] = None 213 username: Optional[str] = None 214 user_id: Optional[str] = None 215 avatar_url: Optional[str] = None 216 organization: Optional[str] = None 217 218@dataclass 219class IntegrationResult: 220 """Result format sent from lambda wrapper to backend. 221 222 This class represents the standardized format that the lambda wrapper 223 sends to the Autohive backend, including SDK version and type-specific data. 224 225 Args: 226 version: SDK version (auto-populated) 227 type: Type of result payload (ResultType enum: ACTION, CONNECTED_ACCOUNT, ERROR) 228 result: The result object - ActionResult for actions, ActionError for 229 application-level action errors, or ConnectedAccountInfo for 230 connected accounts. 231 The lambda wrapper serializes these to dicts using asdict(). 232 233 Note: 234 This type is returned by Integration methods and serialized by the lambda wrapper. 235 Integration developers should use ActionResult for action handlers and 236 ActionError for expected error conditions. 237 """ 238 version: str 239 type: ResultType 240 result: Union[ActionResult, ActionError, ConnectedAccountInfo] 241 242# ---- Configuration Classes ---- 243 244@dataclass 245class Parameter: 246 """Definition of a parameter""" 247 name: str 248 type: str 249 description: str 250 enum: Optional[List[str]] = None 251 required: bool = True 252 default: Any = None 253 254@dataclass 255class SchemaDefinition: 256 """Base class for components that have input/output schemas""" 257 name: str 258 description: str 259 input_schema: List[Parameter] 260 output_schema: Optional[Dict[str, Any]] = None 261 262@dataclass 263class Action(SchemaDefinition): 264 """Empty dataclass that inherits from SchemaDefinition""" 265 pass 266 267@dataclass 268class PollingTrigger(SchemaDefinition): 269 """Definition of a polling trigger""" 270 polling_interval: timedelta = field(default_factory=timedelta) 271 272@dataclass 273class IntegrationConfig: 274 """Configuration for an integration""" 275 name: str 276 version: str 277 description: str 278 auth: Dict[str, Any] 279 actions: Dict[str, Action] 280 polling_triggers: Dict[str, PollingTrigger] 281 282# ---- Base Handler Classes ---- 283class ActionHandler(ABC): 284 """Base class for action handlers. 285 286 Subclass this and implement ``execute()`` to handle a specific action. 287 Register it with the ``@integration.action("action_name")`` decorator. 288 289 Example:: 290 291 @integration.action("get_user") 292 class GetUser(ActionHandler): 293 async def execute(self, inputs, context): 294 user = (await context.fetch(f"https://api.example.com/users/{inputs['id']}")).data 295 return ActionResult(data=user) 296 """ 297 @abstractmethod 298 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: 299 """Run the action logic. 300 301 Args: 302 inputs: Validated action inputs matching the ``input_schema`` from ``config.json``. 303 context: Execution context providing ``fetch()``, ``auth``, and logging. 304 305 Returns: 306 An ``ActionResult`` containing the output data and optional ``cost_usd``. 307 """ 308 pass 309 310class PollingTriggerHandler(ABC): 311 """Base class for polling trigger handlers""" 312 @abstractmethod 313 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: 314 """Execute the polling trigger""" 315 pass 316 317class ConnectedAccountHandler(ABC): 318 """Base class for connected-account handlers. 319 320 The platform calls this after a user links their external account. 321 The returned ``ConnectedAccountInfo`` is shown in the Autohive UI. 322 323 Register with the ``@integration.connected_account()`` decorator. 324 325 Example:: 326 327 @integration.connected_account() 328 class MyAccountHandler(ConnectedAccountHandler): 329 async def get_account_info(self, context): 330 me = (await context.fetch("https://api.example.com/me")).data 331 return ConnectedAccountInfo( 332 email=me["email"], 333 first_name=me["first_name"], 334 last_name=me["last_name"], 335 ) 336 """ 337 @abstractmethod 338 async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccountInfo: 339 """Fetch account metadata from the external service. 340 341 For platform OAuth integrations, ``context.fetch()`` auto-injects 342 the Bearer token — no manual auth handling needed. 343 344 Returns: 345 A ``ConnectedAccountInfo`` with whichever fields the API provides. 346 """ 347 pass 348 349# ---- Core SDK Classes ---- 350class ExecutionContext: 351 """Context provided to integration handlers for making authenticated HTTP requests. 352 353 Manages an ``aiohttp`` session with automatic retries, error handling, and 354 optional Bearer-token injection for platform OAuth integrations. 355 356 Use as an async context manager:: 357 358 async with ExecutionContext(auth=auth) as context: 359 result = await integration.execute_action("my_action", inputs, context) 360 361 Args: 362 auth: Authentication data. This is always the platform auth envelope 363 ``{"auth_type": "...", "credentials": {...}}``. The ``credentials`` 364 object matches the ``auth.fields`` schema in ``config.json``. Local 365 tests must use the same wrapped shape (e.g. 366 ``{"auth_type": "Custom", "credentials": {"api_key": "..."}}``); flat 367 auth is not supported. Handlers should read 368 individual credentials via ``context.auth["credentials"]`` (for 369 example ``context.auth["credentials"].get("api_key", "")``); strict 370 validation guarantees this shape exists before a handler runs. 371 request_config: Override default ``max_retries`` (3) and ``timeout`` (30 s). 372 metadata: Arbitrary metadata forwarded to handlers. 373 logger: Custom logger; falls back to ``logging.getLogger(__name__)``. 374 """ 375 def __init__( 376 self, 377 auth: Dict[str, Any] = {}, 378 request_config: Optional[Dict[str, Any]] = None, 379 metadata: Optional[Dict[str, Any]] = None, 380 logger: Optional[logging.Logger] = None 381 ): 382 self.auth = auth 383 """Authentication configuration""" 384 self.config = request_config or {"max_retries": 3, "timeout": 30} 385 """Request configuration""" 386 self.metadata = metadata or {} 387 """Additional metadata""" 388 self.logger = logger or logging.getLogger(__name__) 389 """Logger instance""" 390 self._session: Optional[aiohttp.ClientSession] = None 391 392 async def __aenter__(self): 393 if not self._session: 394 self._session = aiohttp.ClientSession() 395 return self 396 397 async def __aexit__(self, exc_type, exc_val, exc_tb): 398 if self._session: 399 await self._session.close() 400 self._session = None 401 402 async def fetch( 403 self, 404 url: str, 405 method: str = "GET", 406 params: Optional[Dict[str, Any]] = None, 407 data: Any = None, 408 json: Any = None, 409 headers: Optional[Dict[str, str]] = None, 410 content_type: Optional[str] = None, 411 timeout: Optional[int] = None, 412 retry_count: int = 0 413 ) -> FetchResponse: 414 """Make an HTTP request with automatic retries and error handling. 415 416 For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``), 417 a ``Bearer`` token is auto-injected from ``auth.credentials.access_token`` 418 unless an ``Authorization`` header is explicitly provided. 419 420 Retries up to ``max_retries`` (default 3) on transient network errors 421 with exponential back-off. HTTP 429 responses raise ``RateLimitError`` 422 immediately (no automatic retry). 423 424 Args: 425 url: The URL to request. 426 method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, etc.). 427 params: Query parameters appended to the URL. Nested dicts/lists 428 are JSON-serialized automatically. 429 data: Raw request body. Encoding depends on ``content_type``. 430 json: JSON-serializable payload. Sets ``content_type`` to 431 ``application/json`` automatically. 432 headers: Additional HTTP headers. Merged *after* any auto-injected 433 auth header, so an explicit ``Authorization`` takes precedence. 434 content_type: ``Content-Type`` header value. 435 timeout: Per-request timeout in seconds (overrides ``request_config``). 436 retry_count: Internal — current retry attempt number. 437 438 Returns: 439 A ``FetchResponse`` containing the HTTP status code, response 440 headers, and parsed body data. 441 442 Raises: 443 RateLimitError: On HTTP 429 with the ``Retry-After`` value. 444 HTTPError: On any other non-2xx status. 445 """ 446 if not self._session: 447 self._session = aiohttp.ClientSession() 448 449 # Prepare request 450 if json is not None: 451 data = json 452 content_type = "application/json" 453 454 final_headers = {} 455 456 if self.auth and "Authorization" not in (headers or {}): 457 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) 458 credentials = self.auth.get("credentials", {}) 459 460 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: 461 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" 462 463 if content_type: 464 final_headers["Content-Type"] = content_type 465 if headers: 466 final_headers.update(headers) 467 468 if params: 469 # Handle nested dictionary parameters 470 flat_params = {} 471 for key, value in params.items(): 472 if isinstance(value, (dict, list)): 473 flat_params[key] = jsonX.dumps(value) 474 elif value is not None: 475 flat_params[key] = str(value) 476 query_string = urlencode(flat_params) 477 url = f"{url}{'&' if '?' in url else '?'}{query_string}" 478 479 # Prepare body 480 if data is not None: 481 if content_type == "application/json": 482 data = jsonX.dumps(data) 483 elif content_type == "application/x-www-form-urlencoded": 484 data = urlencode(data) if isinstance(data, dict) else data 485 486 # Store the original timeout numeric value 487 original_timeout = timeout or self.config["timeout"] 488 489 # Convert the numeric timeout to a ClientTimeout instance for this request 490 client_timeout = aiohttp.ClientTimeout(total=original_timeout) 491 492 try: 493 async with self._session.request( 494 method=method, 495 url=url, 496 data=data, 497 headers=final_headers, 498 timeout=client_timeout, 499 ssl=True 500 ) as response: 501 content_type = response.headers.get("Content-Type", "") 502 503 if response.status == 429: # Rate limit 504 retry_after = int(response.headers.get("Retry-After", 60)) 505 raise RateLimitError( 506 retry_after, 507 response.status, 508 "Rate limit exceeded", 509 await response.text() 510 ) 511 512 try: 513 if "application/json" in content_type: 514 result = await response.json() 515 else: 516 result = await response.text() 517 if not result and response.status in {200, 201, 204}: 518 result = None 519 except Exception as e: 520 self.logger.error(f"Error parsing response: {e}") 521 result = await response.text() 522 523 response_headers = dict(response.headers) 524 525 if not response.ok: 526 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") 527 raise HTTPError(response.status, str(result), result) 528 529 return FetchResponse( 530 status=response.status, 531 headers=response_headers, 532 data=result, 533 ) 534 535 except RateLimitError: 536 raise 537 except (aiohttp.ClientError, asyncio.TimeoutError) as e: 538 # Don't want to send this to Raygun here because this will be retried. 539 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") 540 if retry_count < self.config["max_retries"]: 541 await asyncio.sleep(2 ** retry_count) # Exponential backoff 542 print("Retrying request...") 543 # Use original_timeout (numeric) for recursive calls 544 return await self.fetch( 545 url, method, params, data, json, 546 headers, content_type, original_timeout, retry_count + 1 547 ) 548 else: 549 print("Max retries reached. Raising error.") 550 raise 551 except Exception as e: 552 self.logger.error(f"Unexpected error during {method} {url}: {e}") 553 print(f"Unexpected error encountered: {e}") 554 raise 555 556 557class Integration: 558 """Base integration class with handler registration and execution. 559 560 This class manages the integration configuration, handler registration, 561 and provides methods to execute actions and triggers. 562 563 Args: 564 config: Integration configuration 565 566 Attributes: 567 config: Integration configuration 568 """ 569 570 def __init__(self, config: IntegrationConfig): 571 self.config = config 572 """Integration configuration""" 573 self._action_handlers: Dict[str, Type[ActionHandler]] = {} 574 """Action handlers""" 575 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} 576 """Polling handlers""" 577 self._connected_account_handler: Optional[Type[ConnectedAccountHandler]] = None 578 """Connected account handler""" 579 580 @classmethod 581 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': 582 """Load an integration from its ``config.json``. 583 584 Args: 585 config_path: Explicit path to ``config.json``. When omitted the 586 SDK resolves the path relative to its own package location, 587 which works when the SDK is vendored via 588 ``pip install --target dependencies``. Multi-file integrations 589 that use ``actions/`` sub-packages should pass an explicit path 590 (e.g. ``Integration.load("config.json")``). 591 592 Returns: 593 A fully initialised ``Integration`` ready for handler registration. 594 595 Raises: 596 ConfigurationError: If the file is missing or contains invalid JSON. 597 """ 598 if config_path is None: 599 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') 600 601 config_path = Path(config_path) 602 603 if not config_path.exists(): 604 raise ConfigurationError(f"Configuration file not found: {config_path}") 605 606 try: 607 with open(config_path, 'r') as f: 608 config_data = json.load(f) 609 except json.JSONDecodeError as e: 610 raise ConfigurationError(f"Invalid JSON configuration: {e}") 611 612 # Parse configuration sections 613 actions = cls._parse_actions(config_data.get("actions", {})) 614 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) 615 616 config = IntegrationConfig( 617 name=config_data["name"], 618 version=config_data["version"], 619 description=config_data["description"], 620 auth=config_data.get("auth", {}), 621 actions=actions, 622 polling_triggers=polling_triggers 623 ) 624 625 return cls(config) 626 627 @staticmethod 628 def _parse_interval(interval_str: str) -> timedelta: 629 """Parse interval string into timedelta""" 630 unit = interval_str[-1].lower() 631 value = int(interval_str[:-1]) 632 633 if unit == 's': 634 return timedelta(seconds=value) 635 elif unit == 'm': 636 return timedelta(minutes=value) 637 elif unit == 'h': 638 return timedelta(hours=value) 639 elif unit == 'd': 640 return timedelta(days=value) 641 else: 642 raise ConfigurationError(f"Invalid interval format: {interval_str}") 643 644 @classmethod 645 def _parse_actions(cls, actions_config: Dict[str, Any]) -> Dict[str, Action]: 646 """Parse action configurations""" 647 actions = {} 648 for name, data in actions_config.items(): 649 actions[name] = Action( 650 name=name, 651 description=data["description"], 652 input_schema=data["input_schema"], 653 output_schema=data["output_schema"] 654 ) 655 656 return actions 657 658 @classmethod 659 def _parse_polling_triggers(cls, triggers_config: Dict[str, Any]) -> Dict[str, PollingTrigger]: 660 """Parse polling trigger configurations""" 661 triggers = {} 662 for name, data in triggers_config.items(): 663 interval = cls._parse_interval(data["polling_interval"]) 664 665 triggers[name] = PollingTrigger( 666 name=name, 667 description=data["description"], 668 polling_interval=interval, 669 input_schema=data["input_schema"], 670 output_schema=data["output_schema"] 671 ) 672 673 return triggers 674 675 def action(self, name: str): 676 """Decorator to register an action handler. 677 678 Args: 679 name: Name of the action to register 680 681 Returns: 682 Decorator function 683 684 Raises: 685 ConfigurationError: If action is not defined in config 686 687 Example: 688 ```python 689 @integration.action("my_action") 690 class MyActionHandler(ActionHandler): 691 async def execute(self, inputs, context): 692 # Implementation 693 return result 694 ``` 695 """ 696 def decorator(handler_class: Type[ActionHandler]): 697 if name not in self.config.actions: 698 raise ConfigurationError(f"Action '{name}' not defined in config") 699 self._action_handlers[name] = handler_class 700 return handler_class 701 return decorator 702 703 def polling_trigger(self, name: str): 704 """Decorator to register a polling trigger handler 705 706 Args: 707 name: Name of the polling trigger to register 708 709 Returns: 710 Decorator function 711 712 Raises: 713 ConfigurationError: If polling trigger is not defined in config 714 715 Example: 716 ```python 717 @integration.polling_trigger("my_polling_trigger") 718 class MyPollingTriggerHandler(PollingTriggerHandler): 719 async def poll(self, inputs, last_poll_ts, context): 720 # Implementation 721 return result 722 ``` 723 """ 724 def decorator(handler_class: Type[PollingTriggerHandler]): 725 if name not in self.config.polling_triggers: 726 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") 727 self._polling_handlers[name] = handler_class 728 return handler_class 729 return decorator 730 731 def connected_account(self): 732 """Decorator to register a connected account handler 733 734 Returns: 735 Decorator function 736 737 Example: 738 ```python 739 @integration.connected_account() 740 class MyConnectedAccountHandler(ConnectedAccountHandler): 741 async def get_account_info(self, context): 742 # Implementation 743 return {"email": "user@example.com", "name": "John Doe"} 744 ``` 745 """ 746 def decorator(handler_class: Type[ConnectedAccountHandler]): 747 self._connected_account_handler = handler_class 748 return handler_class 749 return decorator 750 751 def _validate_auth(self, context: ExecutionContext) -> None: 752 """Validate the auth envelope's credentials against ``auth.fields``. 753 754 The platform always passes ``context.auth`` as the 755 wrapped envelope ``{"auth_type": ..., "credentials": {...}}``. The 756 ``auth.fields`` schema in ``config.json`` describes only the inner 757 ``credentials`` object, so validation runs against 758 ``context.auth["credentials"]`` — not the whole envelope. 759 760 Integrations with no auth or no ``fields`` key skip validation entirely. 761 762 Raises: 763 ValidationError: (source ``"auth"``) if ``context.auth`` is not a 764 wrapped envelope (a dict with a non-empty string ``auth_type`` 765 and a dict ``credentials``), or if the credentials fail the 766 schema. 767 """ 768 if "fields" not in self.config.auth: 769 return 770 771 auth = context.auth 772 has_valid_credentials = isinstance(auth, dict) and isinstance(auth.get("credentials"), dict) 773 has_valid_auth_type = ( 774 isinstance(auth, dict) 775 and isinstance(auth.get("auth_type"), str) 776 and auth.get("auth_type") != "" 777 ) 778 if not has_valid_credentials or not has_valid_auth_type: 779 raise ValidationError( 780 'context.auth must be the platform auth envelope ' 781 '{"auth_type": ..., "credentials": {...}} with a non-empty ' 782 'auth_type; flat auth is not supported.', 783 source="auth", 784 ) 785 786 valid_auth_types = {member.value for member in AuthType} 787 if auth["auth_type"] not in valid_auth_types: 788 raise ValidationError( 789 f'Unknown auth_type "{auth["auth_type"]}" in context.auth; ' 790 f'expected one of: {", ".join(sorted(valid_auth_types))}.', 791 source="auth", 792 ) 793 794 auth_config = self.config.auth["fields"] 795 validator = Draft7Validator(auth_config) 796 errors = sorted(validator.iter_errors(context.auth["credentials"]), key=lambda e: e.path) 797 if errors: 798 message = "" 799 for error in errors: 800 message += f"{list(error.schema_path)}, {error.message},\n " 801 raise ValidationError(message, auth_config, context.auth["credentials"], source="auth") 802 803 async def execute_action(self, 804 name: str, 805 inputs: Dict[str, Any], 806 context: ExecutionContext) -> IntegrationResult: 807 """Execute a registered action. 808 809 Args: 810 name: Name of the action to execute 811 inputs: Action inputs 812 context: Execution context 813 814 Returns: 815 IntegrationResult with action data (ResultType.ACTION), 816 action error (ResultType.ACTION_ERROR) if the handler returned ActionError, 817 or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. 818 """ 819 try: 820 if name not in self._action_handlers: 821 raise ValidationError(f"Action '{name}' not registered") 822 823 # Validate inputs against action schema 824 action_config = self.config.actions[name] 825 validator = Draft7Validator(action_config.input_schema) 826 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) 827 if errors: 828 message = "" 829 for error in errors: 830 message += f"{list(error.schema_path)}, {error.message},\n " 831 raise ValidationError(message, action_config.input_schema, inputs, source="input") 832 833 self._validate_auth(context) 834 835 # Create handler instance and execute 836 handler = self._action_handlers[name]() 837 result = await handler.execute(inputs, context) 838 839 # Handle ActionError - skip output schema validation 840 if isinstance(result, ActionError): 841 return IntegrationResult( 842 version=__version__, 843 type=ResultType.ACTION_ERROR, 844 result=result 845 ) 846 847 # Validate that result is ActionResult 848 if not isinstance(result, ActionResult): 849 raise ValidationError( 850 f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", 851 source="output" 852 ) 853 854 # Validate output schema against the data inside ActionResult 855 validator = Draft7Validator(action_config.output_schema) 856 errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) 857 if errors: 858 message = "" 859 for error in errors: 860 message += f"{list(error.schema_path)}, {error.message},\n " 861 raise ValidationError(message, action_config.output_schema, result.data, source="output") 862 863 # Return IntegrationResult with ActionResult directly 864 return IntegrationResult( 865 version=__version__, 866 type=ResultType.ACTION, 867 result=result 868 ) 869 except ValidationError as e: 870 return IntegrationResult( 871 version=__version__, 872 type=ResultType.VALIDATION_ERROR, 873 result={ 874 'message': str(e), 875 'property': None, 876 'value': None, 877 'source': getattr(e, 'source', 'legacy') 878 } 879 ) 880 881 async def execute_polling_trigger(self, 882 name: str, 883 inputs: Dict[str, Any], 884 last_poll_ts: Optional[str], 885 context: ExecutionContext) -> List[Dict[str, Any]]: 886 """Execute a registered polling trigger 887 888 Args: 889 name: Name of the polling trigger to execute 890 inputs: Trigger inputs 891 last_poll_ts: Last poll timestamp 892 context: Execution context 893 894 Returns: 895 List of records 896 897 Raises: 898 ValidationError: If inputs or outputs don't match schema 899 """ 900 if name not in self._polling_handlers: 901 raise ValidationError(f"Polling trigger '{name}' not registered") 902 903 # Validate trigger configuration 904 trigger_config = self.config.polling_triggers[name] 905 try: 906 validate(inputs, trigger_config.input_schema) 907 except Exception as e: 908 raise ValidationError(e.message, e.schema, e.instance) 909 910 self._validate_auth(context) 911 912 # Create handler instance and execute 913 handler = self._polling_handlers[name]() 914 records = await handler.poll(inputs, last_poll_ts, context) 915 # Validate each record 916 for record in records: 917 if "id" not in record: 918 raise ValidationError( 919 f"Polling trigger '{name}' returned record without required 'id' field") 920 if "data" not in record: 921 raise ValidationError( 922 f"Polling trigger '{name}' returned record without required 'data' field") 923 924 # Validate record data against output schema 925 try: 926 validate(record["data"], trigger_config.output_schema) 927 except Exception as e: 928 raise ValidationError(e.message, e.schema, e.instance) 929 930 return records 931 932 async def get_connected_account(self, context: ExecutionContext) -> IntegrationResult: 933 """Get connected account information 934 935 Args: 936 context: Execution context 937 938 Returns: 939 IntegrationResult containing connected account data 940 941 Raises: 942 ValidationError: If no connected account handler is registered or auth is invalid 943 """ 944 if not self._connected_account_handler: 945 raise ValidationError("No connected account handler registered") 946 947 self._validate_auth(context) 948 949 handler = self._connected_account_handler() 950 account_info = await handler.get_account_info(context) 951 952 if not isinstance(account_info, ConnectedAccountInfo): 953 raise ValidationError( 954 f"Connected account handler must return ConnectedAccountInfo, got {type(account_info).__name__}" 955 ) 956 957 # Return IntegrationResult with ConnectedAccountInfo object directly 958 return IntegrationResult( 959 version=__version__, 960 type=ResultType.CONNECTED_ACCOUNT, 961 result=account_info 962 )
57class AuthType(Enum): 58 """Authentication strategy used by an integration. 59 60 The platform stores the auth type alongside credentials and passes both 61 to ``ExecutionContext``. ``context.fetch()`` uses the type to decide 62 whether to auto-inject an ``Authorization`` header. 63 64 Members: 65 PlatformOauth2: Platform-managed OAuth 2.0 — the platform handles the 66 token lifecycle and injects ``Bearer <access_token>`` automatically. 67 PlatformTeams: Platform-managed Microsoft Teams auth. 68 ApiKey: A single API key provided by the user. 69 Basic: Username/password (HTTP Basic) credentials. 70 Custom: Free-form credential fields defined by the integration's 71 ``config.json`` auth schema. The integration is responsible for 72 reading individual fields from ``context.auth``. 73 """ 74 PlatformOauth2 = "PlatformOauth2" 75 PlatformTeams = "PlatformTeams" 76 ApiKey = "ApiKey" 77 Basic = "Basic" 78 Custom = "Custom"
Authentication strategy used by an integration.
The platform stores the auth type alongside credentials and passes both
to ExecutionContext. context.fetch() uses the type to decide
whether to auto-inject an Authorization header.
Members:
PlatformOauth2: Platform-managed OAuth 2.0 — the platform handles the
token lifecycle and injects Bearer <access_token> automatically.
PlatformTeams: Platform-managed Microsoft Teams auth.
ApiKey: A single API key provided by the user.
Basic: Username/password (HTTP Basic) credentials.
Custom: Free-form credential fields defined by the integration's
config.json auth schema. The integration is responsible for
reading individual fields from context.auth.
80class ResultType(Enum): 81 """Type of result being returned""" 82 ACTION = "action" 83 ACTION_ERROR = "action_error" 84 CONNECTED_ACCOUNT = "connected_account" 85 ERROR = "error" 86 VALIDATION_ERROR = "validation_error"
Type of result being returned
89class ValidationError(Exception): 90 """Raised when SDK validation fails. 91 92 This covers several cases: 93 94 - Action inputs don't match the ``input_schema`` in ``config.json`` 95 - Action outputs don't match the ``output_schema`` 96 - Auth credentials don't match the ``auth.fields`` schema 97 - An action handler returns something other than ``ActionResult`` 98 - A handler name isn't registered 99 """ 100 def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): 101 self.schema = schema 102 """The schema that failed validation""" 103 self.inputs = inputs 104 """The data that failed validation""" 105 self.message = message 106 """The error message""" 107 self.source = source 108 """Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)""" 109 super().__init__(message)
Raised when SDK validation fails.
This covers several cases:
- Action inputs don't match the
input_schemainconfig.json - Action outputs don't match the
output_schema - Auth credentials don't match the
auth.fieldsschema - An action handler returns something other than
ActionResult - A handler name isn't registered
100 def __init__(self, message: str, schema: str = None, inputs: str = None, source: str = "legacy"): 101 self.schema = schema 102 """The schema that failed validation""" 103 self.inputs = inputs 104 """The data that failed validation""" 105 self.message = message 106 """The error message""" 107 self.source = source 108 """Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)""" 109 super().__init__(message)
111class ConfigurationError(Exception): 112 """Raised when integration configuration is invalid""" 113 pass
Raised when integration configuration is invalid
115class HTTPError(Exception): 116 """Raised by ``ExecutionContext.fetch()`` for non-2xx HTTP responses (except 429).""" 117 def __init__(self, status: int, message: str, response_data: Any = None): 118 self.status = status 119 """Status code""" 120 self.message = message 121 """Error message""" 122 self.response_data = response_data 123 """Response data""" 124 super().__init__(f"HTTP {status}: {message}")
Raised by ExecutionContext.fetch() for non-2xx HTTP responses (except 429).
126class RateLimitError(HTTPError): 127 """Raised by ``ExecutionContext.fetch()`` on HTTP 429 (Too Many Requests). 128 129 Attributes: 130 retry_after: Seconds to wait before retrying, taken from the 131 ``Retry-After`` response header (defaults to 60 if absent). 132 """ 133 def __init__(self, retry_after: int, *args, **kwargs): 134 self.retry_after = retry_after 135 """Seconds to wait before retrying.""" 136 super().__init__(*args, **kwargs)
Raised by ExecutionContext.fetch() on HTTP 429 (Too Many Requests).
Attributes:
retry_after: Seconds to wait before retrying, taken from the
Retry-After response header (defaults to 60 if absent).
Inherited Members
139@dataclass 140class FetchResponse: 141 """Response object returned by ``ExecutionContext.fetch()``. 142 143 Wraps the full HTTP response so callers can inspect status codes and 144 headers in addition to the parsed body. 145 146 Attributes: 147 status: HTTP status code (e.g. ``200``, ``201``). 148 headers: Response headers as a plain ``dict``. 149 data: Parsed JSON (``dict``/``list``) when the response is 150 ``application/json``, otherwise the raw response text. 151 ``None`` for empty 200/201/204 responses. 152 """ 153 status: int 154 headers: Dict[str, str] 155 data: Any
Response object returned by ExecutionContext.fetch().
Wraps the full HTTP response so callers can inspect status codes and headers in addition to the parsed body.
Attributes:
status: HTTP status code (e.g. 200, 201).
headers: Response headers as a plain dict.
data: Parsed JSON (dict/list) when the response is
application/json, otherwise the raw response text.
None for empty 200/201/204 responses.
157@dataclass 158class ActionResult: 159 """Result returned by action handlers. 160 161 This class encapsulates the data returned by an action along with optional 162 billing information for cost tracking. 163 164 Args: 165 data: The actual result data from the action 166 cost_usd: Optional USD cost for billing purposes 167 168 Example: 169 ```python 170 return ActionResult( 171 data={"message": "Success", "result": 42}, 172 cost_usd=0.05 173 ) 174 ``` 175 """ 176 data: Any 177 cost_usd: Optional[float] = None
Result returned by action handlers.
This class encapsulates the data returned by an action along with optional billing information for cost tracking.
Args: data: The actual result data from the action cost_usd: Optional USD cost for billing purposes
Example:
return ActionResult(
data={"message": "Success", "result": 42},
cost_usd=0.05
)
179@dataclass 180class ActionError: 181 """Error result returned by action handlers for expected/application-level errors. 182 183 When returned from an action handler, output schema validation is skipped 184 and the error is returned to the caller as a ResultType.ERROR result. 185 186 Args: 187 message: Human-readable error message 188 cost_usd: Optional USD cost incurred before the error occurred 189 190 Example: 191 ```python 192 return ActionError( 193 message="User not found", 194 cost_usd=0.01 195 ) 196 ``` 197 """ 198 message: str 199 cost_usd: Optional[float] = None
Error result returned by action handlers for expected/application-level errors.
When returned from an action handler, output schema validation is skipped and the error is returned to the caller as a ResultType.ERROR result.
Args: message: Human-readable error message cost_usd: Optional USD cost incurred before the error occurred
Example:
return ActionError(
message="User not found",
cost_usd=0.01
)
201@dataclass 202class ConnectedAccountInfo: 203 """Account metadata returned by a ``ConnectedAccountHandler``. 204 205 The platform calls the connected-account handler after a user links 206 their external account. The returned info is displayed in the 207 Autohive UI (avatar, name, email, etc.). 208 209 All fields are optional — populate whichever ones the external API provides. 210 """ 211 email: Optional[str] = None 212 first_name: Optional[str] = None 213 last_name: Optional[str] = None 214 username: Optional[str] = None 215 user_id: Optional[str] = None 216 avatar_url: Optional[str] = None 217 organization: Optional[str] = None
Account metadata returned by a ConnectedAccountHandler.
The platform calls the connected-account handler after a user links their external account. The returned info is displayed in the Autohive UI (avatar, name, email, etc.).
All fields are optional — populate whichever ones the external API provides.
219@dataclass 220class IntegrationResult: 221 """Result format sent from lambda wrapper to backend. 222 223 This class represents the standardized format that the lambda wrapper 224 sends to the Autohive backend, including SDK version and type-specific data. 225 226 Args: 227 version: SDK version (auto-populated) 228 type: Type of result payload (ResultType enum: ACTION, CONNECTED_ACCOUNT, ERROR) 229 result: The result object - ActionResult for actions, ActionError for 230 application-level action errors, or ConnectedAccountInfo for 231 connected accounts. 232 The lambda wrapper serializes these to dicts using asdict(). 233 234 Note: 235 This type is returned by Integration methods and serialized by the lambda wrapper. 236 Integration developers should use ActionResult for action handlers and 237 ActionError for expected error conditions. 238 """ 239 version: str 240 type: ResultType 241 result: Union[ActionResult, ActionError, ConnectedAccountInfo]
Result format sent from lambda wrapper to backend.
This class represents the standardized format that the lambda wrapper sends to the Autohive backend, including SDK version and type-specific data.
Args: version: SDK version (auto-populated) type: Type of result payload (ResultType enum: ACTION, CONNECTED_ACCOUNT, ERROR) result: The result object - ActionResult for actions, ActionError for application-level action errors, or ConnectedAccountInfo for connected accounts. The lambda wrapper serializes these to dicts using asdict().
Note: This type is returned by Integration methods and serialized by the lambda wrapper. Integration developers should use ActionResult for action handlers and ActionError for expected error conditions.
245@dataclass 246class Parameter: 247 """Definition of a parameter""" 248 name: str 249 type: str 250 description: str 251 enum: Optional[List[str]] = None 252 required: bool = True 253 default: Any = None
Definition of a parameter
255@dataclass 256class SchemaDefinition: 257 """Base class for components that have input/output schemas""" 258 name: str 259 description: str 260 input_schema: List[Parameter] 261 output_schema: Optional[Dict[str, Any]] = None
Base class for components that have input/output schemas
263@dataclass 264class Action(SchemaDefinition): 265 """Empty dataclass that inherits from SchemaDefinition""" 266 pass
Empty dataclass that inherits from SchemaDefinition
Inherited Members
268@dataclass 269class PollingTrigger(SchemaDefinition): 270 """Definition of a polling trigger""" 271 polling_interval: timedelta = field(default_factory=timedelta)
Definition of a polling trigger
Inherited Members
273@dataclass 274class IntegrationConfig: 275 """Configuration for an integration""" 276 name: str 277 version: str 278 description: str 279 auth: Dict[str, Any] 280 actions: Dict[str, Action] 281 polling_triggers: Dict[str, PollingTrigger]
Configuration for an integration
284class ActionHandler(ABC): 285 """Base class for action handlers. 286 287 Subclass this and implement ``execute()`` to handle a specific action. 288 Register it with the ``@integration.action("action_name")`` decorator. 289 290 Example:: 291 292 @integration.action("get_user") 293 class GetUser(ActionHandler): 294 async def execute(self, inputs, context): 295 user = (await context.fetch(f"https://api.example.com/users/{inputs['id']}")).data 296 return ActionResult(data=user) 297 """ 298 @abstractmethod 299 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: 300 """Run the action logic. 301 302 Args: 303 inputs: Validated action inputs matching the ``input_schema`` from ``config.json``. 304 context: Execution context providing ``fetch()``, ``auth``, and logging. 305 306 Returns: 307 An ``ActionResult`` containing the output data and optional ``cost_usd``. 308 """ 309 pass
Base class for action handlers.
Subclass this and implement execute() to handle a specific action.
Register it with the @integration.action("action_name") decorator.
Example::
@integration.action("get_user")
class GetUser(ActionHandler):
async def execute(self, inputs, context):
user = (await context.fetch(f"https://api.example.com/users/{inputs['id']}")).data
return ActionResult(data=user)
298 @abstractmethod 299 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: 300 """Run the action logic. 301 302 Args: 303 inputs: Validated action inputs matching the ``input_schema`` from ``config.json``. 304 context: Execution context providing ``fetch()``, ``auth``, and logging. 305 306 Returns: 307 An ``ActionResult`` containing the output data and optional ``cost_usd``. 308 """ 309 pass
Run the action logic.
Args:
inputs: Validated action inputs matching the input_schema from config.json.
context: Execution context providing fetch(), auth, and logging.
Returns:
An ActionResult containing the output data and optional cost_usd.
311class PollingTriggerHandler(ABC): 312 """Base class for polling trigger handlers""" 313 @abstractmethod 314 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: 315 """Execute the polling trigger""" 316 pass
Base class for polling trigger handlers
313 @abstractmethod 314 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: 315 """Execute the polling trigger""" 316 pass
Execute the polling trigger
318class ConnectedAccountHandler(ABC): 319 """Base class for connected-account handlers. 320 321 The platform calls this after a user links their external account. 322 The returned ``ConnectedAccountInfo`` is shown in the Autohive UI. 323 324 Register with the ``@integration.connected_account()`` decorator. 325 326 Example:: 327 328 @integration.connected_account() 329 class MyAccountHandler(ConnectedAccountHandler): 330 async def get_account_info(self, context): 331 me = (await context.fetch("https://api.example.com/me")).data 332 return ConnectedAccountInfo( 333 email=me["email"], 334 first_name=me["first_name"], 335 last_name=me["last_name"], 336 ) 337 """ 338 @abstractmethod 339 async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccountInfo: 340 """Fetch account metadata from the external service. 341 342 For platform OAuth integrations, ``context.fetch()`` auto-injects 343 the Bearer token — no manual auth handling needed. 344 345 Returns: 346 A ``ConnectedAccountInfo`` with whichever fields the API provides. 347 """ 348 pass
Base class for connected-account handlers.
The platform calls this after a user links their external account.
The returned ConnectedAccountInfo is shown in the Autohive UI.
Register with the @integration.connected_account() decorator.
Example::
@integration.connected_account()
class MyAccountHandler(ConnectedAccountHandler):
async def get_account_info(self, context):
me = (await context.fetch("https://api.example.com/me")).data
return ConnectedAccountInfo(
email=me["email"],
first_name=me["first_name"],
last_name=me["last_name"],
)
338 @abstractmethod 339 async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccountInfo: 340 """Fetch account metadata from the external service. 341 342 For platform OAuth integrations, ``context.fetch()`` auto-injects 343 the Bearer token — no manual auth handling needed. 344 345 Returns: 346 A ``ConnectedAccountInfo`` with whichever fields the API provides. 347 """ 348 pass
Fetch account metadata from the external service.
For platform OAuth integrations, context.fetch() auto-injects
the Bearer token — no manual auth handling needed.
Returns:
A ConnectedAccountInfo with whichever fields the API provides.
351class ExecutionContext: 352 """Context provided to integration handlers for making authenticated HTTP requests. 353 354 Manages an ``aiohttp`` session with automatic retries, error handling, and 355 optional Bearer-token injection for platform OAuth integrations. 356 357 Use as an async context manager:: 358 359 async with ExecutionContext(auth=auth) as context: 360 result = await integration.execute_action("my_action", inputs, context) 361 362 Args: 363 auth: Authentication data. This is always the platform auth envelope 364 ``{"auth_type": "...", "credentials": {...}}``. The ``credentials`` 365 object matches the ``auth.fields`` schema in ``config.json``. Local 366 tests must use the same wrapped shape (e.g. 367 ``{"auth_type": "Custom", "credentials": {"api_key": "..."}}``); flat 368 auth is not supported. Handlers should read 369 individual credentials via ``context.auth["credentials"]`` (for 370 example ``context.auth["credentials"].get("api_key", "")``); strict 371 validation guarantees this shape exists before a handler runs. 372 request_config: Override default ``max_retries`` (3) and ``timeout`` (30 s). 373 metadata: Arbitrary metadata forwarded to handlers. 374 logger: Custom logger; falls back to ``logging.getLogger(__name__)``. 375 """ 376 def __init__( 377 self, 378 auth: Dict[str, Any] = {}, 379 request_config: Optional[Dict[str, Any]] = None, 380 metadata: Optional[Dict[str, Any]] = None, 381 logger: Optional[logging.Logger] = None 382 ): 383 self.auth = auth 384 """Authentication configuration""" 385 self.config = request_config or {"max_retries": 3, "timeout": 30} 386 """Request configuration""" 387 self.metadata = metadata or {} 388 """Additional metadata""" 389 self.logger = logger or logging.getLogger(__name__) 390 """Logger instance""" 391 self._session: Optional[aiohttp.ClientSession] = None 392 393 async def __aenter__(self): 394 if not self._session: 395 self._session = aiohttp.ClientSession() 396 return self 397 398 async def __aexit__(self, exc_type, exc_val, exc_tb): 399 if self._session: 400 await self._session.close() 401 self._session = None 402 403 async def fetch( 404 self, 405 url: str, 406 method: str = "GET", 407 params: Optional[Dict[str, Any]] = None, 408 data: Any = None, 409 json: Any = None, 410 headers: Optional[Dict[str, str]] = None, 411 content_type: Optional[str] = None, 412 timeout: Optional[int] = None, 413 retry_count: int = 0 414 ) -> FetchResponse: 415 """Make an HTTP request with automatic retries and error handling. 416 417 For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``), 418 a ``Bearer`` token is auto-injected from ``auth.credentials.access_token`` 419 unless an ``Authorization`` header is explicitly provided. 420 421 Retries up to ``max_retries`` (default 3) on transient network errors 422 with exponential back-off. HTTP 429 responses raise ``RateLimitError`` 423 immediately (no automatic retry). 424 425 Args: 426 url: The URL to request. 427 method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, etc.). 428 params: Query parameters appended to the URL. Nested dicts/lists 429 are JSON-serialized automatically. 430 data: Raw request body. Encoding depends on ``content_type``. 431 json: JSON-serializable payload. Sets ``content_type`` to 432 ``application/json`` automatically. 433 headers: Additional HTTP headers. Merged *after* any auto-injected 434 auth header, so an explicit ``Authorization`` takes precedence. 435 content_type: ``Content-Type`` header value. 436 timeout: Per-request timeout in seconds (overrides ``request_config``). 437 retry_count: Internal — current retry attempt number. 438 439 Returns: 440 A ``FetchResponse`` containing the HTTP status code, response 441 headers, and parsed body data. 442 443 Raises: 444 RateLimitError: On HTTP 429 with the ``Retry-After`` value. 445 HTTPError: On any other non-2xx status. 446 """ 447 if not self._session: 448 self._session = aiohttp.ClientSession() 449 450 # Prepare request 451 if json is not None: 452 data = json 453 content_type = "application/json" 454 455 final_headers = {} 456 457 if self.auth and "Authorization" not in (headers or {}): 458 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) 459 credentials = self.auth.get("credentials", {}) 460 461 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: 462 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" 463 464 if content_type: 465 final_headers["Content-Type"] = content_type 466 if headers: 467 final_headers.update(headers) 468 469 if params: 470 # Handle nested dictionary parameters 471 flat_params = {} 472 for key, value in params.items(): 473 if isinstance(value, (dict, list)): 474 flat_params[key] = jsonX.dumps(value) 475 elif value is not None: 476 flat_params[key] = str(value) 477 query_string = urlencode(flat_params) 478 url = f"{url}{'&' if '?' in url else '?'}{query_string}" 479 480 # Prepare body 481 if data is not None: 482 if content_type == "application/json": 483 data = jsonX.dumps(data) 484 elif content_type == "application/x-www-form-urlencoded": 485 data = urlencode(data) if isinstance(data, dict) else data 486 487 # Store the original timeout numeric value 488 original_timeout = timeout or self.config["timeout"] 489 490 # Convert the numeric timeout to a ClientTimeout instance for this request 491 client_timeout = aiohttp.ClientTimeout(total=original_timeout) 492 493 try: 494 async with self._session.request( 495 method=method, 496 url=url, 497 data=data, 498 headers=final_headers, 499 timeout=client_timeout, 500 ssl=True 501 ) as response: 502 content_type = response.headers.get("Content-Type", "") 503 504 if response.status == 429: # Rate limit 505 retry_after = int(response.headers.get("Retry-After", 60)) 506 raise RateLimitError( 507 retry_after, 508 response.status, 509 "Rate limit exceeded", 510 await response.text() 511 ) 512 513 try: 514 if "application/json" in content_type: 515 result = await response.json() 516 else: 517 result = await response.text() 518 if not result and response.status in {200, 201, 204}: 519 result = None 520 except Exception as e: 521 self.logger.error(f"Error parsing response: {e}") 522 result = await response.text() 523 524 response_headers = dict(response.headers) 525 526 if not response.ok: 527 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") 528 raise HTTPError(response.status, str(result), result) 529 530 return FetchResponse( 531 status=response.status, 532 headers=response_headers, 533 data=result, 534 ) 535 536 except RateLimitError: 537 raise 538 except (aiohttp.ClientError, asyncio.TimeoutError) as e: 539 # Don't want to send this to Raygun here because this will be retried. 540 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") 541 if retry_count < self.config["max_retries"]: 542 await asyncio.sleep(2 ** retry_count) # Exponential backoff 543 print("Retrying request...") 544 # Use original_timeout (numeric) for recursive calls 545 return await self.fetch( 546 url, method, params, data, json, 547 headers, content_type, original_timeout, retry_count + 1 548 ) 549 else: 550 print("Max retries reached. Raising error.") 551 raise 552 except Exception as e: 553 self.logger.error(f"Unexpected error during {method} {url}: {e}") 554 print(f"Unexpected error encountered: {e}") 555 raise
Context provided to integration handlers for making authenticated HTTP requests.
Manages an aiohttp session with automatic retries, error handling, and
optional Bearer-token injection for platform OAuth integrations.
Use as an async context manager::
async with ExecutionContext(auth=auth) as context:
result = await integration.execute_action("my_action", inputs, context)
Args:
auth: Authentication data. This is always the platform auth envelope
{"auth_type": "...", "credentials": {...}}. The credentials
object matches the auth.fields schema in config.json. Local
tests must use the same wrapped shape (e.g.
{"auth_type": "Custom", "credentials": {"api_key": "..."}}); flat
auth is not supported. Handlers should read
individual credentials via context.auth["credentials"] (for
example context.auth["credentials"].get("api_key", "")); strict
validation guarantees this shape exists before a handler runs.
request_config: Override default max_retries (3) and timeout (30 s).
metadata: Arbitrary metadata forwarded to handlers.
logger: Custom logger; falls back to logging.getLogger(__name__).
376 def __init__( 377 self, 378 auth: Dict[str, Any] = {}, 379 request_config: Optional[Dict[str, Any]] = None, 380 metadata: Optional[Dict[str, Any]] = None, 381 logger: Optional[logging.Logger] = None 382 ): 383 self.auth = auth 384 """Authentication configuration""" 385 self.config = request_config or {"max_retries": 3, "timeout": 30} 386 """Request configuration""" 387 self.metadata = metadata or {} 388 """Additional metadata""" 389 self.logger = logger or logging.getLogger(__name__) 390 """Logger instance""" 391 self._session: Optional[aiohttp.ClientSession] = None
403 async def fetch( 404 self, 405 url: str, 406 method: str = "GET", 407 params: Optional[Dict[str, Any]] = None, 408 data: Any = None, 409 json: Any = None, 410 headers: Optional[Dict[str, str]] = None, 411 content_type: Optional[str] = None, 412 timeout: Optional[int] = None, 413 retry_count: int = 0 414 ) -> FetchResponse: 415 """Make an HTTP request with automatic retries and error handling. 416 417 For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``), 418 a ``Bearer`` token is auto-injected from ``auth.credentials.access_token`` 419 unless an ``Authorization`` header is explicitly provided. 420 421 Retries up to ``max_retries`` (default 3) on transient network errors 422 with exponential back-off. HTTP 429 responses raise ``RateLimitError`` 423 immediately (no automatic retry). 424 425 Args: 426 url: The URL to request. 427 method: HTTP method (``"GET"``, ``"POST"``, ``"PUT"``, etc.). 428 params: Query parameters appended to the URL. Nested dicts/lists 429 are JSON-serialized automatically. 430 data: Raw request body. Encoding depends on ``content_type``. 431 json: JSON-serializable payload. Sets ``content_type`` to 432 ``application/json`` automatically. 433 headers: Additional HTTP headers. Merged *after* any auto-injected 434 auth header, so an explicit ``Authorization`` takes precedence. 435 content_type: ``Content-Type`` header value. 436 timeout: Per-request timeout in seconds (overrides ``request_config``). 437 retry_count: Internal — current retry attempt number. 438 439 Returns: 440 A ``FetchResponse`` containing the HTTP status code, response 441 headers, and parsed body data. 442 443 Raises: 444 RateLimitError: On HTTP 429 with the ``Retry-After`` value. 445 HTTPError: On any other non-2xx status. 446 """ 447 if not self._session: 448 self._session = aiohttp.ClientSession() 449 450 # Prepare request 451 if json is not None: 452 data = json 453 content_type = "application/json" 454 455 final_headers = {} 456 457 if self.auth and "Authorization" not in (headers or {}): 458 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) 459 credentials = self.auth.get("credentials", {}) 460 461 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: 462 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" 463 464 if content_type: 465 final_headers["Content-Type"] = content_type 466 if headers: 467 final_headers.update(headers) 468 469 if params: 470 # Handle nested dictionary parameters 471 flat_params = {} 472 for key, value in params.items(): 473 if isinstance(value, (dict, list)): 474 flat_params[key] = jsonX.dumps(value) 475 elif value is not None: 476 flat_params[key] = str(value) 477 query_string = urlencode(flat_params) 478 url = f"{url}{'&' if '?' in url else '?'}{query_string}" 479 480 # Prepare body 481 if data is not None: 482 if content_type == "application/json": 483 data = jsonX.dumps(data) 484 elif content_type == "application/x-www-form-urlencoded": 485 data = urlencode(data) if isinstance(data, dict) else data 486 487 # Store the original timeout numeric value 488 original_timeout = timeout or self.config["timeout"] 489 490 # Convert the numeric timeout to a ClientTimeout instance for this request 491 client_timeout = aiohttp.ClientTimeout(total=original_timeout) 492 493 try: 494 async with self._session.request( 495 method=method, 496 url=url, 497 data=data, 498 headers=final_headers, 499 timeout=client_timeout, 500 ssl=True 501 ) as response: 502 content_type = response.headers.get("Content-Type", "") 503 504 if response.status == 429: # Rate limit 505 retry_after = int(response.headers.get("Retry-After", 60)) 506 raise RateLimitError( 507 retry_after, 508 response.status, 509 "Rate limit exceeded", 510 await response.text() 511 ) 512 513 try: 514 if "application/json" in content_type: 515 result = await response.json() 516 else: 517 result = await response.text() 518 if not result and response.status in {200, 201, 204}: 519 result = None 520 except Exception as e: 521 self.logger.error(f"Error parsing response: {e}") 522 result = await response.text() 523 524 response_headers = dict(response.headers) 525 526 if not response.ok: 527 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") 528 raise HTTPError(response.status, str(result), result) 529 530 return FetchResponse( 531 status=response.status, 532 headers=response_headers, 533 data=result, 534 ) 535 536 except RateLimitError: 537 raise 538 except (aiohttp.ClientError, asyncio.TimeoutError) as e: 539 # Don't want to send this to Raygun here because this will be retried. 540 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") 541 if retry_count < self.config["max_retries"]: 542 await asyncio.sleep(2 ** retry_count) # Exponential backoff 543 print("Retrying request...") 544 # Use original_timeout (numeric) for recursive calls 545 return await self.fetch( 546 url, method, params, data, json, 547 headers, content_type, original_timeout, retry_count + 1 548 ) 549 else: 550 print("Max retries reached. Raising error.") 551 raise 552 except Exception as e: 553 self.logger.error(f"Unexpected error during {method} {url}: {e}") 554 print(f"Unexpected error encountered: {e}") 555 raise
Make an HTTP request with automatic retries and error handling.
For platform OAuth integrations (auth_type == "PlatformOauth2"),
a Bearer token is auto-injected from auth.credentials.access_token
unless an Authorization header is explicitly provided.
Retries up to max_retries (default 3) on transient network errors
with exponential back-off. HTTP 429 responses raise RateLimitError
immediately (no automatic retry).
Args:
url: The URL to request.
method: HTTP method ("GET", "POST", "PUT", etc.).
params: Query parameters appended to the URL. Nested dicts/lists
are JSON-serialized automatically.
data: Raw request body. Encoding depends on content_type.
json: JSON-serializable payload. Sets content_type to
application/json automatically.
headers: Additional HTTP headers. Merged after any auto-injected
auth header, so an explicit Authorization takes precedence.
content_type: Content-Type header value.
timeout: Per-request timeout in seconds (overrides request_config).
retry_count: Internal — current retry attempt number.
Returns:
A FetchResponse containing the HTTP status code, response
headers, and parsed body data.
Raises:
RateLimitError: On HTTP 429 with the Retry-After value.
HTTPError: On any other non-2xx status.
558class Integration: 559 """Base integration class with handler registration and execution. 560 561 This class manages the integration configuration, handler registration, 562 and provides methods to execute actions and triggers. 563 564 Args: 565 config: Integration configuration 566 567 Attributes: 568 config: Integration configuration 569 """ 570 571 def __init__(self, config: IntegrationConfig): 572 self.config = config 573 """Integration configuration""" 574 self._action_handlers: Dict[str, Type[ActionHandler]] = {} 575 """Action handlers""" 576 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} 577 """Polling handlers""" 578 self._connected_account_handler: Optional[Type[ConnectedAccountHandler]] = None 579 """Connected account handler""" 580 581 @classmethod 582 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': 583 """Load an integration from its ``config.json``. 584 585 Args: 586 config_path: Explicit path to ``config.json``. When omitted the 587 SDK resolves the path relative to its own package location, 588 which works when the SDK is vendored via 589 ``pip install --target dependencies``. Multi-file integrations 590 that use ``actions/`` sub-packages should pass an explicit path 591 (e.g. ``Integration.load("config.json")``). 592 593 Returns: 594 A fully initialised ``Integration`` ready for handler registration. 595 596 Raises: 597 ConfigurationError: If the file is missing or contains invalid JSON. 598 """ 599 if config_path is None: 600 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') 601 602 config_path = Path(config_path) 603 604 if not config_path.exists(): 605 raise ConfigurationError(f"Configuration file not found: {config_path}") 606 607 try: 608 with open(config_path, 'r') as f: 609 config_data = json.load(f) 610 except json.JSONDecodeError as e: 611 raise ConfigurationError(f"Invalid JSON configuration: {e}") 612 613 # Parse configuration sections 614 actions = cls._parse_actions(config_data.get("actions", {})) 615 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) 616 617 config = IntegrationConfig( 618 name=config_data["name"], 619 version=config_data["version"], 620 description=config_data["description"], 621 auth=config_data.get("auth", {}), 622 actions=actions, 623 polling_triggers=polling_triggers 624 ) 625 626 return cls(config) 627 628 @staticmethod 629 def _parse_interval(interval_str: str) -> timedelta: 630 """Parse interval string into timedelta""" 631 unit = interval_str[-1].lower() 632 value = int(interval_str[:-1]) 633 634 if unit == 's': 635 return timedelta(seconds=value) 636 elif unit == 'm': 637 return timedelta(minutes=value) 638 elif unit == 'h': 639 return timedelta(hours=value) 640 elif unit == 'd': 641 return timedelta(days=value) 642 else: 643 raise ConfigurationError(f"Invalid interval format: {interval_str}") 644 645 @classmethod 646 def _parse_actions(cls, actions_config: Dict[str, Any]) -> Dict[str, Action]: 647 """Parse action configurations""" 648 actions = {} 649 for name, data in actions_config.items(): 650 actions[name] = Action( 651 name=name, 652 description=data["description"], 653 input_schema=data["input_schema"], 654 output_schema=data["output_schema"] 655 ) 656 657 return actions 658 659 @classmethod 660 def _parse_polling_triggers(cls, triggers_config: Dict[str, Any]) -> Dict[str, PollingTrigger]: 661 """Parse polling trigger configurations""" 662 triggers = {} 663 for name, data in triggers_config.items(): 664 interval = cls._parse_interval(data["polling_interval"]) 665 666 triggers[name] = PollingTrigger( 667 name=name, 668 description=data["description"], 669 polling_interval=interval, 670 input_schema=data["input_schema"], 671 output_schema=data["output_schema"] 672 ) 673 674 return triggers 675 676 def action(self, name: str): 677 """Decorator to register an action handler. 678 679 Args: 680 name: Name of the action to register 681 682 Returns: 683 Decorator function 684 685 Raises: 686 ConfigurationError: If action is not defined in config 687 688 Example: 689 ```python 690 @integration.action("my_action") 691 class MyActionHandler(ActionHandler): 692 async def execute(self, inputs, context): 693 # Implementation 694 return result 695 ``` 696 """ 697 def decorator(handler_class: Type[ActionHandler]): 698 if name not in self.config.actions: 699 raise ConfigurationError(f"Action '{name}' not defined in config") 700 self._action_handlers[name] = handler_class 701 return handler_class 702 return decorator 703 704 def polling_trigger(self, name: str): 705 """Decorator to register a polling trigger handler 706 707 Args: 708 name: Name of the polling trigger to register 709 710 Returns: 711 Decorator function 712 713 Raises: 714 ConfigurationError: If polling trigger is not defined in config 715 716 Example: 717 ```python 718 @integration.polling_trigger("my_polling_trigger") 719 class MyPollingTriggerHandler(PollingTriggerHandler): 720 async def poll(self, inputs, last_poll_ts, context): 721 # Implementation 722 return result 723 ``` 724 """ 725 def decorator(handler_class: Type[PollingTriggerHandler]): 726 if name not in self.config.polling_triggers: 727 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") 728 self._polling_handlers[name] = handler_class 729 return handler_class 730 return decorator 731 732 def connected_account(self): 733 """Decorator to register a connected account handler 734 735 Returns: 736 Decorator function 737 738 Example: 739 ```python 740 @integration.connected_account() 741 class MyConnectedAccountHandler(ConnectedAccountHandler): 742 async def get_account_info(self, context): 743 # Implementation 744 return {"email": "user@example.com", "name": "John Doe"} 745 ``` 746 """ 747 def decorator(handler_class: Type[ConnectedAccountHandler]): 748 self._connected_account_handler = handler_class 749 return handler_class 750 return decorator 751 752 def _validate_auth(self, context: ExecutionContext) -> None: 753 """Validate the auth envelope's credentials against ``auth.fields``. 754 755 The platform always passes ``context.auth`` as the 756 wrapped envelope ``{"auth_type": ..., "credentials": {...}}``. The 757 ``auth.fields`` schema in ``config.json`` describes only the inner 758 ``credentials`` object, so validation runs against 759 ``context.auth["credentials"]`` — not the whole envelope. 760 761 Integrations with no auth or no ``fields`` key skip validation entirely. 762 763 Raises: 764 ValidationError: (source ``"auth"``) if ``context.auth`` is not a 765 wrapped envelope (a dict with a non-empty string ``auth_type`` 766 and a dict ``credentials``), or if the credentials fail the 767 schema. 768 """ 769 if "fields" not in self.config.auth: 770 return 771 772 auth = context.auth 773 has_valid_credentials = isinstance(auth, dict) and isinstance(auth.get("credentials"), dict) 774 has_valid_auth_type = ( 775 isinstance(auth, dict) 776 and isinstance(auth.get("auth_type"), str) 777 and auth.get("auth_type") != "" 778 ) 779 if not has_valid_credentials or not has_valid_auth_type: 780 raise ValidationError( 781 'context.auth must be the platform auth envelope ' 782 '{"auth_type": ..., "credentials": {...}} with a non-empty ' 783 'auth_type; flat auth is not supported.', 784 source="auth", 785 ) 786 787 valid_auth_types = {member.value for member in AuthType} 788 if auth["auth_type"] not in valid_auth_types: 789 raise ValidationError( 790 f'Unknown auth_type "{auth["auth_type"]}" in context.auth; ' 791 f'expected one of: {", ".join(sorted(valid_auth_types))}.', 792 source="auth", 793 ) 794 795 auth_config = self.config.auth["fields"] 796 validator = Draft7Validator(auth_config) 797 errors = sorted(validator.iter_errors(context.auth["credentials"]), key=lambda e: e.path) 798 if errors: 799 message = "" 800 for error in errors: 801 message += f"{list(error.schema_path)}, {error.message},\n " 802 raise ValidationError(message, auth_config, context.auth["credentials"], source="auth") 803 804 async def execute_action(self, 805 name: str, 806 inputs: Dict[str, Any], 807 context: ExecutionContext) -> IntegrationResult: 808 """Execute a registered action. 809 810 Args: 811 name: Name of the action to execute 812 inputs: Action inputs 813 context: Execution context 814 815 Returns: 816 IntegrationResult with action data (ResultType.ACTION), 817 action error (ResultType.ACTION_ERROR) if the handler returned ActionError, 818 or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. 819 """ 820 try: 821 if name not in self._action_handlers: 822 raise ValidationError(f"Action '{name}' not registered") 823 824 # Validate inputs against action schema 825 action_config = self.config.actions[name] 826 validator = Draft7Validator(action_config.input_schema) 827 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) 828 if errors: 829 message = "" 830 for error in errors: 831 message += f"{list(error.schema_path)}, {error.message},\n " 832 raise ValidationError(message, action_config.input_schema, inputs, source="input") 833 834 self._validate_auth(context) 835 836 # Create handler instance and execute 837 handler = self._action_handlers[name]() 838 result = await handler.execute(inputs, context) 839 840 # Handle ActionError - skip output schema validation 841 if isinstance(result, ActionError): 842 return IntegrationResult( 843 version=__version__, 844 type=ResultType.ACTION_ERROR, 845 result=result 846 ) 847 848 # Validate that result is ActionResult 849 if not isinstance(result, ActionResult): 850 raise ValidationError( 851 f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", 852 source="output" 853 ) 854 855 # Validate output schema against the data inside ActionResult 856 validator = Draft7Validator(action_config.output_schema) 857 errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) 858 if errors: 859 message = "" 860 for error in errors: 861 message += f"{list(error.schema_path)}, {error.message},\n " 862 raise ValidationError(message, action_config.output_schema, result.data, source="output") 863 864 # Return IntegrationResult with ActionResult directly 865 return IntegrationResult( 866 version=__version__, 867 type=ResultType.ACTION, 868 result=result 869 ) 870 except ValidationError as e: 871 return IntegrationResult( 872 version=__version__, 873 type=ResultType.VALIDATION_ERROR, 874 result={ 875 'message': str(e), 876 'property': None, 877 'value': None, 878 'source': getattr(e, 'source', 'legacy') 879 } 880 ) 881 882 async def execute_polling_trigger(self, 883 name: str, 884 inputs: Dict[str, Any], 885 last_poll_ts: Optional[str], 886 context: ExecutionContext) -> List[Dict[str, Any]]: 887 """Execute a registered polling trigger 888 889 Args: 890 name: Name of the polling trigger to execute 891 inputs: Trigger inputs 892 last_poll_ts: Last poll timestamp 893 context: Execution context 894 895 Returns: 896 List of records 897 898 Raises: 899 ValidationError: If inputs or outputs don't match schema 900 """ 901 if name not in self._polling_handlers: 902 raise ValidationError(f"Polling trigger '{name}' not registered") 903 904 # Validate trigger configuration 905 trigger_config = self.config.polling_triggers[name] 906 try: 907 validate(inputs, trigger_config.input_schema) 908 except Exception as e: 909 raise ValidationError(e.message, e.schema, e.instance) 910 911 self._validate_auth(context) 912 913 # Create handler instance and execute 914 handler = self._polling_handlers[name]() 915 records = await handler.poll(inputs, last_poll_ts, context) 916 # Validate each record 917 for record in records: 918 if "id" not in record: 919 raise ValidationError( 920 f"Polling trigger '{name}' returned record without required 'id' field") 921 if "data" not in record: 922 raise ValidationError( 923 f"Polling trigger '{name}' returned record without required 'data' field") 924 925 # Validate record data against output schema 926 try: 927 validate(record["data"], trigger_config.output_schema) 928 except Exception as e: 929 raise ValidationError(e.message, e.schema, e.instance) 930 931 return records 932 933 async def get_connected_account(self, context: ExecutionContext) -> IntegrationResult: 934 """Get connected account information 935 936 Args: 937 context: Execution context 938 939 Returns: 940 IntegrationResult containing connected account data 941 942 Raises: 943 ValidationError: If no connected account handler is registered or auth is invalid 944 """ 945 if not self._connected_account_handler: 946 raise ValidationError("No connected account handler registered") 947 948 self._validate_auth(context) 949 950 handler = self._connected_account_handler() 951 account_info = await handler.get_account_info(context) 952 953 if not isinstance(account_info, ConnectedAccountInfo): 954 raise ValidationError( 955 f"Connected account handler must return ConnectedAccountInfo, got {type(account_info).__name__}" 956 ) 957 958 # Return IntegrationResult with ConnectedAccountInfo object directly 959 return IntegrationResult( 960 version=__version__, 961 type=ResultType.CONNECTED_ACCOUNT, 962 result=account_info 963 )
Base integration class with handler registration and execution.
This class manages the integration configuration, handler registration, and provides methods to execute actions and triggers.
Args: config: Integration configuration
Attributes: config: Integration configuration
571 def __init__(self, config: IntegrationConfig): 572 self.config = config 573 """Integration configuration""" 574 self._action_handlers: Dict[str, Type[ActionHandler]] = {} 575 """Action handlers""" 576 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} 577 """Polling handlers""" 578 self._connected_account_handler: Optional[Type[ConnectedAccountHandler]] = None 579 """Connected account handler"""
581 @classmethod 582 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': 583 """Load an integration from its ``config.json``. 584 585 Args: 586 config_path: Explicit path to ``config.json``. When omitted the 587 SDK resolves the path relative to its own package location, 588 which works when the SDK is vendored via 589 ``pip install --target dependencies``. Multi-file integrations 590 that use ``actions/`` sub-packages should pass an explicit path 591 (e.g. ``Integration.load("config.json")``). 592 593 Returns: 594 A fully initialised ``Integration`` ready for handler registration. 595 596 Raises: 597 ConfigurationError: If the file is missing or contains invalid JSON. 598 """ 599 if config_path is None: 600 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') 601 602 config_path = Path(config_path) 603 604 if not config_path.exists(): 605 raise ConfigurationError(f"Configuration file not found: {config_path}") 606 607 try: 608 with open(config_path, 'r') as f: 609 config_data = json.load(f) 610 except json.JSONDecodeError as e: 611 raise ConfigurationError(f"Invalid JSON configuration: {e}") 612 613 # Parse configuration sections 614 actions = cls._parse_actions(config_data.get("actions", {})) 615 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) 616 617 config = IntegrationConfig( 618 name=config_data["name"], 619 version=config_data["version"], 620 description=config_data["description"], 621 auth=config_data.get("auth", {}), 622 actions=actions, 623 polling_triggers=polling_triggers 624 ) 625 626 return cls(config)
Load an integration from its config.json.
Args:
config_path: Explicit path to config.json. When omitted the
SDK resolves the path relative to its own package location,
which works when the SDK is vendored via
pip install --target dependencies. Multi-file integrations
that use actions/ sub-packages should pass an explicit path
(e.g. Integration.load("config.json")).
Returns:
A fully initialised Integration ready for handler registration.
Raises: ConfigurationError: If the file is missing or contains invalid JSON.
676 def action(self, name: str): 677 """Decorator to register an action handler. 678 679 Args: 680 name: Name of the action to register 681 682 Returns: 683 Decorator function 684 685 Raises: 686 ConfigurationError: If action is not defined in config 687 688 Example: 689 ```python 690 @integration.action("my_action") 691 class MyActionHandler(ActionHandler): 692 async def execute(self, inputs, context): 693 # Implementation 694 return result 695 ``` 696 """ 697 def decorator(handler_class: Type[ActionHandler]): 698 if name not in self.config.actions: 699 raise ConfigurationError(f"Action '{name}' not defined in config") 700 self._action_handlers[name] = handler_class 701 return handler_class 702 return decorator
Decorator to register an action handler.
Args: name: Name of the action to register
Returns: Decorator function
Raises: ConfigurationError: If action is not defined in config
Example:
@integration.action("my_action")
class MyActionHandler(ActionHandler):
async def execute(self, inputs, context):
# Implementation
return result
704 def polling_trigger(self, name: str): 705 """Decorator to register a polling trigger handler 706 707 Args: 708 name: Name of the polling trigger to register 709 710 Returns: 711 Decorator function 712 713 Raises: 714 ConfigurationError: If polling trigger is not defined in config 715 716 Example: 717 ```python 718 @integration.polling_trigger("my_polling_trigger") 719 class MyPollingTriggerHandler(PollingTriggerHandler): 720 async def poll(self, inputs, last_poll_ts, context): 721 # Implementation 722 return result 723 ``` 724 """ 725 def decorator(handler_class: Type[PollingTriggerHandler]): 726 if name not in self.config.polling_triggers: 727 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") 728 self._polling_handlers[name] = handler_class 729 return handler_class 730 return decorator
Decorator to register a polling trigger handler
Args: name: Name of the polling trigger to register
Returns: Decorator function
Raises: ConfigurationError: If polling trigger is not defined in config
Example:
@integration.polling_trigger("my_polling_trigger")
class MyPollingTriggerHandler(PollingTriggerHandler):
async def poll(self, inputs, last_poll_ts, context):
# Implementation
return result
732 def connected_account(self): 733 """Decorator to register a connected account handler 734 735 Returns: 736 Decorator function 737 738 Example: 739 ```python 740 @integration.connected_account() 741 class MyConnectedAccountHandler(ConnectedAccountHandler): 742 async def get_account_info(self, context): 743 # Implementation 744 return {"email": "user@example.com", "name": "John Doe"} 745 ``` 746 """ 747 def decorator(handler_class: Type[ConnectedAccountHandler]): 748 self._connected_account_handler = handler_class 749 return handler_class 750 return decorator
Decorator to register a connected account handler
Returns: Decorator function
Example:
@integration.connected_account()
class MyConnectedAccountHandler(ConnectedAccountHandler):
async def get_account_info(self, context):
# Implementation
return {"email": "user@example.com", "name": "John Doe"}
804 async def execute_action(self, 805 name: str, 806 inputs: Dict[str, Any], 807 context: ExecutionContext) -> IntegrationResult: 808 """Execute a registered action. 809 810 Args: 811 name: Name of the action to execute 812 inputs: Action inputs 813 context: Execution context 814 815 Returns: 816 IntegrationResult with action data (ResultType.ACTION), 817 action error (ResultType.ACTION_ERROR) if the handler returned ActionError, 818 or validation error (ResultType.VALIDATION_ERROR) if schema validation fails. 819 """ 820 try: 821 if name not in self._action_handlers: 822 raise ValidationError(f"Action '{name}' not registered") 823 824 # Validate inputs against action schema 825 action_config = self.config.actions[name] 826 validator = Draft7Validator(action_config.input_schema) 827 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) 828 if errors: 829 message = "" 830 for error in errors: 831 message += f"{list(error.schema_path)}, {error.message},\n " 832 raise ValidationError(message, action_config.input_schema, inputs, source="input") 833 834 self._validate_auth(context) 835 836 # Create handler instance and execute 837 handler = self._action_handlers[name]() 838 result = await handler.execute(inputs, context) 839 840 # Handle ActionError - skip output schema validation 841 if isinstance(result, ActionError): 842 return IntegrationResult( 843 version=__version__, 844 type=ResultType.ACTION_ERROR, 845 result=result 846 ) 847 848 # Validate that result is ActionResult 849 if not isinstance(result, ActionResult): 850 raise ValidationError( 851 f"Action handler '{name}' must return ActionResult or ActionError, got {type(result).__name__}", 852 source="output" 853 ) 854 855 # Validate output schema against the data inside ActionResult 856 validator = Draft7Validator(action_config.output_schema) 857 errors = sorted(validator.iter_errors(result.data), key=lambda e: e.path) 858 if errors: 859 message = "" 860 for error in errors: 861 message += f"{list(error.schema_path)}, {error.message},\n " 862 raise ValidationError(message, action_config.output_schema, result.data, source="output") 863 864 # Return IntegrationResult with ActionResult directly 865 return IntegrationResult( 866 version=__version__, 867 type=ResultType.ACTION, 868 result=result 869 ) 870 except ValidationError as e: 871 return IntegrationResult( 872 version=__version__, 873 type=ResultType.VALIDATION_ERROR, 874 result={ 875 'message': str(e), 876 'property': None, 877 'value': None, 878 'source': getattr(e, 'source', 'legacy') 879 } 880 )
Execute a registered action.
Args: name: Name of the action to execute inputs: Action inputs context: Execution context
Returns: IntegrationResult with action data (ResultType.ACTION), action error (ResultType.ACTION_ERROR) if the handler returned ActionError, or validation error (ResultType.VALIDATION_ERROR) if schema validation fails.
882 async def execute_polling_trigger(self, 883 name: str, 884 inputs: Dict[str, Any], 885 last_poll_ts: Optional[str], 886 context: ExecutionContext) -> List[Dict[str, Any]]: 887 """Execute a registered polling trigger 888 889 Args: 890 name: Name of the polling trigger to execute 891 inputs: Trigger inputs 892 last_poll_ts: Last poll timestamp 893 context: Execution context 894 895 Returns: 896 List of records 897 898 Raises: 899 ValidationError: If inputs or outputs don't match schema 900 """ 901 if name not in self._polling_handlers: 902 raise ValidationError(f"Polling trigger '{name}' not registered") 903 904 # Validate trigger configuration 905 trigger_config = self.config.polling_triggers[name] 906 try: 907 validate(inputs, trigger_config.input_schema) 908 except Exception as e: 909 raise ValidationError(e.message, e.schema, e.instance) 910 911 self._validate_auth(context) 912 913 # Create handler instance and execute 914 handler = self._polling_handlers[name]() 915 records = await handler.poll(inputs, last_poll_ts, context) 916 # Validate each record 917 for record in records: 918 if "id" not in record: 919 raise ValidationError( 920 f"Polling trigger '{name}' returned record without required 'id' field") 921 if "data" not in record: 922 raise ValidationError( 923 f"Polling trigger '{name}' returned record without required 'data' field") 924 925 # Validate record data against output schema 926 try: 927 validate(record["data"], trigger_config.output_schema) 928 except Exception as e: 929 raise ValidationError(e.message, e.schema, e.instance) 930 931 return records
Execute a registered polling trigger
Args: name: Name of the polling trigger to execute inputs: Trigger inputs last_poll_ts: Last poll timestamp context: Execution context
Returns: List of records
Raises: ValidationError: If inputs or outputs don't match schema
933 async def get_connected_account(self, context: ExecutionContext) -> IntegrationResult: 934 """Get connected account information 935 936 Args: 937 context: Execution context 938 939 Returns: 940 IntegrationResult containing connected account data 941 942 Raises: 943 ValidationError: If no connected account handler is registered or auth is invalid 944 """ 945 if not self._connected_account_handler: 946 raise ValidationError("No connected account handler registered") 947 948 self._validate_auth(context) 949 950 handler = self._connected_account_handler() 951 account_info = await handler.get_account_info(context) 952 953 if not isinstance(account_info, ConnectedAccountInfo): 954 raise ValidationError( 955 f"Connected account handler must return ConnectedAccountInfo, got {type(account_info).__name__}" 956 ) 957 958 # Return IntegrationResult with ConnectedAccountInfo object directly 959 return IntegrationResult( 960 version=__version__, 961 type=ResultType.CONNECTED_ACCOUNT, 962 result=account_info 963 )
Get connected account information
Args: context: Execution context
Returns: IntegrationResult containing connected account data
Raises: ValidationError: If no connected account handler is registered or auth is invalid