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