Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,089 changes: 2,627 additions & 2,462 deletions docs/apidocs/autohive_integrations_sdk/integration.html

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/apidocs/search.js

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion docs/manual/building_your_first_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,24 @@ class GetItemsAction(ActionHandler):

### Making HTTP Requests

Use `context.fetch()` for all HTTP calls. It handles authentication headers, retries, timeouts, and response parsing automatically. It returns a `FetchResponse` object — access the parsed body via `.data`, the HTTP status via `.status`, and response headers via `.headers`.
Use `context.fetch()` for all HTTP calls. It handles authentication headers, a default `User-Agent`, retries, timeouts, and response parsing automatically. It returns a `FetchResponse` object — access the parsed body via `.data`, the HTTP status via `.status`, and response headers via `.headers`.

When no `User-Agent` header is provided, the SDK sends a versioned default using the SDK version and, when the request is made from a registered integration handler, the integration `name` and `version` from `config.json`:

```text
AutohiveIntegrationsSDK/<sdk-version> <integration-name>/<integration-version>
```

If an API requires a specific `User-Agent`, pass it explicitly with the `user_agent` convenience argument:

```python
response = await context.fetch(
f"{BASE_URL}/items",
user_agent="MyIntegration/1.0"
)
```

Existing `headers={"User-Agent": "..."}` usage is also supported and takes precedence over `user_agent` when both are provided.

```python
# GET with query parameters
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies = [

[project.optional-dependencies]
test = [
"aiohttp<3.13.4",
"pytest>=8.0",
"pytest-asyncio>=0.24",
"aioresponses>=0.7",
Expand All @@ -43,4 +44,4 @@ asyncio_mode = "auto"

[project.urls]
Homepage = "https://github.com/Autohive-AI/integrations-sdk"
Issues = "https://github.com/Autohive-AI/integrations-sdk/issues"
Issues = "https://github.com/Autohive-AI/integrations-sdk/issues"
76 changes: 68 additions & 8 deletions src/autohive_integrations_sdk/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ async def execute(self, inputs, context):
import json as jsonX # Keep alias to avoid conflict with 'json' parameter in fetch
import logging
import os
import re
import sys
from pathlib import Path
from typing import Dict, Any, List, Optional, Union, Type, TypeVar, Generic, ClassVar
Expand All @@ -52,6 +53,18 @@ async def execute(self, inputs, context):
# ---- Type Definitions ----
T = TypeVar('T')

_USER_AGENT_TOKEN_RE = re.compile(r"[^A-Za-z0-9!#$%&'*+.^_`|~-]+")


def _sanitize_user_agent_token(value: Any) -> str:
"""Return a safe User-Agent product token component."""
token = _USER_AGENT_TOKEN_RE.sub("-", str(value)).strip("-")
return token or "unknown"


DEFAULT_USER_AGENT = f"AutohiveIntegrationsSDK/{_sanitize_user_agent_token(__version__)}"
"""Default User-Agent sent by ``ExecutionContext.fetch()`` when not overridden."""

# ---- Auth Types ----
class AuthType(Enum):
"""Authentication strategy used by an integration.
Expand Down Expand Up @@ -350,8 +363,9 @@ async def get_account_info(self, context: 'ExecutionContext') -> ConnectedAccoun
class ExecutionContext:
"""Context provided to integration handlers for making authenticated HTTP requests.

Manages an ``aiohttp`` session with automatic retries, error handling, and
optional Bearer-token injection for platform OAuth integrations.
Manages an ``aiohttp`` session with automatic retries, error handling,
default ``User-Agent`` handling, and optional Bearer-token injection for
platform OAuth integrations.

Use as an async context manager::

Expand Down Expand Up @@ -383,6 +397,8 @@ def __init__(
self.logger = logger or logging.getLogger(__name__)
"""Logger instance"""
self._session: Optional[aiohttp.ClientSession] = None
self._integration_name: Optional[str] = None
self._integration_version: Optional[str] = None

async def __aenter__(self):
if not self._session:
Expand All @@ -394,6 +410,21 @@ async def __aexit__(self, exc_type, exc_val, exc_tb):
await self._session.close()
self._session = None

def _set_integration_identity(self, name: Optional[str], version: Optional[str]) -> None:
"""Attach integration identity for SDK-generated request metadata."""
self._integration_name = name
self._integration_version = version

def _build_default_user_agent(self) -> str:
if self._integration_name and self._integration_version:
integration_token = (
f"{_sanitize_user_agent_token(self._integration_name)}/"
f"{_sanitize_user_agent_token(self._integration_version)}"
)
return f"{DEFAULT_USER_AGENT} {integration_token}"

return DEFAULT_USER_AGENT

async def fetch(
self,
url: str,
Expand All @@ -404,10 +435,17 @@ async def fetch(
headers: Optional[Dict[str, str]] = None,
content_type: Optional[str] = None,
timeout: Optional[int] = None,
retry_count: int = 0
retry_count: int = 0,
user_agent: Optional[str] = None
) -> FetchResponse:
"""Make an HTTP request with automatic retries and error handling.

If no ``User-Agent`` header is provided, a default SDK ``User-Agent`` is
added. When the request is made inside a handler executed by
``Integration``, the integration's ``config.json`` name and version are
included. Pass ``user_agent`` to set a per-request value more easily.
Explicit ``User-Agent`` headers always take precedence.

For **platform OAuth** integrations (``auth_type == "PlatformOauth2"``),
a ``Bearer`` token is auto-injected from ``auth.credentials.access_token``
unless an ``Authorization`` header is explicitly provided.
Expand All @@ -425,7 +463,10 @@ async def fetch(
json: JSON-serializable payload. Sets ``content_type`` to
``application/json`` automatically.
headers: Additional HTTP headers. Merged *after* any auto-injected
auth header, so an explicit ``Authorization`` takes precedence.
auth header, so explicit ``Authorization`` and ``User-Agent``
values take precedence.
user_agent: Convenience override for the request ``User-Agent``.
Ignored when ``headers`` already contains a ``User-Agent`` key.
content_type: ``Content-Type`` header value.
timeout: Per-request timeout in seconds (overrides ``request_config``).
retry_count: Internal — current retry attempt number.
Expand All @@ -447,6 +488,9 @@ async def fetch(
content_type = "application/json"

final_headers = {}

if not any(key.lower() == "user-agent" for key in (headers or {})):
final_headers["User-Agent"] = user_agent or self._build_default_user_agent()

if self.auth and "Authorization" not in (headers or {}):
auth_type = AuthType(self.auth.get("auth_type", "PlatformOauth2"))
Expand Down Expand Up @@ -538,7 +582,8 @@ async def fetch(
# Use original_timeout (numeric) for recursive calls
return await self.fetch(
url, method, params, data, json,
headers, content_type, original_timeout, retry_count + 1
headers, content_type, original_timeout, retry_count + 1,
user_agent=user_agent,
)
else:
print("Max retries reached. Raising error.")
Expand Down Expand Up @@ -785,7 +830,12 @@ async def execute_action(self,

# Create handler instance and execute
handler = self._action_handlers[name]()
result = await handler.execute(inputs, context)
previous_identity = (context._integration_name, context._integration_version)
context._set_integration_identity(self.config.name, self.config.version)
try:
result = await handler.execute(inputs, context)
finally:
context._set_integration_identity(*previous_identity)

# Handle ActionError - skip output schema validation
if isinstance(result, ActionError):
Expand Down Expand Up @@ -866,7 +916,12 @@ async def execute_polling_trigger(self,

# Create handler instance and execute
handler = self._polling_handlers[name]()
records = await handler.poll(inputs, last_poll_ts, context)
previous_identity = (context._integration_name, context._integration_version)
context._set_integration_identity(self.config.name, self.config.version)
try:
records = await handler.poll(inputs, last_poll_ts, context)
finally:
context._set_integration_identity(*previous_identity)
# Validate each record
for record in records:
if "id" not in record:
Expand Down Expand Up @@ -910,7 +965,12 @@ async def get_connected_account(self, context: ExecutionContext) -> IntegrationR
raise ValidationError(message, auth_config, context.auth)

handler = self._connected_account_handler()
account_info = await handler.get_account_info(context)
previous_identity = (context._integration_name, context._integration_version)
context._set_integration_identity(self.config.name, self.config.version)
try:
account_info = await handler.get_account_info(context)
finally:
context._set_integration_identity(*previous_identity)

if not isinstance(account_info, ConnectedAccountInfo):
raise ValidationError(
Expand Down
79 changes: 79 additions & 0 deletions tests/test_execution_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from yarl import URL

from autohive_integrations_sdk import (
__version__,
ExecutionContext,
HTTPError,
RateLimitError,
Expand Down Expand Up @@ -128,6 +129,84 @@ async def test_fetch_no_auth_injection_when_header_provided(mock_aio):
assert request.kwargs["headers"]["Authorization"] == "Custom xyz"


# ── User-Agent ──────────────────────────────────────────────────────────────


async def test_fetch_sets_default_user_agent(mock_aio):
mock_aio.get(BASE_URL, payload={"ok": True})

async with ExecutionContext() as ctx:
await ctx.fetch(BASE_URL)

key = ("GET", URL(BASE_URL))
request = mock_aio.requests[key][0]
assert request.kwargs["headers"]["User-Agent"] == f"AutohiveIntegrationsSDK/{__version__}"


async def test_fetch_default_user_agent_includes_integration_identity(mock_aio):
mock_aio.get(BASE_URL, payload={"ok": True})

async with ExecutionContext() as ctx:
ctx._set_integration_identity("My Integration", "1.0.0 beta")
await ctx.fetch(BASE_URL)

key = ("GET", URL(BASE_URL))
request = mock_aio.requests[key][0]
assert (
request.kwargs["headers"]["User-Agent"]
== f"AutohiveIntegrationsSDK/{__version__} My-Integration/1.0.0-beta"
)


async def test_fetch_user_agent_header_can_be_overridden(mock_aio):
mock_aio.get(BASE_URL, payload={"ok": True})

async with ExecutionContext() as ctx:
await ctx.fetch(BASE_URL, headers={"User-Agent": "CustomIntegration/1.0"})

key = ("GET", URL(BASE_URL))
request = mock_aio.requests[key][0]
assert request.kwargs["headers"]["User-Agent"] == "CustomIntegration/1.0"


async def test_fetch_user_agent_argument_sets_user_agent(mock_aio):
mock_aio.get(BASE_URL, payload={"ok": True})

async with ExecutionContext() as ctx:
await ctx.fetch(BASE_URL, user_agent="CustomIntegration/1.0")

key = ("GET", URL(BASE_URL))
request = mock_aio.requests[key][0]
assert request.kwargs["headers"]["User-Agent"] == "CustomIntegration/1.0"


async def test_fetch_user_agent_header_takes_precedence_over_argument(mock_aio):
mock_aio.get(BASE_URL, payload={"ok": True})

async with ExecutionContext() as ctx:
await ctx.fetch(
BASE_URL,
headers={"User-Agent": "HeaderIntegration/1.0"},
user_agent="ArgumentIntegration/1.0",
)

key = ("GET", URL(BASE_URL))
request = mock_aio.requests[key][0]
assert request.kwargs["headers"]["User-Agent"] == "HeaderIntegration/1.0"


async def test_fetch_lowercase_user_agent_header_can_be_overridden(mock_aio):
mock_aio.get(BASE_URL, payload={"ok": True})

async with ExecutionContext() as ctx:
await ctx.fetch(BASE_URL, headers={"user-agent": "CustomIntegration/1.0"})

key = ("GET", URL(BASE_URL))
request = mock_aio.requests[key][0]
assert "User-Agent" not in request.kwargs["headers"]
assert request.kwargs["headers"]["user-agent"] == "CustomIntegration/1.0"


# ── Query params ─────────────────────────────────────────────────────────────


Expand Down
42 changes: 42 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@
from datetime import timedelta

import pytest
from aioresponses import aioresponses
from yarl import URL

from autohive_integrations_sdk import (
__version__,
Integration,
ExecutionContext,
ActionHandler,
Expand Down Expand Up @@ -97,6 +100,45 @@ async def execute(self, inputs, context):
assert result.result.cost_usd == 0.05


async def test_execute_action_applies_integration_identity_to_fetch_user_agent(integration):
url = "https://api.example.com/resource"

@integration.action("test_action")
class Handler(ActionHandler):
async def execute(self, inputs, context):
await context.fetch(url)
return ActionResult(data={"greeting": "hi"})

with aioresponses() as mock_aio:
mock_aio.get(url, payload={"ok": True})

ctx = ExecutionContext(auth={"api_key": "k"})
result = await integration.execute_action("test_action", {"name": "x"}, ctx)

request = mock_aio.requests[("GET", URL(url))][0]
assert result.type == ResultType.ACTION
assert (
request.kwargs["headers"]["User-Agent"]
== f"AutohiveIntegrationsSDK/{__version__} test-integration/0.1.0"
)


async def test_execute_action_restores_previous_context_integration_identity(integration):
@integration.action("test_action")
class Handler(ActionHandler):
async def execute(self, inputs, context):
return ActionResult(data={"greeting": "hi"})

ctx = ExecutionContext(auth={"api_key": "k"})
ctx._set_integration_identity("previous-integration", "9.9.9")

result = await integration.execute_action("test_action", {"name": "x"}, ctx)

assert result.type == ResultType.ACTION
assert ctx._integration_name == "previous-integration"
assert ctx._integration_version == "9.9.9"


async def test_execute_action_invalid_inputs(integration):
@integration.action("test_action")
class Handler(ActionHandler):
Expand Down
Loading