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
2 changes: 1 addition & 1 deletion README_PYPI.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class FetchData(ActionHandler):
async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult:
response = await context.fetch(
"https://api.example.com/data",
headers={"Authorization": f"Bearer {context.auth['api_key']}"}
headers={"Authorization": f"Bearer {context.auth['credentials']['api_key']}"}
)
return ActionResult(data=response.data)
```
Expand Down
7 changes: 4 additions & 3 deletions docs/manual/building_your_first_integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,17 +437,18 @@ if __name__ == "__main__":
asyncio.run(test_get_items())
```

For integrations that require authentication, pass credentials matching your `auth.fields` schema:
For integrations that require authentication, pass the wrapped auth envelope. The `credentials` object must match your `auth.fields` schema, and `auth_type` must be present:

```python
auth = {
"api_token": "your-test-token"
"auth_type": "Custom",
"credentials": {"api_token": "your-test-token"}
}
async with ExecutionContext(auth=auth) as context:
result = await my_integration.execute_action("get_items", inputs, context)
```

> **Note:** In production, the platform wraps credentials as `{"auth_type": "...", "credentials": {"api_token": "..."}}`. That's why production action handlers access `context.auth.get("credentials", {}).get("api_token")`. When testing locally with `execute_action`, the SDK validates `context.auth` directly against your `auth.fields` schema, so pass credentials flat.
> **Note:** The platform always passes `context.auth` as the wrapped envelope `{"auth_type": "...", "credentials": {"api_token": "..."}}`, and the SDK validates it as such β€” flat auth is rejected with a `source="auth"` validation error. Handlers should read individual credentials directly from the envelope via `context.auth["credentials"]` (for example `context.auth["credentials"].get("api_token")`). Local tests must use the same wrapped shape.

For **platform OAuth** integrations (where `context.fetch()` auto-injects the Bearer token), pass the full wrapped structure in tests:

Expand Down
10 changes: 8 additions & 2 deletions docs/manual/integration_structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ For integrations where users provide their own credentials:

Required fields:
- `type`: must be `"custom"`
- `fields`: JSON Schema object with `properties`
- `fields`: a JSON Schema describing the **credentials object only** (the inner
`credentials` the platform passes at runtime), with `properties` and an
optional `required` list. `required` is allowed and recommended β€” it is
enforced against the credentials object.

The `title` field labels the auth section in the UI.

Expand All @@ -158,7 +161,10 @@ Each property in `fields.properties` supports:
| `help_text` | string | Help text shown below the field |
| `default` | any | Default value |

At runtime, credentials are nested under `context.auth["credentials"]`: `context.auth.get("credentials", {}).get("api_token")`.
At runtime `context.auth` is the wrapped platform envelope
(`{"auth_type": ..., "credentials": {...}}`). Handlers should read individual
credentials directly from the envelope via
`context.auth["credentials"]`: `context.auth["credentials"].get("api_token")`.

#### Platform Auth (OAuth2)

Expand Down
6 changes: 5 additions & 1 deletion docs/manual/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,11 @@ Define multiple properties in the `auth.fields` schema:

### Accessing Credentials

Credentials are nested under `context.auth["credentials"]`:
At runtime `context.auth` is the wrapped platform envelope
(`{"auth_type": ..., "credentials": {...}}`). The `auth.fields` schema in
`config.json` describes the inner credentials object only, and `auth.fields.required`
is allowed and recommended. Read credentials directly from the envelope via
`context.auth["credentials"]`:

```python
def get_base_url(context: ExecutionContext) -> str:
Expand Down
6 changes: 3 additions & 3 deletions samples/api-fetch/api_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ class APIFetchActionBasicAuth(ActionHandler):

async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult:
url = inputs["url"]
username = context.auth.get("user_name", "")
password = context.auth.get("password", "")
username = context.auth["credentials"].get("user_name", "")
password = context.auth["credentials"].get("password", "")

# Inject credentials into the URL for Basic Auth
if url.startswith("https://"):
Expand All @@ -57,7 +57,7 @@ class APIFetchActionHeader(ActionHandler):

async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult:
url = inputs["url"]
api_key = context.auth.get("api_key", "")
api_key = context.auth["credentials"].get("api_key", "")

response = await context.fetch(
url,
Expand Down
27 changes: 18 additions & 9 deletions samples/api-fetch/tests/test_api_fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@
async def test_call_api():
"""Test simple API call."""
auth = {
"user_name": "test_user",
"password": "test_password",
"api_key": "test_api_key"
"auth_type": "Custom",
"credentials": {
"user_name": "test_user",
"password": "test_password",
"api_key": "test_api_key"
}
}
inputs = {"url": "https://httpbin.org/get"}

Expand All @@ -21,9 +24,12 @@ async def test_call_api():
async def test_call_api_un_pw():
"""Test API call with Basic Auth."""
auth = {
"user_name": "test_user",
"password": "test_password",
"api_key": "test_api_key"
"auth_type": "Custom",
"credentials": {
"user_name": "test_user",
"password": "test_password",
"api_key": "test_api_key"
}
}
inputs = {"url": "https://httpbin.org/basic-auth/test_user/test_password"}

Expand All @@ -35,9 +41,12 @@ async def test_call_api_un_pw():
async def test_call_api_header():
"""Test API call with Bearer token header."""
auth = {
"user_name": "test_user",
"password": "test_password",
"api_key": "test_api_key"
"auth_type": "Custom",
"credentials": {
"user_name": "test_user",
"password": "test_password",
"api_key": "test_api_key"
}
}
inputs = {"url": "https://httpbin.org/bearer"}

Expand Down
2 changes: 1 addition & 1 deletion samples/template/my_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class MyAction(ActionHandler):

async def execute(self, inputs: Dict[str, Any], context: ExecutionContext) -> ActionResult:
example_input = inputs["example_input"]
api_key = context.auth.get("api_key", "")
api_key = context.auth["credentials"].get("api_key", "")

response = await context.fetch(
"https://api.example.com/endpoint",
Expand Down
10 changes: 7 additions & 3 deletions samples/template/tests/test_my_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@

async def test_my_action():
"""Test the my_action action."""
# Auth fields are passed flat (matching config.json fields schema).
# In production, the platform wraps these under {"auth_type": "...", "credentials": {...}}.
# Auth is the platform envelope. The credentials object matches the
# config.json fields schema. This is the same shape the platform sends
# in production.
auth = {
"api_key": "test_api_key"
"auth_type": "Custom",
"credentials": {
"api_key": "test_api_key"
}
}

inputs = {
Expand Down
4 changes: 2 additions & 2 deletions skills/writing-integration-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def live_context(env_credentials, make_context):
api_key = env_credentials("MYINTEGRATION_API_KEY")
if not api_key:
pytest.skip("MYINTEGRATION_API_KEY not set β€” skipping integration tests")
return make_context(auth={"credentials": {"api_key": api_key}})
return make_context(auth={"auth_type": "Custom", "credentials": {"api_key": api_key}})
```

This is the recommended pattern for any test that needs API credentials: it skips automatically when the env var is missing, integrates with the project `.env`, and avoids per-test boilerplate.
Expand Down Expand Up @@ -264,7 +264,7 @@ def live_context(env_credentials, make_context):
api_key = env_credentials("MYINTEGRATION_API_KEY")
if not api_key:
pytest.skip("MYINTEGRATION_API_KEY not set β€” skipping integration tests")
return make_context(auth={"credentials": {"api_key": api_key}})
return make_context(auth={"auth_type": "Custom", "credentials": {"api_key": api_key}})
```

This variant is the simplest of the four β€” no `real_fetch` definition needed. Use it whenever the integration's source imports a vendor SDK and calls it directly rather than calling `context.fetch`.
Expand Down
26 changes: 17 additions & 9 deletions skills/writing-unit-tests/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def mock_context():
"""Mock ExecutionContext pre-loaded with this integration's credentials."""
ctx = MagicMock(name="ExecutionContext")
ctx.fetch = AsyncMock(name="fetch")
ctx.auth = {"credentials": {"api_key": "test_api_key"}} # nosec B105
ctx.auth = {"auth_type": "Custom", "credentials": {"api_key": "test_api_key"}} # nosec B105
return ctx
```

Expand Down Expand Up @@ -132,7 +132,7 @@ For one-off credential shapes inside a single test, use `make_context`:

```python
async def test_with_custom_auth(make_context):
ctx = make_context(auth={"credentials": {"api_key": "different_key"}}) # nosec B105
ctx = make_context(auth={"auth_type": "Custom", "credentials": {"api_key": "different_key"}}) # nosec B105
...
```

Expand All @@ -145,24 +145,32 @@ ctx.auth = {
}
```

**Custom auth** (`config.auth.type == "custom"`): The SDK validates `context.auth` directly against `config.auth.fields`. Use a flat object matching the field schema:
**Custom auth** (`config.auth.type == "custom"`): The platform always passes `context.auth` as the wrapped envelope `{"auth_type": ..., "credentials": {...}}`, and the SDK validates the inner `credentials` object against `config.auth.fields`. Use the wrapped envelope with a non-empty `auth_type` and put the field values under `credentials`:

```python
# Example: Freshdesk (api_key + domain)
ctx.auth = {
"api_key": "test_api_key", # nosec B105
"domain": "testcompany",
"auth_type": "Custom",
"credentials": {
"api_key": "test_api_key", # nosec B105
"domain": "testcompany",
},
}

# Example: Ghost (api_url + content_api_key + admin_api_key)
ctx.auth = {
"api_url": "https://test.ghost.io",
"content_api_key": "test_content_key", # nosec B105
"admin_api_key": "test_admin_key", # nosec B105
"auth_type": "Custom",
"credentials": {
"api_url": "https://test.ghost.io",
"content_api_key": "test_content_key", # nosec B105
"admin_api_key": "test_admin_key", # nosec B105
},
}
```

Using the wrong shape will cause `ResultType.VALIDATION_ERROR` before the action handler runs, making tests false negatives.
Handlers read individual credentials directly from the envelope via `context.auth["credentials"]` (for example `context.auth["credentials"].get("api_key")`).

Using the wrong shape β€” flat auth, or an envelope missing `auth_type` β€” will cause `ResultType.VALIDATION_ERROR` (with `source="auth"`) before the action handler runs, making tests false negatives.

## Test Categories

Expand Down
95 changes: 66 additions & 29 deletions src/autohive_integrations_sdk/integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def __init__(self, message: str, schema: str = None, inputs: str = None, source:
self.message = message
"""The error message"""
self.source = source
"""Where the validation failed: 'input', 'output', or 'legacy' (pre-versioning default)"""
"""Where the validation failed: 'input', 'output', 'auth', or 'legacy' (pre-versioning default)"""
super().__init__(message)

class ConfigurationError(Exception):
Expand Down Expand Up @@ -373,10 +373,15 @@ class ExecutionContext:
result = await integration.execute_action("my_action", inputs, context)

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.
Comment thread
TheRealAgentK marked this conversation as resolved.
``{"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__)``.
Expand Down Expand Up @@ -788,6 +793,58 @@ def decorator(handler_class: Type[ConnectedAccountHandler]):
return handler_class
return decorator

def _validate_auth(self, context: ExecutionContext) -> None:
"""Validate the auth envelope's credentials against ``auth.fields``.

The platform always passes ``context.auth`` as the
wrapped envelope ``{"auth_type": ..., "credentials": {...}}``. The
``auth.fields`` schema in ``config.json`` describes only the inner
``credentials`` object, so validation runs against
``context.auth["credentials"]`` β€” not the whole envelope.

Integrations with no auth or no ``fields`` key skip validation entirely.

Raises:
ValidationError: (source ``"auth"``) if ``context.auth`` is not a
wrapped envelope (a dict with a non-empty string ``auth_type``
and a dict ``credentials``), or if the credentials fail the
schema.
"""
if "fields" not in self.config.auth:
return

auth = context.auth
has_valid_credentials = isinstance(auth, dict) and isinstance(auth.get("credentials"), dict)
has_valid_auth_type = (
isinstance(auth, dict)
and isinstance(auth.get("auth_type"), str)
and auth.get("auth_type") != ""
)
if not has_valid_credentials or not has_valid_auth_type:
raise ValidationError(
'context.auth must be the platform auth envelope '
'{"auth_type": ..., "credentials": {...}} with a non-empty '
'auth_type; flat auth is not supported.',
source="auth",
)

valid_auth_types = {member.value for member in AuthType}
if auth["auth_type"] not in valid_auth_types:
raise ValidationError(
f'Unknown auth_type "{auth["auth_type"]}" in context.auth; '
f'expected one of: {", ".join(sorted(valid_auth_types))}.',
source="auth",
)

auth_config = self.config.auth["fields"]
validator = Draft7Validator(auth_config)
errors = sorted(validator.iter_errors(context.auth["credentials"]), key=lambda e: e.path)
if errors:
message = ""
for error in errors:
message += f"{list(error.schema_path)}, {error.message},\n "
raise ValidationError(message, auth_config, context.auth["credentials"], source="auth")

async def execute_action(self,
name: str,
inputs: Dict[str, Any],
Expand Down Expand Up @@ -818,15 +875,7 @@ async def execute_action(self,
message += f"{list(error.schema_path)}, {error.message},\n "
raise ValidationError(message, action_config.input_schema, inputs, source="input")

if "fields" in self.config.auth:
auth_config = self.config.auth["fields"]
validator = Draft7Validator(auth_config)
errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path)
if errors:
message = ""
for error in errors:
message += f"{list(error.schema_path)}, {error.message},\n "
raise ValidationError(message, auth_config, context.auth, source="input")
self._validate_auth(context)

# Create handler instance and execute
handler = self._action_handlers[name]()
Expand Down Expand Up @@ -908,12 +957,8 @@ async def execute_polling_trigger(self,
except Exception as e:
raise ValidationError(e.message, e.schema, e.instance)

try:
auth_config = self.config.auth["fields"]
validate(context.auth, auth_config)
except Exception as e:
raise ValidationError(e.message, e.schema, e.instance)

self._validate_auth(context)

# Create handler instance and execute
handler = self._polling_handlers[name]()
previous_identity = (context._integration_name, context._integration_version)
Expand Down Expand Up @@ -954,15 +999,7 @@ async def get_connected_account(self, context: ExecutionContext) -> IntegrationR
if not self._connected_account_handler:
raise ValidationError("No connected account handler registered")

if "fields" in self.config.auth:
auth_config = self.config.auth["fields"]
validator = Draft7Validator(auth_config)
errors = sorted(validator.iter_errors(context.auth), key=lambda e: e.path)
if errors:
message = ""
for error in errors:
message += f"{list(error.schema_path)}, {error.message},\n "
raise ValidationError(message, auth_config, context.auth)
self._validate_auth(context)

handler = self._connected_account_handler()
previous_identity = (context._integration_name, context._integration_version)
Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ def integration(tmp_config):

@pytest.fixture
def execution_context():
"""A simple ExecutionContext with a Custom API key."""
"""A simple ExecutionContext with a Custom API key in the platform envelope."""
return ExecutionContext(
auth={"api_key": "test-key-123"},
auth={"auth_type": "Custom", "credentials": {"api_key": "test-key-123"}},
request_config={"max_retries": 1, "timeout": 1},
)
Loading
Loading