autohive_integrations_sdk.integration
1# Standard Library Imports 2from abc import ABC, abstractmethod 3import asyncio 4from dataclasses import dataclass, field 5from datetime import timedelta 6from enum import Enum 7import json 8import json as jsonX # Keep alias to avoid conflict with 'json' parameter in fetch 9import logging 10import os 11from pathlib import Path 12from typing import Dict, Any, List, Optional, Union, Type, TypeVar, Generic, ClassVar 13from urllib.parse import urlencode 14 15# Third-Party Imports 16import aiohttp 17from jsonschema import validate, Draft7Validator 18 19# ---- Type Definitions ---- 20T = TypeVar('T') 21 22# ---- Auth Types ---- 23class AuthType(Enum): 24 """The type of authentication to use""" 25 PlatformOauth2 = "PlatformOauth2" 26 PlatformTeams = "PlatformTeams" 27 ApiKey = "ApiKey" 28 Basic = "Basic" 29 Custom = "Custom" 30 31# ---- Exceptions ---- 32class ValidationError(Exception): 33 """Raised when inputs/outputs validation fails""" 34 def __init__(self, message: str, schema: str = None, inputs: str = None): 35 self.schema = schema 36 """The schema that failed validation""" 37 self.inputs = inputs 38 """The data that failed validation""" 39 self.message = message 40 """The error message""" 41 super().__init__(message) 42 43class ConfigurationError(Exception): 44 """Raised when integration configuration is invalid""" 45 pass 46 47class HTTPError(Exception): 48 """Custom HTTP error with detailed information""" 49 def __init__(self, status: int, message: str, response_data: Any = None): 50 self.status = status 51 """Status code""" 52 self.message = message 53 """Error message""" 54 self.response_data = response_data 55 """Response data""" 56 super().__init__(f"HTTP {status}: {message}") 57 58class RateLimitError(HTTPError): 59 """Raised when rate limited by the API""" 60 def __init__(self, retry_after: int, *args, **kwargs): 61 self.retry_after = retry_after 62 """Retry after""" 63 super().__init__(*args, **kwargs) 64 65# ---- Configuration Classes ---- 66@dataclass 67class Parameter: 68 """Definition of a parameter""" 69 name: str 70 type: str 71 description: str 72 enum: Optional[List[str]] = None 73 required: bool = True 74 default: Any = None 75 76@dataclass 77class SchemaDefinition: 78 """Base class for components that have input/output schemas""" 79 name: str 80 description: str 81 input_schema: List[Parameter] 82 output_schema: Optional[Dict[str, Any]] = None 83 84@dataclass 85class Action(SchemaDefinition): 86 """Empty dataclass that inherits from SchemaDefinition""" 87 pass 88 89@dataclass 90class PollingTrigger(SchemaDefinition): 91 """Definition of a polling trigger""" 92 polling_interval: timedelta = field(default_factory=timedelta) 93 94@dataclass 95class IntegrationConfig: 96 """Configuration for an integration""" 97 name: str 98 version: str 99 description: str 100 auth: Dict[str, Any] 101 actions: Dict[str, Action] 102 polling_triggers: Dict[str, PollingTrigger] 103 104# ---- Base Handler Classes ---- 105class ActionHandler(ABC): 106 """Base class for action handlers""" 107 @abstractmethod 108 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: 109 """Execute the action""" 110 pass 111 112class PollingTriggerHandler(ABC): 113 """Base class for polling trigger handlers""" 114 @abstractmethod 115 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: 116 """Execute the polling trigger""" 117 pass 118 119# ---- Core SDK Classes ---- 120class ExecutionContext: 121 """Context provided to integration handlers for making authenticated HTTP requests. 122 123 This class manages authentication, HTTP sessions, and provides a convenient interface 124 for making API requests with automatic retries, error handling, and logging.""" 125 def __init__( 126 self, 127 auth: Dict[str, Any] = {}, 128 request_config: Optional[Dict[str, Any]] = None, 129 metadata: Optional[Dict[str, Any]] = None, 130 logger: Optional[logging.Logger] = None 131 ): 132 self.auth = auth 133 """Authentication configuration""" 134 self.config = request_config or {"max_retries": 3, "timeout": 30} 135 """Request configuration""" 136 self.metadata = metadata or {} 137 """Additional metadata""" 138 self.logger = logger or logging.getLogger(__name__) 139 """Logger instance""" 140 self._session: Optional[aiohttp.ClientSession] = None 141 142 async def __aenter__(self): 143 if not self._session: 144 self._session = aiohttp.ClientSession() 145 return self 146 147 async def __aexit__(self, exc_type, exc_val, exc_tb): 148 if self._session: 149 await self._session.close() 150 self._session = None 151 152 async def fetch( 153 self, 154 url: str, 155 method: str = "GET", 156 params: Optional[Dict[str, Any]] = None, 157 data: Any = None, 158 json: Any = None, 159 headers: Optional[Dict[str, str]] = None, 160 content_type: Optional[str] = None, 161 timeout: Optional[int] = None, 162 retry_count: int = 0 163 ) -> Any: 164 """Make an authenticated HTTP request. 165 166 This method handles authentication, retries, error handling, and response parsing. 167 168 Args: 169 url: The URL to request 170 method: HTTP method to use. Defaults to "GET". 171 params: Query parameters 172 data: Request body data 173 json: JSON data to send (will set content_type to application/json) 174 headers: Additional HTTP headers 175 content_type: Content-Type header 176 timeout: Request timeout in seconds 177 retry_count: Current retry attempt (used internally) 178 179 Returns: 180 Response data, parsed as JSON if possible 181 182 Raises: 183 HTTPError: For HTTP error responses 184 RateLimitError: When rate limited by the API 185 Exception: For other request errors 186 """ 187 if not self._session: 188 self._session = aiohttp.ClientSession() 189 190 # Prepare request 191 if json is not None: 192 data = json 193 content_type = "application/json" 194 195 final_headers = {} 196 197 if self.auth and "Authorization" not in (headers or {}): 198 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) 199 credentials = self.auth.get("credentials", {}) 200 201 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: 202 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" 203 204 if content_type: 205 final_headers["Content-Type"] = content_type 206 if headers: 207 final_headers.update(headers) 208 209 if params: 210 # Handle nested dictionary parameters 211 flat_params = {} 212 for key, value in params.items(): 213 if isinstance(value, (dict, list)): 214 flat_params[key] = jsonX.dumps(value) 215 elif value is not None: 216 flat_params[key] = str(value) 217 query_string = urlencode(flat_params) 218 url = f"{url}{'&' if '?' in url else '?'}{query_string}" 219 220 # Prepare body 221 if data is not None: 222 if content_type == "application/json": 223 data = jsonX.dumps(data) 224 elif content_type == "application/x-www-form-urlencoded": 225 data = urlencode(data) if isinstance(data, dict) else data 226 227 # Store the original timeout numeric value 228 original_timeout = timeout or self.config["timeout"] 229 230 # Convert the numeric timeout to a ClientTimeout instance for this request 231 client_timeout = aiohttp.ClientTimeout(total=original_timeout) 232 233 try: 234 async with self._session.request( 235 method=method, 236 url=url, 237 data=data, 238 headers=final_headers, 239 timeout=client_timeout, 240 ssl=True 241 ) as response: 242 content_type = response.headers.get("Content-Type", "") 243 244 if response.status == 429: # Rate limit 245 retry_after = int(response.headers.get("Retry-After", 60)) 246 raise RateLimitError( 247 retry_after, 248 response.status, 249 "Rate limit exceeded", 250 await response.text() 251 ) 252 253 try: 254 if "application/json" in content_type: 255 result = await response.json() 256 else: 257 result = await response.text() 258 if not result and response.status in {200, 201, 204}: 259 return None 260 except Exception as e: 261 self.logger.error(f"Error parsing response: {e}") 262 result = await response.text() 263 264 if not response.ok: 265 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") 266 raise HTTPError(response.status, str(result), result) 267 268 return result 269 270 except RateLimitError: 271 raise 272 except (aiohttp.ClientError, asyncio.TimeoutError) as e: 273 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") 274 if retry_count < self.config["max_retries"]: 275 await asyncio.sleep(2 ** retry_count) # Exponential backoff 276 print("Retrying request...") 277 # Use original_timeout (numeric) for recursive calls 278 return await self.fetch( 279 url, method, params, data, json, 280 headers, content_type, original_timeout, retry_count + 1 281 ) 282 else: 283 print("Max retries reached. Raising error.") 284 raise 285 except Exception as e: 286 self.logger.error(f"Unexpected error during {method} {url}: {e}") 287 print(f"Unexpected error encountered: {e}") 288 raise 289 290 291class Integration: 292 """Base integration class with handler registration and execution. 293 294 This class manages the integration configuration, handler registration, 295 and provides methods to execute actions and triggers. 296 297 Args: 298 config: Integration configuration 299 300 Attributes: 301 config: Integration configuration 302 """ 303 304 def __init__(self, config: IntegrationConfig): 305 self.config = config 306 """Integration configuration""" 307 self._action_handlers: Dict[str, Type[ActionHandler]] = {} 308 """Action handlers""" 309 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} 310 """Polling handlers""" 311 312 @classmethod 313 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': 314 """Load integration from JSON configuration. 315 316 Args: 317 config_path: Path to the configuration file. Defaults to 'config.json' in the project root. 318 319 Returns: 320 Initialized integration instance 321 322 Raises: 323 ConfigurationError: If configuration is invalid or missing 324 """ 325 if config_path is None: 326 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') 327 328 config_path = Path(config_path) 329 330 if not config_path.exists(): 331 raise ConfigurationError(f"Configuration file not found: {config_path}") 332 333 try: 334 with open(config_path, 'r') as f: 335 config_data = json.load(f) 336 except json.JSONDecodeError as e: 337 raise ConfigurationError(f"Invalid JSON configuration: {e}") 338 339 # Parse configuration sections 340 actions = cls._parse_actions(config_data.get("actions", {})) 341 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) 342 343 config = IntegrationConfig( 344 name=config_data["name"], 345 version=config_data["version"], 346 description=config_data["description"], 347 auth=config_data.get("auth", {}), 348 actions=actions, 349 polling_triggers=polling_triggers 350 ) 351 352 return cls(config) 353 354 @staticmethod 355 def _parse_interval(interval_str: str) -> timedelta: 356 """Parse interval string into timedelta""" 357 unit = interval_str[-1].lower() 358 value = int(interval_str[:-1]) 359 360 if unit == 's': 361 return timedelta(seconds=value) 362 elif unit == 'm': 363 return timedelta(minutes=value) 364 elif unit == 'h': 365 return timedelta(hours=value) 366 elif unit == 'd': 367 return timedelta(days=value) 368 else: 369 raise ConfigurationError(f"Invalid interval format: {interval_str}") 370 371 @classmethod 372 def _parse_actions(cls, actions_config: Dict[str, Any]) -> Dict[str, Action]: 373 """Parse action configurations""" 374 actions = {} 375 for name, data in actions_config.items(): 376 actions[name] = Action( 377 name=name, 378 description=data["description"], 379 input_schema=data["input_schema"], 380 output_schema=data["output_schema"] 381 ) 382 383 return actions 384 385 @classmethod 386 def _parse_polling_triggers(cls, triggers_config: Dict[str, Any]) -> Dict[str, PollingTrigger]: 387 """Parse polling trigger configurations""" 388 triggers = {} 389 for name, data in triggers_config.items(): 390 interval = cls._parse_interval(data["polling_interval"]) 391 392 triggers[name] = PollingTrigger( 393 name=name, 394 description=data["description"], 395 polling_interval=interval, 396 input_schema=data["input_schema"], 397 output_schema=data["output_schema"] 398 ) 399 400 return triggers 401 402 def action(self, name: str): 403 """Decorator to register an action handler. 404 405 Args: 406 name: Name of the action to register 407 408 Returns: 409 Decorator function 410 411 Raises: 412 ConfigurationError: If action is not defined in config 413 414 Example: 415 ```python 416 @integration.action("my_action") 417 class MyActionHandler(ActionHandler): 418 async def execute(self, inputs, context): 419 # Implementation 420 return result 421 ``` 422 """ 423 def decorator(handler_class: Type[ActionHandler]): 424 if name not in self.config.actions: 425 raise ConfigurationError(f"Action '{name}' not defined in config") 426 self._action_handlers[name] = handler_class 427 return handler_class 428 return decorator 429 430 def polling_trigger(self, name: str): 431 """Decorator to register a polling trigger handler 432 433 Args: 434 name: Name of the polling trigger to register 435 436 Returns: 437 Decorator function 438 439 Raises: 440 ConfigurationError: If polling trigger is not defined in config 441 442 Example: 443 ```python 444 @integration.polling_trigger("my_polling_trigger") 445 class MyPollingTriggerHandler(PollingTriggerHandler): 446 async def poll(self, inputs, last_poll_ts, context): 447 # Implementation 448 return result 449 ``` 450 """ 451 def decorator(handler_class: Type[PollingTriggerHandler]): 452 if name not in self.config.polling_triggers: 453 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") 454 self._polling_handlers[name] = handler_class 455 return handler_class 456 return decorator 457 458 async def execute_action(self, 459 name: str, 460 inputs: Dict[str, Any], 461 context: ExecutionContext) -> Any: 462 """Execute a registered action. 463 464 Args: 465 name: Name of the action to execute 466 inputs: Action inputs 467 context: Execution context 468 469 Returns: 470 Action result 471 472 Raises: 473 ValidationError: If inputs or outputs don't match schema 474 """ 475 if name not in self._action_handlers: 476 raise ValidationError(f"Action '{name}' not registered") 477 478 # Validate inputs against action schema 479 action_config = self.config.actions[name] 480 validator = Draft7Validator(action_config.input_schema) 481 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) 482 if errors: 483 message = "" 484 for error in errors: 485 message += f"{list(error.schema_path)}, {error.message},\n " 486 raise ValidationError(message, action_config.input_schema, inputs) 487 488 if "fields" in self.config.auth: 489 auth_config = self.config.auth["fields"] 490 validator = Draft7Validator(auth_config) 491 errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) 492 if errors: 493 message = "" 494 for error in errors: 495 message += f"{list(error.schema_path)}, {error.message},\n " 496 raise ValidationError(message, auth_config, context.auth) 497 498 # Create handler instance and execute 499 handler = self._action_handlers[name]() 500 result = await handler.execute(inputs, context) 501 502 # Validate output if schema is defined 503 validator = Draft7Validator(action_config.output_schema) 504 errors = sorted(validator.iter_errors(result), key=lambda e: e.path) 505 if errors: 506 message = "" 507 for error in errors: 508 message += f"{list(error.schema_path)}, {error.message},\n " 509 raise ValidationError(message, action_config.output_schema, result) 510 511 return result 512 513 async def execute_polling_trigger(self, 514 name: str, 515 inputs: Dict[str, Any], 516 last_poll_ts: Optional[str], 517 context: ExecutionContext) -> List[Dict[str, Any]]: 518 """Execute a registered polling trigger 519 520 Args: 521 name: Name of the polling trigger to execute 522 inputs: Trigger inputs 523 last_poll_ts: Last poll timestamp 524 context: Execution context 525 526 Returns: 527 List of records 528 529 Raises: 530 ValidationError: If inputs or outputs don't match schema 531 """ 532 if name not in self._polling_handlers: 533 raise ValidationError(f"Polling trigger '{name}' not registered") 534 535 # Validate trigger configuration 536 trigger_config = self.config.polling_triggers[name] 537 try: 538 validate(inputs, trigger_config.input_schema) 539 except Exception as e: 540 raise ValidationError(e.message, e.schema, e.instance) 541 542 try: 543 auth_config = self.config.auth["fields"] 544 validate(context.auth, auth_config) 545 except Exception as e: 546 raise ValidationError(e.message, e.schema, e.instance) 547 548 # Create handler instance and execute 549 handler = self._polling_handlers[name]() 550 records = await handler.poll(inputs, last_poll_ts, context) 551 # Validate each record 552 for record in records: 553 if "id" not in record: 554 raise ValidationError( 555 f"Polling trigger '{name}' returned record without required 'id' field") 556 if "data" not in record: 557 raise ValidationError( 558 f"Polling trigger '{name}' returned record without required 'data' field") 559 560 # Validate record data against output schema 561 try: 562 validate(record["data"], trigger_config.output_schema) 563 except Exception as e: 564 raise ValidationError(e.message, e.schema, e.instance) 565 566 return records
24class AuthType(Enum): 25 """The type of authentication to use""" 26 PlatformOauth2 = "PlatformOauth2" 27 PlatformTeams = "PlatformTeams" 28 ApiKey = "ApiKey" 29 Basic = "Basic" 30 Custom = "Custom"
The type of authentication to use
33class ValidationError(Exception): 34 """Raised when inputs/outputs validation fails""" 35 def __init__(self, message: str, schema: str = None, inputs: str = None): 36 self.schema = schema 37 """The schema that failed validation""" 38 self.inputs = inputs 39 """The data that failed validation""" 40 self.message = message 41 """The error message""" 42 super().__init__(message)
Raised when inputs/outputs validation fails
44class ConfigurationError(Exception): 45 """Raised when integration configuration is invalid""" 46 pass
Raised when integration configuration is invalid
48class HTTPError(Exception): 49 """Custom HTTP error with detailed information""" 50 def __init__(self, status: int, message: str, response_data: Any = None): 51 self.status = status 52 """Status code""" 53 self.message = message 54 """Error message""" 55 self.response_data = response_data 56 """Response data""" 57 super().__init__(f"HTTP {status}: {message}")
Custom HTTP error with detailed information
59class RateLimitError(HTTPError): 60 """Raised when rate limited by the API""" 61 def __init__(self, retry_after: int, *args, **kwargs): 62 self.retry_after = retry_after 63 """Retry after""" 64 super().__init__(*args, **kwargs)
Raised when rate limited by the API
Inherited Members
67@dataclass 68class Parameter: 69 """Definition of a parameter""" 70 name: str 71 type: str 72 description: str 73 enum: Optional[List[str]] = None 74 required: bool = True 75 default: Any = None
Definition of a parameter
77@dataclass 78class SchemaDefinition: 79 """Base class for components that have input/output schemas""" 80 name: str 81 description: str 82 input_schema: List[Parameter] 83 output_schema: Optional[Dict[str, Any]] = None
Base class for components that have input/output schemas
85@dataclass 86class Action(SchemaDefinition): 87 """Empty dataclass that inherits from SchemaDefinition""" 88 pass
Empty dataclass that inherits from SchemaDefinition
Inherited Members
90@dataclass 91class PollingTrigger(SchemaDefinition): 92 """Definition of a polling trigger""" 93 polling_interval: timedelta = field(default_factory=timedelta)
Definition of a polling trigger
Inherited Members
95@dataclass 96class IntegrationConfig: 97 """Configuration for an integration""" 98 name: str 99 version: str 100 description: str 101 auth: Dict[str, Any] 102 actions: Dict[str, Action] 103 polling_triggers: Dict[str, PollingTrigger]
Configuration for an integration
106class ActionHandler(ABC): 107 """Base class for action handlers""" 108 @abstractmethod 109 async def execute(self, inputs: Dict[str, Any], context: 'ExecutionContext') -> Any: 110 """Execute the action""" 111 pass
Base class for action handlers
113class PollingTriggerHandler(ABC): 114 """Base class for polling trigger handlers""" 115 @abstractmethod 116 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: 117 """Execute the polling trigger""" 118 pass
Base class for polling trigger handlers
115 @abstractmethod 116 async def poll(self, inputs: Dict[str, Any], last_poll_ts: Optional[str], context: 'ExecutionContext') -> List[Dict[str, Any]]: 117 """Execute the polling trigger""" 118 pass
Execute the polling trigger
121class ExecutionContext: 122 """Context provided to integration handlers for making authenticated HTTP requests. 123 124 This class manages authentication, HTTP sessions, and provides a convenient interface 125 for making API requests with automatic retries, error handling, and logging.""" 126 def __init__( 127 self, 128 auth: Dict[str, Any] = {}, 129 request_config: Optional[Dict[str, Any]] = None, 130 metadata: Optional[Dict[str, Any]] = None, 131 logger: Optional[logging.Logger] = None 132 ): 133 self.auth = auth 134 """Authentication configuration""" 135 self.config = request_config or {"max_retries": 3, "timeout": 30} 136 """Request configuration""" 137 self.metadata = metadata or {} 138 """Additional metadata""" 139 self.logger = logger or logging.getLogger(__name__) 140 """Logger instance""" 141 self._session: Optional[aiohttp.ClientSession] = None 142 143 async def __aenter__(self): 144 if not self._session: 145 self._session = aiohttp.ClientSession() 146 return self 147 148 async def __aexit__(self, exc_type, exc_val, exc_tb): 149 if self._session: 150 await self._session.close() 151 self._session = None 152 153 async def fetch( 154 self, 155 url: str, 156 method: str = "GET", 157 params: Optional[Dict[str, Any]] = None, 158 data: Any = None, 159 json: Any = None, 160 headers: Optional[Dict[str, str]] = None, 161 content_type: Optional[str] = None, 162 timeout: Optional[int] = None, 163 retry_count: int = 0 164 ) -> Any: 165 """Make an authenticated HTTP request. 166 167 This method handles authentication, retries, error handling, and response parsing. 168 169 Args: 170 url: The URL to request 171 method: HTTP method to use. Defaults to "GET". 172 params: Query parameters 173 data: Request body data 174 json: JSON data to send (will set content_type to application/json) 175 headers: Additional HTTP headers 176 content_type: Content-Type header 177 timeout: Request timeout in seconds 178 retry_count: Current retry attempt (used internally) 179 180 Returns: 181 Response data, parsed as JSON if possible 182 183 Raises: 184 HTTPError: For HTTP error responses 185 RateLimitError: When rate limited by the API 186 Exception: For other request errors 187 """ 188 if not self._session: 189 self._session = aiohttp.ClientSession() 190 191 # Prepare request 192 if json is not None: 193 data = json 194 content_type = "application/json" 195 196 final_headers = {} 197 198 if self.auth and "Authorization" not in (headers or {}): 199 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) 200 credentials = self.auth.get("credentials", {}) 201 202 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: 203 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" 204 205 if content_type: 206 final_headers["Content-Type"] = content_type 207 if headers: 208 final_headers.update(headers) 209 210 if params: 211 # Handle nested dictionary parameters 212 flat_params = {} 213 for key, value in params.items(): 214 if isinstance(value, (dict, list)): 215 flat_params[key] = jsonX.dumps(value) 216 elif value is not None: 217 flat_params[key] = str(value) 218 query_string = urlencode(flat_params) 219 url = f"{url}{'&' if '?' in url else '?'}{query_string}" 220 221 # Prepare body 222 if data is not None: 223 if content_type == "application/json": 224 data = jsonX.dumps(data) 225 elif content_type == "application/x-www-form-urlencoded": 226 data = urlencode(data) if isinstance(data, dict) else data 227 228 # Store the original timeout numeric value 229 original_timeout = timeout or self.config["timeout"] 230 231 # Convert the numeric timeout to a ClientTimeout instance for this request 232 client_timeout = aiohttp.ClientTimeout(total=original_timeout) 233 234 try: 235 async with self._session.request( 236 method=method, 237 url=url, 238 data=data, 239 headers=final_headers, 240 timeout=client_timeout, 241 ssl=True 242 ) as response: 243 content_type = response.headers.get("Content-Type", "") 244 245 if response.status == 429: # Rate limit 246 retry_after = int(response.headers.get("Retry-After", 60)) 247 raise RateLimitError( 248 retry_after, 249 response.status, 250 "Rate limit exceeded", 251 await response.text() 252 ) 253 254 try: 255 if "application/json" in content_type: 256 result = await response.json() 257 else: 258 result = await response.text() 259 if not result and response.status in {200, 201, 204}: 260 return None 261 except Exception as e: 262 self.logger.error(f"Error parsing response: {e}") 263 result = await response.text() 264 265 if not response.ok: 266 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") 267 raise HTTPError(response.status, str(result), result) 268 269 return result 270 271 except RateLimitError: 272 raise 273 except (aiohttp.ClientError, asyncio.TimeoutError) as e: 274 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") 275 if retry_count < self.config["max_retries"]: 276 await asyncio.sleep(2 ** retry_count) # Exponential backoff 277 print("Retrying request...") 278 # Use original_timeout (numeric) for recursive calls 279 return await self.fetch( 280 url, method, params, data, json, 281 headers, content_type, original_timeout, retry_count + 1 282 ) 283 else: 284 print("Max retries reached. Raising error.") 285 raise 286 except Exception as e: 287 self.logger.error(f"Unexpected error during {method} {url}: {e}") 288 print(f"Unexpected error encountered: {e}") 289 raise
Context provided to integration handlers for making authenticated HTTP requests.
This class manages authentication, HTTP sessions, and provides a convenient interface for making API requests with automatic retries, error handling, and logging.
126 def __init__( 127 self, 128 auth: Dict[str, Any] = {}, 129 request_config: Optional[Dict[str, Any]] = None, 130 metadata: Optional[Dict[str, Any]] = None, 131 logger: Optional[logging.Logger] = None 132 ): 133 self.auth = auth 134 """Authentication configuration""" 135 self.config = request_config or {"max_retries": 3, "timeout": 30} 136 """Request configuration""" 137 self.metadata = metadata or {} 138 """Additional metadata""" 139 self.logger = logger or logging.getLogger(__name__) 140 """Logger instance""" 141 self._session: Optional[aiohttp.ClientSession] = None
153 async def fetch( 154 self, 155 url: str, 156 method: str = "GET", 157 params: Optional[Dict[str, Any]] = None, 158 data: Any = None, 159 json: Any = None, 160 headers: Optional[Dict[str, str]] = None, 161 content_type: Optional[str] = None, 162 timeout: Optional[int] = None, 163 retry_count: int = 0 164 ) -> Any: 165 """Make an authenticated HTTP request. 166 167 This method handles authentication, retries, error handling, and response parsing. 168 169 Args: 170 url: The URL to request 171 method: HTTP method to use. Defaults to "GET". 172 params: Query parameters 173 data: Request body data 174 json: JSON data to send (will set content_type to application/json) 175 headers: Additional HTTP headers 176 content_type: Content-Type header 177 timeout: Request timeout in seconds 178 retry_count: Current retry attempt (used internally) 179 180 Returns: 181 Response data, parsed as JSON if possible 182 183 Raises: 184 HTTPError: For HTTP error responses 185 RateLimitError: When rate limited by the API 186 Exception: For other request errors 187 """ 188 if not self._session: 189 self._session = aiohttp.ClientSession() 190 191 # Prepare request 192 if json is not None: 193 data = json 194 content_type = "application/json" 195 196 final_headers = {} 197 198 if self.auth and "Authorization" not in (headers or {}): 199 auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2")) 200 credentials = self.auth.get("credentials", {}) 201 202 if auth_type == AuthType.PlatformOauth2 and "access_token" in credentials: 203 final_headers["Authorization"] = f"Bearer {credentials['access_token']}" 204 205 if content_type: 206 final_headers["Content-Type"] = content_type 207 if headers: 208 final_headers.update(headers) 209 210 if params: 211 # Handle nested dictionary parameters 212 flat_params = {} 213 for key, value in params.items(): 214 if isinstance(value, (dict, list)): 215 flat_params[key] = jsonX.dumps(value) 216 elif value is not None: 217 flat_params[key] = str(value) 218 query_string = urlencode(flat_params) 219 url = f"{url}{'&' if '?' in url else '?'}{query_string}" 220 221 # Prepare body 222 if data is not None: 223 if content_type == "application/json": 224 data = jsonX.dumps(data) 225 elif content_type == "application/x-www-form-urlencoded": 226 data = urlencode(data) if isinstance(data, dict) else data 227 228 # Store the original timeout numeric value 229 original_timeout = timeout or self.config["timeout"] 230 231 # Convert the numeric timeout to a ClientTimeout instance for this request 232 client_timeout = aiohttp.ClientTimeout(total=original_timeout) 233 234 try: 235 async with self._session.request( 236 method=method, 237 url=url, 238 data=data, 239 headers=final_headers, 240 timeout=client_timeout, 241 ssl=True 242 ) as response: 243 content_type = response.headers.get("Content-Type", "") 244 245 if response.status == 429: # Rate limit 246 retry_after = int(response.headers.get("Retry-After", 60)) 247 raise RateLimitError( 248 retry_after, 249 response.status, 250 "Rate limit exceeded", 251 await response.text() 252 ) 253 254 try: 255 if "application/json" in content_type: 256 result = await response.json() 257 else: 258 result = await response.text() 259 if not result and response.status in {200, 201, 204}: 260 return None 261 except Exception as e: 262 self.logger.error(f"Error parsing response: {e}") 263 result = await response.text() 264 265 if not response.ok: 266 print(f"HTTP error encountered. Status: {response.status}. Result: {result}") 267 raise HTTPError(response.status, str(result), result) 268 269 return result 270 271 except RateLimitError: 272 raise 273 except (aiohttp.ClientError, asyncio.TimeoutError) as e: 274 print(f"Error encountered: {e}. Retry count: {retry_count}. Backing off.") 275 if retry_count < self.config["max_retries"]: 276 await asyncio.sleep(2 ** retry_count) # Exponential backoff 277 print("Retrying request...") 278 # Use original_timeout (numeric) for recursive calls 279 return await self.fetch( 280 url, method, params, data, json, 281 headers, content_type, original_timeout, retry_count + 1 282 ) 283 else: 284 print("Max retries reached. Raising error.") 285 raise 286 except Exception as e: 287 self.logger.error(f"Unexpected error during {method} {url}: {e}") 288 print(f"Unexpected error encountered: {e}") 289 raise
Make an authenticated HTTP request.
This method handles authentication, retries, error handling, and response parsing.
Arguments:
- url: The URL to request
- method: HTTP method to use. Defaults to "GET".
- params: Query parameters
- data: Request body data
- json: JSON data to send (will set content_type to application/json)
- headers: Additional HTTP headers
- content_type: Content-Type header
- timeout: Request timeout in seconds
- retry_count: Current retry attempt (used internally)
Returns:
Response data, parsed as JSON if possible
Raises:
- HTTPError: For HTTP error responses
- RateLimitError: When rate limited by the API
- Exception: For other request errors
292class Integration: 293 """Base integration class with handler registration and execution. 294 295 This class manages the integration configuration, handler registration, 296 and provides methods to execute actions and triggers. 297 298 Args: 299 config: Integration configuration 300 301 Attributes: 302 config: Integration configuration 303 """ 304 305 def __init__(self, config: IntegrationConfig): 306 self.config = config 307 """Integration configuration""" 308 self._action_handlers: Dict[str, Type[ActionHandler]] = {} 309 """Action handlers""" 310 self._polling_handlers: Dict[str, Type[PollingTriggerHandler]] = {} 311 """Polling handlers""" 312 313 @classmethod 314 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': 315 """Load integration from JSON configuration. 316 317 Args: 318 config_path: Path to the configuration file. Defaults to 'config.json' in the project root. 319 320 Returns: 321 Initialized integration instance 322 323 Raises: 324 ConfigurationError: If configuration is invalid or missing 325 """ 326 if config_path is None: 327 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') 328 329 config_path = Path(config_path) 330 331 if not config_path.exists(): 332 raise ConfigurationError(f"Configuration file not found: {config_path}") 333 334 try: 335 with open(config_path, 'r') as f: 336 config_data = json.load(f) 337 except json.JSONDecodeError as e: 338 raise ConfigurationError(f"Invalid JSON configuration: {e}") 339 340 # Parse configuration sections 341 actions = cls._parse_actions(config_data.get("actions", {})) 342 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) 343 344 config = IntegrationConfig( 345 name=config_data["name"], 346 version=config_data["version"], 347 description=config_data["description"], 348 auth=config_data.get("auth", {}), 349 actions=actions, 350 polling_triggers=polling_triggers 351 ) 352 353 return cls(config) 354 355 @staticmethod 356 def _parse_interval(interval_str: str) -> timedelta: 357 """Parse interval string into timedelta""" 358 unit = interval_str[-1].lower() 359 value = int(interval_str[:-1]) 360 361 if unit == 's': 362 return timedelta(seconds=value) 363 elif unit == 'm': 364 return timedelta(minutes=value) 365 elif unit == 'h': 366 return timedelta(hours=value) 367 elif unit == 'd': 368 return timedelta(days=value) 369 else: 370 raise ConfigurationError(f"Invalid interval format: {interval_str}") 371 372 @classmethod 373 def _parse_actions(cls, actions_config: Dict[str, Any]) -> Dict[str, Action]: 374 """Parse action configurations""" 375 actions = {} 376 for name, data in actions_config.items(): 377 actions[name] = Action( 378 name=name, 379 description=data["description"], 380 input_schema=data["input_schema"], 381 output_schema=data["output_schema"] 382 ) 383 384 return actions 385 386 @classmethod 387 def _parse_polling_triggers(cls, triggers_config: Dict[str, Any]) -> Dict[str, PollingTrigger]: 388 """Parse polling trigger configurations""" 389 triggers = {} 390 for name, data in triggers_config.items(): 391 interval = cls._parse_interval(data["polling_interval"]) 392 393 triggers[name] = PollingTrigger( 394 name=name, 395 description=data["description"], 396 polling_interval=interval, 397 input_schema=data["input_schema"], 398 output_schema=data["output_schema"] 399 ) 400 401 return triggers 402 403 def action(self, name: str): 404 """Decorator to register an action handler. 405 406 Args: 407 name: Name of the action to register 408 409 Returns: 410 Decorator function 411 412 Raises: 413 ConfigurationError: If action is not defined in config 414 415 Example: 416 ```python 417 @integration.action("my_action") 418 class MyActionHandler(ActionHandler): 419 async def execute(self, inputs, context): 420 # Implementation 421 return result 422 ``` 423 """ 424 def decorator(handler_class: Type[ActionHandler]): 425 if name not in self.config.actions: 426 raise ConfigurationError(f"Action '{name}' not defined in config") 427 self._action_handlers[name] = handler_class 428 return handler_class 429 return decorator 430 431 def polling_trigger(self, name: str): 432 """Decorator to register a polling trigger handler 433 434 Args: 435 name: Name of the polling trigger to register 436 437 Returns: 438 Decorator function 439 440 Raises: 441 ConfigurationError: If polling trigger is not defined in config 442 443 Example: 444 ```python 445 @integration.polling_trigger("my_polling_trigger") 446 class MyPollingTriggerHandler(PollingTriggerHandler): 447 async def poll(self, inputs, last_poll_ts, context): 448 # Implementation 449 return result 450 ``` 451 """ 452 def decorator(handler_class: Type[PollingTriggerHandler]): 453 if name not in self.config.polling_triggers: 454 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") 455 self._polling_handlers[name] = handler_class 456 return handler_class 457 return decorator 458 459 async def execute_action(self, 460 name: str, 461 inputs: Dict[str, Any], 462 context: ExecutionContext) -> Any: 463 """Execute a registered action. 464 465 Args: 466 name: Name of the action to execute 467 inputs: Action inputs 468 context: Execution context 469 470 Returns: 471 Action result 472 473 Raises: 474 ValidationError: If inputs or outputs don't match schema 475 """ 476 if name not in self._action_handlers: 477 raise ValidationError(f"Action '{name}' not registered") 478 479 # Validate inputs against action schema 480 action_config = self.config.actions[name] 481 validator = Draft7Validator(action_config.input_schema) 482 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) 483 if errors: 484 message = "" 485 for error in errors: 486 message += f"{list(error.schema_path)}, {error.message},\n " 487 raise ValidationError(message, action_config.input_schema, inputs) 488 489 if "fields" in self.config.auth: 490 auth_config = self.config.auth["fields"] 491 validator = Draft7Validator(auth_config) 492 errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) 493 if errors: 494 message = "" 495 for error in errors: 496 message += f"{list(error.schema_path)}, {error.message},\n " 497 raise ValidationError(message, auth_config, context.auth) 498 499 # Create handler instance and execute 500 handler = self._action_handlers[name]() 501 result = await handler.execute(inputs, context) 502 503 # Validate output if schema is defined 504 validator = Draft7Validator(action_config.output_schema) 505 errors = sorted(validator.iter_errors(result), key=lambda e: e.path) 506 if errors: 507 message = "" 508 for error in errors: 509 message += f"{list(error.schema_path)}, {error.message},\n " 510 raise ValidationError(message, action_config.output_schema, result) 511 512 return result 513 514 async def execute_polling_trigger(self, 515 name: str, 516 inputs: Dict[str, Any], 517 last_poll_ts: Optional[str], 518 context: ExecutionContext) -> List[Dict[str, Any]]: 519 """Execute a registered polling trigger 520 521 Args: 522 name: Name of the polling trigger to execute 523 inputs: Trigger inputs 524 last_poll_ts: Last poll timestamp 525 context: Execution context 526 527 Returns: 528 List of records 529 530 Raises: 531 ValidationError: If inputs or outputs don't match schema 532 """ 533 if name not in self._polling_handlers: 534 raise ValidationError(f"Polling trigger '{name}' not registered") 535 536 # Validate trigger configuration 537 trigger_config = self.config.polling_triggers[name] 538 try: 539 validate(inputs, trigger_config.input_schema) 540 except Exception as e: 541 raise ValidationError(e.message, e.schema, e.instance) 542 543 try: 544 auth_config = self.config.auth["fields"] 545 validate(context.auth, auth_config) 546 except Exception as e: 547 raise ValidationError(e.message, e.schema, e.instance) 548 549 # Create handler instance and execute 550 handler = self._polling_handlers[name]() 551 records = await handler.poll(inputs, last_poll_ts, context) 552 # Validate each record 553 for record in records: 554 if "id" not in record: 555 raise ValidationError( 556 f"Polling trigger '{name}' returned record without required 'id' field") 557 if "data" not in record: 558 raise ValidationError( 559 f"Polling trigger '{name}' returned record without required 'data' field") 560 561 # Validate record data against output schema 562 try: 563 validate(record["data"], trigger_config.output_schema) 564 except Exception as e: 565 raise ValidationError(e.message, e.schema, e.instance) 566 567 return records
Base integration class with handler registration and execution.
This class manages the integration configuration, handler registration, and provides methods to execute actions and triggers.
Arguments:
- config: Integration configuration
Attributes:
- config: Integration configuration
313 @classmethod 314 def load(cls, config_path: Union[str, Path] = None) -> 'Integration': 315 """Load integration from JSON configuration. 316 317 Args: 318 config_path: Path to the configuration file. Defaults to 'config.json' in the project root. 319 320 Returns: 321 Initialized integration instance 322 323 Raises: 324 ConfigurationError: If configuration is invalid or missing 325 """ 326 if config_path is None: 327 config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'config.json') 328 329 config_path = Path(config_path) 330 331 if not config_path.exists(): 332 raise ConfigurationError(f"Configuration file not found: {config_path}") 333 334 try: 335 with open(config_path, 'r') as f: 336 config_data = json.load(f) 337 except json.JSONDecodeError as e: 338 raise ConfigurationError(f"Invalid JSON configuration: {e}") 339 340 # Parse configuration sections 341 actions = cls._parse_actions(config_data.get("actions", {})) 342 polling_triggers = cls._parse_polling_triggers(config_data.get("polling_triggers", {})) 343 344 config = IntegrationConfig( 345 name=config_data["name"], 346 version=config_data["version"], 347 description=config_data["description"], 348 auth=config_data.get("auth", {}), 349 actions=actions, 350 polling_triggers=polling_triggers 351 ) 352 353 return cls(config)
Load integration from JSON configuration.
Arguments:
- config_path: Path to the configuration file. Defaults to 'config.json' in the project root.
Returns:
Initialized integration instance
Raises:
- ConfigurationError: If configuration is invalid or missing
403 def action(self, name: str): 404 """Decorator to register an action handler. 405 406 Args: 407 name: Name of the action to register 408 409 Returns: 410 Decorator function 411 412 Raises: 413 ConfigurationError: If action is not defined in config 414 415 Example: 416 ```python 417 @integration.action("my_action") 418 class MyActionHandler(ActionHandler): 419 async def execute(self, inputs, context): 420 # Implementation 421 return result 422 ``` 423 """ 424 def decorator(handler_class: Type[ActionHandler]): 425 if name not in self.config.actions: 426 raise ConfigurationError(f"Action '{name}' not defined in config") 427 self._action_handlers[name] = handler_class 428 return handler_class 429 return decorator
Decorator to register an action handler.
Arguments:
- name: Name of the action to register
Returns:
Decorator function
Raises:
- ConfigurationError: If action is not defined in config
Example:
@integration.action("my_action") class MyActionHandler(ActionHandler): async def execute(self, inputs, context): # Implementation return result
431 def polling_trigger(self, name: str): 432 """Decorator to register a polling trigger handler 433 434 Args: 435 name: Name of the polling trigger to register 436 437 Returns: 438 Decorator function 439 440 Raises: 441 ConfigurationError: If polling trigger is not defined in config 442 443 Example: 444 ```python 445 @integration.polling_trigger("my_polling_trigger") 446 class MyPollingTriggerHandler(PollingTriggerHandler): 447 async def poll(self, inputs, last_poll_ts, context): 448 # Implementation 449 return result 450 ``` 451 """ 452 def decorator(handler_class: Type[PollingTriggerHandler]): 453 if name not in self.config.polling_triggers: 454 raise ConfigurationError(f"Polling trigger '{name}' not defined in config") 455 self._polling_handlers[name] = handler_class 456 return handler_class 457 return decorator
Decorator to register a polling trigger handler
Arguments:
- name: Name of the polling trigger to register
Returns:
Decorator function
Raises:
- ConfigurationError: If polling trigger is not defined in config
Example:
@integration.polling_trigger("my_polling_trigger") class MyPollingTriggerHandler(PollingTriggerHandler): async def poll(self, inputs, last_poll_ts, context): # Implementation return result
459 async def execute_action(self, 460 name: str, 461 inputs: Dict[str, Any], 462 context: ExecutionContext) -> Any: 463 """Execute a registered action. 464 465 Args: 466 name: Name of the action to execute 467 inputs: Action inputs 468 context: Execution context 469 470 Returns: 471 Action result 472 473 Raises: 474 ValidationError: If inputs or outputs don't match schema 475 """ 476 if name not in self._action_handlers: 477 raise ValidationError(f"Action '{name}' not registered") 478 479 # Validate inputs against action schema 480 action_config = self.config.actions[name] 481 validator = Draft7Validator(action_config.input_schema) 482 errors = sorted(validator.iter_errors(inputs), key=lambda e: e.path) 483 if errors: 484 message = "" 485 for error in errors: 486 message += f"{list(error.schema_path)}, {error.message},\n " 487 raise ValidationError(message, action_config.input_schema, inputs) 488 489 if "fields" in self.config.auth: 490 auth_config = self.config.auth["fields"] 491 validator = Draft7Validator(auth_config) 492 errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path) 493 if errors: 494 message = "" 495 for error in errors: 496 message += f"{list(error.schema_path)}, {error.message},\n " 497 raise ValidationError(message, auth_config, context.auth) 498 499 # Create handler instance and execute 500 handler = self._action_handlers[name]() 501 result = await handler.execute(inputs, context) 502 503 # Validate output if schema is defined 504 validator = Draft7Validator(action_config.output_schema) 505 errors = sorted(validator.iter_errors(result), key=lambda e: e.path) 506 if errors: 507 message = "" 508 for error in errors: 509 message += f"{list(error.schema_path)}, {error.message},\n " 510 raise ValidationError(message, action_config.output_schema, result) 511 512 return result
Execute a registered action.
Arguments:
- name: Name of the action to execute
- inputs: Action inputs
- context: Execution context
Returns:
Action result
Raises:
- ValidationError: If inputs or outputs don't match schema
514 async def execute_polling_trigger(self, 515 name: str, 516 inputs: Dict[str, Any], 517 last_poll_ts: Optional[str], 518 context: ExecutionContext) -> List[Dict[str, Any]]: 519 """Execute a registered polling trigger 520 521 Args: 522 name: Name of the polling trigger to execute 523 inputs: Trigger inputs 524 last_poll_ts: Last poll timestamp 525 context: Execution context 526 527 Returns: 528 List of records 529 530 Raises: 531 ValidationError: If inputs or outputs don't match schema 532 """ 533 if name not in self._polling_handlers: 534 raise ValidationError(f"Polling trigger '{name}' not registered") 535 536 # Validate trigger configuration 537 trigger_config = self.config.polling_triggers[name] 538 try: 539 validate(inputs, trigger_config.input_schema) 540 except Exception as e: 541 raise ValidationError(e.message, e.schema, e.instance) 542 543 try: 544 auth_config = self.config.auth["fields"] 545 validate(context.auth, auth_config) 546 except Exception as e: 547 raise ValidationError(e.message, e.schema, e.instance) 548 549 # Create handler instance and execute 550 handler = self._polling_handlers[name]() 551 records = await handler.poll(inputs, last_poll_ts, context) 552 # Validate each record 553 for record in records: 554 if "id" not in record: 555 raise ValidationError( 556 f"Polling trigger '{name}' returned record without required 'id' field") 557 if "data" not in record: 558 raise ValidationError( 559 f"Polling trigger '{name}' returned record without required 'data' field") 560 561 # Validate record data against output schema 562 try: 563 validate(record["data"], trigger_config.output_schema) 564 except Exception as e: 565 raise ValidationError(e.message, e.schema, e.instance) 566 567 return records
Execute a registered polling trigger
Arguments:
- name: Name of the polling trigger to execute
- inputs: Trigger inputs
- last_poll_ts: Last poll timestamp
- context: Execution context
Returns:
List of records
Raises:
- ValidationError: If inputs or outputs don't match schema