Skip to content
Merged
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
56 changes: 56 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/apidocs/autohive_integrations_sdk.html
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ <h1 class="modulename">
<label class="view-source-button" for="mod-autohive_integrations_sdk-view-source"><span>View Source</span></label>

<div class="pdoc-code codehilite"><pre><span></span><span id="L-1"><a href="#L-1"><span class="linenos">1</span></a><span class="c1"># Version</span>
</span><span id="L-2"><a href="#L-2"><span class="linenos">2</span></a><span class="n">__version__</span> <span class="o">=</span> <span class="s2">&quot;2.0.0&quot;</span>
</span><span id="L-2"><a href="#L-2"><span class="linenos">2</span></a><span class="n">__version__</span> <span class="o">=</span> <span class="s2">&quot;2.0.1&quot;</span>
</span><span id="L-3"><a href="#L-3"><span class="linenos">3</span></a>
</span><span id="L-4"><a href="#L-4"><span class="linenos">4</span></a><span class="c1"># Re-export classes from integration module</span>
</span><span id="L-5"><a href="#L-5"><span class="linenos">5</span></a><span class="kn">from</span><span class="w"> </span><span class="nn">autohive_integrations_sdk.integration</span><span class="w"> </span><span class="kn">import</span> <span class="p">(</span>
Expand Down
5,196 changes: 2,545 additions & 2,651 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: 1 addition & 18 deletions docs/manual/building_your_first_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,24 +263,7 @@ class GetItemsAction(ActionHandler):

### Making HTTP Requests

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.
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`.

```python
# GET with query parameters
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "autohive_integrations_sdk"
version = "2.0.0"
version = "2.0.1"
authors = [
{ name="Reilly Oldham", email="reilly@autohive.com" },
{ name="Kai Koenig", email="kai@autohive.com" },
Expand Down
2 changes: 1 addition & 1 deletion src/autohive_integrations_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Version
__version__ = "2.0.0"
__version__ = "2.0.1"

# Re-export classes from integration module
from autohive_integrations_sdk.integration import (
Expand Down
76 changes: 8 additions & 68 deletions src/autohive_integrations_sdk/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ 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 @@ -53,18 +52,6 @@ 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 @@ -363,9 +350,8 @@ 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,
default ``User-Agent`` handling, and optional Bearer-token injection for
platform OAuth integrations.
Manages an ``aiohttp`` session with automatic retries, error handling, and
optional Bearer-token injection for platform OAuth integrations.

Use as an async context manager::

Expand Down Expand Up @@ -402,8 +388,6 @@ 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 @@ -415,21 +399,6 @@ 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 @@ -440,17 +409,10 @@ async def fetch(
headers: Optional[Dict[str, str]] = None,
content_type: Optional[str] = None,
timeout: Optional[int] = None,
retry_count: int = 0,
user_agent: Optional[str] = None
retry_count: int = 0
) -> 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 @@ -468,10 +430,7 @@ 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 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.
auth header, so an explicit ``Authorization`` takes precedence.
content_type: ``Content-Type`` header value.
timeout: Per-request timeout in seconds (overrides ``request_config``).
retry_count: Internal β€” current retry attempt number.
Expand All @@ -493,9 +452,6 @@ 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 @@ -587,8 +543,7 @@ 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,
user_agent=user_agent,
headers, content_type, original_timeout, retry_count + 1
)
else:
print("Max retries reached. Raising error.")
Expand Down Expand Up @@ -879,12 +834,7 @@ async def execute_action(self,

# Create handler instance and execute
handler = self._action_handlers[name]()
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)
result = await handler.execute(inputs, context)

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

# Create handler instance and execute
handler = self._polling_handlers[name]()
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)
records = await handler.poll(inputs, last_poll_ts, context)
# Validate each record
for record in records:
if "id" not in record:
Expand Down Expand Up @@ -1002,12 +947,7 @@ async def get_connected_account(self, context: ExecutionContext) -> IntegrationR
self._validate_auth(context)

handler = self._connected_account_handler()
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)
account_info = await handler.get_account_info(context)

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

from autohive_integrations_sdk import (
__version__,
ExecutionContext,
HTTPError,
RateLimitError,
Expand Down Expand Up @@ -129,84 +128,6 @@ 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
Loading
Loading