diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 0b4f32756..fb7597068 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Added `client.user_defined_functions` for reusable expressions. - Added `client.annotations` for data reviews, phases, and review history. - Added `client.campaigns` for grouping report results across runs. +- Added `client.webhooks`, and `RuleAction.webhook` to call one when a rule is violated. +- `RuleCreate.action` and `RuleUpdate.action` accept a `Webhook` directly. +- `RuleCreate.asset_ids` and `RuleUpdate.asset_ids` now accept `Asset` objects as well as IDs. +- `WebhookTestResult.text` decodes the response body, and `Webhook.created_by`/`modified_by` resolve users. ### What's New diff --git a/python/lib/sift_client/_internal/low_level_wrappers/rules.py b/python/lib/sift_client/_internal/low_level_wrappers/rules.py index 5a49acf11..6a8665a0a 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/rules.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/rules.py @@ -58,6 +58,7 @@ from sift_client._internal.util.util import count_non_none from sift_client.sift_types.rule import ( Rule, + RuleAction, RuleCreate, RuleUpdate, RuleVersion, @@ -144,7 +145,7 @@ def _update_rule_request_from_create(self, create: RuleCreate) -> UpdateRuleRequ conditions_request = [ UpdateConditionRequest( expression=expression_proto, - actions=[create.action._to_update_request()], + actions=[cast("RuleAction", create.action)._to_update_request()], ) ] update_request = UpdateRuleRequest( @@ -156,7 +157,7 @@ def _update_rule_request_from_create(self, create: RuleCreate) -> UpdateRuleRequ is_live_evaluation_enabled=create.evaluate_on_live_data, conditions=conditions_request, asset_configuration=RuleAssetConfiguration( - asset_ids=create.asset_ids or [], + asset_ids=cast("list[str]", create.asset_ids or []), tag_ids=create.asset_tag_ids or [], ), contextual_channels=ContextualChannels( @@ -259,7 +260,7 @@ def _update_rule_request_from_update( if "channel_references" in model_dump else rule.channel_references ) or [] - action = update.action if "action" in model_dump else rule.action + action = cast("RuleAction", update.action if "action" in model_dump else rule.action) if bool(expression) != bool(channel_references): raise ValueError( "Expression and channel_references must both be provided or both be None" @@ -296,7 +297,10 @@ def _update_rule_request_from_update( # This always needs to be set, so handle the defaults. update_dict["asset_configuration"] = RuleAssetConfiguration( # type: ignore - asset_ids=(update.asset_ids if "asset_ids" in model_dump else rule.asset_ids or []), + asset_ids=cast( + "list[str]", + update.asset_ids if "asset_ids" in model_dump else rule.asset_ids or [], + ), tag_ids=( update.asset_tag_ids if "asset_tag_ids" in model_dump else rule.asset_tag_ids or [] ), diff --git a/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py new file mode 100644 index 000000000..8153fcd7f --- /dev/null +++ b/python/lib/sift_client/_internal/low_level_wrappers/webhooks.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, cast + +from sift.webhooks.v1.webhooks_pb2 import ( + CreateWebhookResponse, + GetWebhookRequest, + GetWebhookResponse, + ListWebhooksRequest, + ListWebhooksResponse, + TestWebhookRequest, + TestWebhookResponse, + UpdateWebhookRequest, + UpdateWebhookResponse, +) +from sift.webhooks.v1.webhooks_pb2 import Webhook as WebhookProto +from sift.webhooks.v1.webhooks_pb2_grpc import WebhookServiceStub + +from sift_client._internal.low_level_wrappers.base import DEFAULT_PAGE_SIZE, LowLevelClientBase +from sift_client.sift_types.webhook import ( + Webhook, + WebhookCreate, + WebhookTest, + WebhookUpdate, +) +from sift_client.transport import WithGrpcClient + +if TYPE_CHECKING: + from sift_client.transport.grpc_transport import GrpcClient + +# Configure logging +logger = logging.getLogger(__name__) + + +class WebhooksLowLevelClient(LowLevelClientBase, WithGrpcClient): + """Low-level client for the WebhooksAPI. + + A thin wrapper around the autogenerated bindings for the WebhookService. + """ + + def __init__(self, grpc_client: GrpcClient): + """Initialize the WebhooksLowLevelClient. + + Args: + grpc_client: The gRPC client to use for making API calls. + """ + super().__init__(grpc_client) + + async def get_webhook(self, webhook_id: str) -> Webhook: + """Get a webhook by webhook_id. + + Args: + webhook_id: The webhook ID to get. + + Returns: + The Webhook. + """ + return Webhook._from_proto(await self._get_webhook_proto(webhook_id)) + + async def _get_webhook_proto(self, webhook_id: str) -> WebhookProto: + request = GetWebhookRequest(webhook_id=webhook_id) + response = await self._grpc_client.get_stub(WebhookServiceStub).GetWebhook(request) + return cast("GetWebhookResponse", response).webhook + + async def list_webhooks( + self, + *, + page_size: int | None = DEFAULT_PAGE_SIZE, + page_token: str | None = None, + query_filter: str | None = None, + order_by: str | None = None, + ) -> tuple[list[Webhook], str]: + """List webhooks with optional filtering and pagination. + + Args: + page_size: The maximum number of webhooks to return. + page_token: A page token for pagination. + query_filter: A CEL filter string. + order_by: How to order the retrieved webhooks. + + Returns: + A tuple of (webhooks, next_page_token). + """ + request_kwargs: dict[str, Any] = {} + if page_size is not None: + request_kwargs["page_size"] = page_size + if page_token is not None: + request_kwargs["page_token"] = page_token + if query_filter is not None: + request_kwargs["filter"] = query_filter + if order_by is not None: + request_kwargs["order_by"] = order_by + + request = ListWebhooksRequest(**request_kwargs) + response = await self._grpc_client.get_stub(WebhookServiceStub).ListWebhooks(request) + response = cast("ListWebhooksResponse", response) + + webhooks = [Webhook._from_proto(webhook) for webhook in response.webhooks] + return webhooks, response.next_page_token + + async def list_all_webhooks( + self, + *, + query_filter: str | None = None, + order_by: str | None = None, + page_size: int | None = DEFAULT_PAGE_SIZE, + max_results: int | None = None, + ) -> list[Webhook]: + """List all webhooks with optional filtering. + + Args: + query_filter: A CEL filter string. + order_by: How to order the retrieved webhooks. + page_size: The number of results to fetch per request. + max_results: Maximum number of results to return. + + Returns: + A list of all matching webhooks. + """ + return await self._handle_pagination( + self.list_webhooks, + kwargs={"query_filter": query_filter}, + order_by=order_by, + max_results=max_results, + page_size=page_size, + ) + + async def create_webhook(self, *, create: WebhookCreate) -> Webhook: + """Create a webhook. + + Args: + create: The webhook definition. + + Returns: + The created Webhook. + """ + request_proto = create.to_proto() + response = await self._grpc_client.get_stub(WebhookServiceStub).CreateWebhook(request_proto) + grpc_webhook = cast("CreateWebhookResponse", response).webhook + return Webhook._from_proto(grpc_webhook) + + async def update_webhook(self, update: WebhookUpdate) -> Webhook: + """Update select fields of a webhook. + + Reads the webhook first and merges the patch over it. UpdateWebhook runs + protovalidate across the whole `Webhook` message and ignores the update mask, so + a sparse patch fails with `webhook.organization_id: value must be a valid UUID` + and three similar errors. The cost is a round trip, and concurrent updates + overwrite each other. Remove this once the service validates only masked fields. + + Args: + update: The updates to apply. Its `resource_id` must be set. + + Returns: + The updated Webhook. + """ + patch, update_mask = update.to_proto_with_mask() + grpc_webhook = await self._get_webhook_proto(patch.webhook_id) + update_mask.MergeMessage(patch, grpc_webhook, replace_repeated_field=True) + request = UpdateWebhookRequest(webhook=grpc_webhook, update_mask=update_mask) + response = await self._grpc_client.get_stub(WebhookServiceStub).UpdateWebhook(request) + updated_grpc_webhook = cast("UpdateWebhookResponse", response).webhook + return Webhook._from_proto(updated_grpc_webhook) + + async def test_webhook( + self, + *, + webhook_id: str | None = None, + create: WebhookCreate | None = None, + ) -> WebhookTest: + """Send a real request to a webhook's target URL. Pass exactly one identifier. + + Args: + webhook_id: The ID of an existing webhook to test. + create: An unsaved webhook definition to test. + + Returns: + The response the target URL returned. + + Raises: + ValueError: If neither or both arguments are provided. + """ + if (webhook_id is None) == (create is None): + raise ValueError("Exactly one of webhook_id or create must be provided") + + if webhook_id is not None: + request = TestWebhookRequest(webhook_id=webhook_id) + else: + request = TestWebhookRequest(create_request=cast("WebhookCreate", create).to_proto()) + + response = await self._grpc_client.get_stub(WebhookServiceStub).TestWebhook(request) + response = cast("TestWebhookResponse", response) + return WebhookTest( + http_response_code=response.http_response_code, + http_response_body=response.http_response_body, + ) diff --git a/python/lib/sift_client/_tests/conftest.py b/python/lib/sift_client/_tests/conftest.py index 2fd3d35f3..c1054ecc6 100644 --- a/python/lib/sift_client/_tests/conftest.py +++ b/python/lib/sift_client/_tests/conftest.py @@ -95,6 +95,7 @@ def mock_client(): client.data_import = MagicMock() client.data_export = MagicMock() client.user_defined_functions = MagicMock() + client.webhooks = MagicMock() client.async_ = MagicMock(spec=AsyncAPIs) client.async_.ingestion = MagicMock() return client diff --git a/python/lib/sift_client/_tests/resources/test_webhooks.py b/python/lib/sift_client/_tests/resources/test_webhooks.py new file mode 100644 index 000000000..1a5fe7a4a --- /dev/null +++ b/python/lib/sift_client/_tests/resources/test_webhooks.py @@ -0,0 +1,229 @@ +"""Pytest tests for the Webhooks API. + +These tests demonstrate and validate the usage of the Webhooks API including: +- Basic webhook operations (get, list, find) +- Webhook filtering and searching +- Webhook creation, updates, and archiving +- Error handling and edge cases + +Note that these tests never call `test()`, because that RPC sends a real HTTP +request to the webhook's target URL. +""" + +from datetime import datetime, timezone + +import pytest + +from sift_client import SiftClient +from sift_client.resources import WebhooksAPI, WebhooksAPIAsync +from sift_client.sift_types import Webhook +from sift_client.sift_types.webhook import ( + WebhookCreate, + WebhookEventType, + WebhookHttpHeader, + WebhookUpdate, +) + +pytestmark = pytest.mark.integration + +TEST_TARGET_URL = "https://example.com/sift-client-pytest" + + +def test_client_binding(sift_client): + assert sift_client.webhooks + assert isinstance(sift_client.webhooks, WebhooksAPI) + assert sift_client.async_.webhooks + assert isinstance(sift_client.async_.webhooks, WebhooksAPIAsync) + + +@pytest.fixture +def webhooks_api_async(sift_client: SiftClient): + """Get the async webhooks API instance.""" + return sift_client.async_.webhooks + + +@pytest.fixture +def webhooks_api_sync(sift_client: SiftClient): + """Get the synchronous webhooks API instance.""" + return sift_client.webhooks + + +@pytest.fixture(scope="session") +def test_timestamp_str(): + """A per-session suffix so webhook names stay unique across runs.""" + return datetime.now(timezone.utc).isoformat() + + +@pytest.fixture(scope="session") +def new_webhook(sift_client, test_timestamp_str): + """Create a webhook for the session and archive it on teardown.""" + created = sift_client.webhooks.create( + WebhookCreate( + name=f"test_webhook_{test_timestamp_str}", + target_url=TEST_TARGET_URL, + http_headers={"X-Sift-Client-Pytest": "true"}, + ) + ) + yield created + sift_client.webhooks.archive(created) + + +class TestWebhooks: + """Tests for the Webhooks API.""" + + def test_create(self, new_webhook, test_timestamp_str): + """Test that create returns a fully populated webhook.""" + assert isinstance(new_webhook, Webhook) + assert new_webhook.id_ is not None + assert new_webhook.name == f"test_webhook_{test_timestamp_str}" + assert new_webhook.target_url == TEST_TARGET_URL + assert new_webhook.event_type == WebhookEventType.RULE_VIOLATION + assert new_webhook.http_headers == [ + WebhookHttpHeader(name="X-Sift-Client-Pytest", value="true") + ] + assert new_webhook.is_archived is False + assert new_webhook.organization_id + + def test_get(self, webhooks_api_sync, new_webhook): + """Test getting a webhook by ID.""" + fetched = webhooks_api_sync.get(new_webhook._id_or_error) + + assert isinstance(fetched, Webhook) + assert fetched.id_ == new_webhook.id_ + assert fetched.name == new_webhook.name + + def test_basic_list(self, webhooks_api_sync, new_webhook): + """Test basic webhook listing functionality.""" + webhooks = webhooks_api_sync.list_(limit=5) + + assert isinstance(webhooks, list) + assert len(webhooks) >= 1 + for webhook in webhooks: + assert isinstance(webhook, Webhook) + assert webhook.id_ is not None + + def test_list_with_name_filter(self, webhooks_api_sync, new_webhook): + """Test webhook listing with name filtering.""" + by_name = webhooks_api_sync.list_(name=new_webhook.name) + by_contains = webhooks_api_sync.list_(name_contains=new_webhook.name) + + assert len(by_name) == 1 + assert by_name[0].id_ == new_webhook.id_ + assert by_contains[0].id_ == new_webhook.id_ + + def test_list_with_id_filter(self, webhooks_api_sync, new_webhook): + """Test webhook listing filtered to specific IDs.""" + webhooks = webhooks_api_sync.list_(webhook_ids=[new_webhook._id_or_error]) + + assert len(webhooks) == 1 + assert webhooks[0].id_ == new_webhook.id_ + + def test_list_with_event_type_filter(self, webhooks_api_sync, new_webhook): + """Test webhook listing filtered by event type.""" + webhooks = webhooks_api_sync.list_( + webhook_ids=[new_webhook._id_or_error], + event_type=WebhookEventType.RULE_VIOLATION, + ) + + assert len(webhooks) == 1 + assert webhooks[0].id_ == new_webhook.id_ + + def test_find(self, webhooks_api_sync, new_webhook): + """Test finding a single webhook.""" + found = webhooks_api_sync.find(name=new_webhook.name) + + assert found is not None + assert found.id_ == new_webhook.id_ + + def test_find_nonexistent(self, webhooks_api_sync): + """Test finding a non-existent webhook returns None.""" + found = webhooks_api_sync.find( + name=f"nonexistent_webhook_{datetime.now(timezone.utc).timestamp()}" + ) + assert found is None + + def test_update(self, webhooks_api_sync, new_webhook): + """Test updating a webhook's target URL and headers.""" + updated_url = "https://example.com/sift-client-pytest-updated" + updated = webhooks_api_sync.update( + new_webhook, + WebhookUpdate( + target_url=updated_url, + http_headers={"X-Sift-Client-Pytest": "updated"}, + ), + ) + + assert updated.id_ == new_webhook.id_ + assert updated.target_url == updated_url + assert updated.http_headers == [ + WebhookHttpHeader(name="X-Sift-Client-Pytest", value="updated") + ] + # The name was not in the mask, so it is unchanged. + assert updated.name == new_webhook.name + + # Restore the original target so later tests see a stable fixture. + webhooks_api_sync.update(new_webhook, {"target_url": TEST_TARGET_URL}) + + def test_update_accepts_dict(self, webhooks_api_sync, new_webhook, test_timestamp_str): + """Test that update accepts a plain dict.""" + renamed = f"test_webhook_renamed_{test_timestamp_str}" + updated = webhooks_api_sync.update(new_webhook._id_or_error, {"name": renamed}) + + assert updated.name == renamed + + webhooks_api_sync.update(new_webhook, {"name": new_webhook.name}) + + def test_archive_and_unarchive(self, webhooks_api_sync, test_timestamp_str): + """Test archiving and unarchiving a webhook.""" + webhook = webhooks_api_sync.create( + WebhookCreate( + name=f"test_webhook_archive_{test_timestamp_str}", + target_url=TEST_TARGET_URL, + ) + ) + + archived = webhooks_api_sync.archive(webhook) + assert archived.is_archived is True + + # Archived webhooks are excluded from list_ by default. + assert webhooks_api_sync.find(name=webhook.name) is None + assert webhooks_api_sync.find(name=webhook.name, include_archived=True).id_ == webhook.id_ + + unarchived = webhooks_api_sync.unarchive(webhook) + assert unarchived.is_archived is False + + webhooks_api_sync.archive(webhook) + + def test_instance_methods(self, webhooks_api_sync, test_timestamp_str): + """Test the update and archive methods on the Webhook instance itself.""" + webhook = webhooks_api_sync.create( + WebhookCreate( + name=f"test_webhook_instance_{test_timestamp_str}", + target_url=TEST_TARGET_URL, + ) + ) + + webhook.update({"payload": "custom-payload"}) + assert webhook.payload == "custom-payload" + + webhook.archive() + assert webhook.is_archived is True + + def test_send_test_request_requires_exactly_one_argument(self, webhooks_api_sync, new_webhook): + """Test that send_test_request() rejects ambiguous arguments before any request.""" + with pytest.raises(ValueError, match="Exactly one of webhook or create"): + webhooks_api_sync.send_test_request() + + with pytest.raises(ValueError, match="Exactly one of webhook or create"): + webhooks_api_sync.send_test_request( + webhook=new_webhook, + create=WebhookCreate(name="ignored", target_url=TEST_TARGET_URL), + ) + + @pytest.mark.asyncio + async def test_async_list(self, webhooks_api_async, new_webhook): + """Test the async API returns the same webhooks.""" + webhooks = await webhooks_api_async.list_(webhook_ids=[new_webhook._id_or_error]) + + assert len(webhooks) == 1 + assert webhooks[0].id_ == new_webhook.id_ diff --git a/python/lib/sift_client/_tests/sift_types/test_rule.py b/python/lib/sift_client/_tests/sift_types/test_rule.py index f91443c6a..9dda2d4db 100644 --- a/python/lib/sift_client/_tests/sift_types/test_rule.py +++ b/python/lib/sift_client/_tests/sift_types/test_rule.py @@ -220,3 +220,72 @@ def test_from_proto_reads_version_id_when_present(self): ref = rule.channel_references[0] assert ref.calculated_channel == "v-xyz" assert ref.channel_identifier is None + + +class TestRuleActionWebhook: + """Unit tests for the webhook rule action.""" + + WEBHOOK_ID = "3bc0c34b-5c63-446d-845c-2b319b7b1a78" + + def test_from_webhook_id(self): + """Test building the action from a webhook ID string.""" + action = RuleAction.webhook(self.WEBHOOK_ID) + + assert action.action_type == RuleActionType.WEBHOOK + assert action.webhook_id == self.WEBHOOK_ID + assert action._to_update_request().configuration.webhook.webhook_id == self.WEBHOOK_ID + + def test_from_webhook_object(self): + """Test building the action from a Webhook instance.""" + from sift.webhooks.v1.webhooks_pb2 import Webhook as WebhookProto + + from sift_client.sift_types.webhook import Webhook + + webhook = Webhook._from_proto(WebhookProto(webhook_id=self.WEBHOOK_ID)) + + action = RuleAction.webhook(webhook) + + assert action.webhook_id is webhook + assert action._to_update_request().configuration.webhook.webhook_id == self.WEBHOOK_ID + + def test_rejects_non_uuid(self): + """Test that a malformed webhook ID is caught before it reaches the server.""" + with pytest.raises(ValueError, match="badly formed hexadecimal UUID string"): + RuleAction.webhook("not-a-uuid") + + def test_to_update_request_sets_webhook_configuration(self): + """Test the action serializes into the webhook branch of the oneof.""" + request = RuleAction.webhook(self.WEBHOOK_ID)._to_update_request() + + assert request.action_type == RuleActionType.WEBHOOK.value + assert request.configuration.webhook.webhook_id == self.WEBHOOK_ID + assert not request.configuration.annotation.tag_ids + + def test_annotation_action_sets_no_webhook(self): + """Test the annotation action is unaffected by the webhook branch.""" + request = RuleAction.annotation( + annotation_type=RuleAnnotationType.DATA_REVIEW, tags=[] + )._to_update_request() + + assert request.configuration.webhook.webhook_id == "" + + def test_from_proto_reads_webhook_id(self): + """Test the webhook ID round trips back out of the proto.""" + from sift.rules.v1.rules_pb2 import ( + ActionKind, + RuleActionConfiguration, + WebhookActionConfiguration, + ) + from sift.rules.v1.rules_pb2 import RuleAction as RuleActionProto + + proto = RuleActionProto( + action_type=ActionKind.WEBHOOK, + configuration=RuleActionConfiguration( + webhook=WebhookActionConfiguration(webhook_id=self.WEBHOOK_ID) + ), + ) + action = RuleAction._from_proto(proto) + + assert action.action_type == RuleActionType.WEBHOOK + assert action.webhook_id == self.WEBHOOK_ID + assert action.annotation_type is None diff --git a/python/lib/sift_client/_tests/sift_types/test_webhook.py b/python/lib/sift_client/_tests/sift_types/test_webhook.py new file mode 100644 index 000000000..399992f14 --- /dev/null +++ b/python/lib/sift_client/_tests/sift_types/test_webhook.py @@ -0,0 +1,222 @@ +"""Tests for sift_types.Webhook model.""" + +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +from sift_client.sift_types import Webhook +from sift_client.sift_types.webhook import ( + WebhookCreate, + WebhookEventType, + WebhookHttpHeader, + WebhookUpdate, +) + + +class TestWebhookEventType: + """Unit tests for the WebhookEventType enum.""" + + def test_to_filter_str(self): + """Test the CEL filter representation matches the proto enum value name.""" + assert WebhookEventType.RULE_VIOLATION.to_filter_str() == ( + "WEBHOOK_EVENT_TYPE_RULE_VIOLATION" + ) + + +class TestWebhookCreate: + """Unit tests for WebhookCreate model - tests _to_proto_helpers and validators.""" + + def test_minimal_create(self): + """Test a create with only required fields sets a concrete event type.""" + create = WebhookCreate(name="test_webhook", target_url="https://example.com/hook") + proto = create.to_proto() + + assert proto.name == "test_webhook" + assert proto.target_url == "https://example.com/hook" + # Left unset, the proto would default to UNSPECIFIED, which the server rejects. + assert proto.event_type == WebhookEventType.RULE_VIOLATION.value + + def test_http_headers_list_converter(self): + """Test that a list of headers is converted using _to_proto_helpers.""" + create = WebhookCreate( + name="test_webhook", + target_url="https://example.com/hook", + http_headers=[WebhookHttpHeader(name="X-Token", value="secret")], + ) + proto = create.to_proto() + + assert len(proto.http_headers) == 1 + assert proto.http_headers[0].name == "X-Token" + assert proto.http_headers[0].value == "secret" + + def test_http_headers_dict_is_normalized(self): + """Test that a name-to-value mapping is accepted in place of a list.""" + create = WebhookCreate( + name="test_webhook", + target_url="https://example.com/hook", + http_headers={"X-Token": "secret", "X-Env": "staging"}, + ) + + assert create.http_headers == [ + WebhookHttpHeader(name="X-Token", value="secret"), + WebhookHttpHeader(name="X-Env", value="staging"), + ] + + proto = create.to_proto() + assert {header.name: header.value for header in proto.http_headers} == { + "X-Token": "secret", + "X-Env": "staging", + } + + def test_explicit_event_type_is_preserved(self): + """Test that an explicitly set event type is not overwritten by the default.""" + create = WebhookCreate( + name="test_webhook", + target_url="https://example.com/hook", + event_type=WebhookEventType.RULE_VIOLATION, + ) + assert create.event_type == WebhookEventType.RULE_VIOLATION + + def test_payload_is_optional(self): + """Test that payload is omitted from the proto when not provided.""" + create = WebhookCreate(name="test_webhook", target_url="https://example.com/hook") + proto = create.to_proto() + + assert not proto.HasField("payload") + + @pytest.mark.parametrize("target_url", ["example.com/hook", "ftp://example.com", ""]) + def test_rejects_non_http_target_url(self, target_url): + """Test the target URL validator rejects URLs that are not absolute HTTP(S).""" + with pytest.raises(ValueError, match="target_url must start with"): + WebhookCreate(name="test_webhook", target_url=target_url) + + +class TestWebhookUpdate: + """Unit tests for WebhookUpdate model - tests field masks and validators.""" + + def test_update_mask_only_includes_set_fields(self): + """Test that the field mask covers exactly the fields that were set.""" + update = WebhookUpdate(name="renamed", target_url="https://example.com/v2") + update.resource_id = "test_webhook_id" + + proto, mask = update.to_proto_with_mask() + + assert proto.webhook_id == "test_webhook_id" + assert proto.name == "renamed" + assert proto.target_url == "https://example.com/v2" + assert set(mask.paths) == {"name", "target_url"} + + def test_http_headers_converter(self): + """Test that headers are converted and named in the field mask.""" + update = WebhookUpdate(http_headers={"X-Token": "rotated"}) + update.resource_id = "test_webhook_id" + + proto, mask = update.to_proto_with_mask() + + assert len(proto.http_headers) == 1 + assert proto.http_headers[0].name == "X-Token" + assert proto.http_headers[0].value == "rotated" + assert "http_headers" in mask.paths + + def test_archive_update(self): + """Test that is_archived is masked so archive() takes effect.""" + update = WebhookUpdate(is_archived=True) + update.resource_id = "test_webhook_id" + + proto, mask = update.to_proto_with_mask() + + assert proto.is_archived is True + assert mask.paths == ["is_archived"] + + def test_requires_resource_id(self): + """Test that converting without a resource ID raises.""" + update = WebhookUpdate(name="renamed") + + with pytest.raises(ValueError, match="Resource ID must be set"): + update.to_proto_with_mask() + + def test_rejects_non_http_target_url(self): + """Test the target URL validator also applies to updates.""" + with pytest.raises(ValueError, match="target_url must start with"): + WebhookUpdate(target_url="example.com/hook") + + +@pytest.fixture +def mock_webhook(mock_client): + """Create a mock Webhook instance for testing.""" + webhook = Webhook( + proto=MagicMock(), + id_="test_webhook_id", + name="test_webhook", + organization_id="org1", + target_url="https://example.com/hook", + event_type=WebhookEventType.RULE_VIOLATION, + http_headers=[WebhookHttpHeader(name="X-Token", value="secret")], + created_date=datetime.now(timezone.utc), + modified_date=datetime.now(timezone.utc), + created_by_user_id="user1", + modified_by_user_id="user1", + is_archived=False, + payload=None, + archived_date=None, + ) + webhook._apply_client_to_instance(mock_client) + return webhook + + +class TestWebhook: + """Unit tests for Webhook model - tests methods.""" + + def test_update_calls_client_and_updates_self(self, mock_webhook, mock_client): + """Test that update() calls client.webhooks.update and calls _update.""" + updated_webhook = MagicMock() + mock_client.webhooks.update.return_value = updated_webhook + + with MagicMock() as mock_update: + mock_webhook._update = mock_update + + update = WebhookUpdate(name="renamed") + result = mock_webhook.update(update) + + mock_client.webhooks.update.assert_called_once_with(webhook=mock_webhook, update=update) + mock_update.assert_called_once_with(updated_webhook) + assert result is mock_webhook + + def test_archive_calls_client_and_updates_self(self, mock_webhook, mock_client): + """Test that archive() calls client.webhooks.archive and calls _update.""" + archived_webhook = MagicMock() + mock_client.webhooks.archive.return_value = archived_webhook + + with MagicMock() as mock_update: + mock_webhook._update = mock_update + + result = mock_webhook.archive() + + mock_client.webhooks.archive.assert_called_once_with(webhook=mock_webhook) + mock_update.assert_called_once_with(archived_webhook) + assert result is mock_webhook + + def test_unarchive_calls_client_and_updates_self(self, mock_webhook, mock_client): + """Test that unarchive() calls client.webhooks.unarchive and calls _update.""" + unarchived_webhook = MagicMock() + mock_client.webhooks.unarchive.return_value = unarchived_webhook + + with MagicMock() as mock_update: + mock_webhook._update = mock_update + + result = mock_webhook.unarchive() + + mock_client.webhooks.unarchive.assert_called_once_with(webhook=mock_webhook) + mock_update.assert_called_once_with(unarchived_webhook) + assert result is mock_webhook + + def test_test_calls_client(self, mock_webhook, mock_client): + """Test that test() delegates to client.webhooks.send_test_request.""" + expected = MagicMock() + mock_client.webhooks.send_test_request.return_value = expected + + result = mock_webhook.send_test_request() + + mock_client.webhooks.send_test_request.assert_called_once_with(webhook=mock_webhook) + assert result is expected diff --git a/python/lib/sift_client/client.py b/python/lib/sift_client/client.py index ae5b2a283..dafb2cd3e 100644 --- a/python/lib/sift_client/client.py +++ b/python/lib/sift_client/client.py @@ -48,6 +48,8 @@ UserDefinedFunctionsAPIAsync, UsersAPI, UsersAPIAsync, + WebhooksAPI, + WebhooksAPIAsync, ) from sift_client.resources.access_control import AccessControlAPI, AccessControlAPIAsync from sift_client.transport import ( @@ -155,6 +157,9 @@ class SiftClient( users: UsersAPI """Instance of the Users API for making synchronous requests.""" + webhooks: WebhooksAPI + """Instance of the Webhooks API for making synchronous requests.""" + data_export: DataExportAPI """Instance of the Data Export API for making synchronous requests.""" @@ -267,6 +272,7 @@ def __init__( self.test_results = TestResultsAPI(self) self.user_defined_functions = UserDefinedFunctionsAPI(self) self.users = UsersAPI(self) + self.webhooks = WebhooksAPI(self) self.data_export = DataExportAPI(self) self.data_import = DataImportAPI(self) @@ -292,6 +298,7 @@ def __init__( test_results=TestResultsAPIAsync(self), user_defined_functions=UserDefinedFunctionsAPIAsync(self), users=UsersAPIAsync(self), + webhooks=WebhooksAPIAsync(self), data_export=DataExportAPIAsync(self), data_import=DataImportAPIAsync(self), ) diff --git a/python/lib/sift_client/resources/__init__.py b/python/lib/sift_client/resources/__init__.py index 0e8c0e1bf..65bb284fa 100644 --- a/python/lib/sift_client/resources/__init__.py +++ b/python/lib/sift_client/resources/__init__.py @@ -187,6 +187,7 @@ async def main(): UserDefinedFunctionVersionsAPIAsync, ) from sift_client.resources.users import UsersAPIAsync +from sift_client.resources.webhooks import WebhooksAPIAsync from sift_client.resources.data_imports import DataImportAPIAsync from sift_client.resources.exports import DataExportAPIAsync @@ -217,6 +218,7 @@ async def main(): UserDefinedFunctionsAPI, UserDefinedFunctionVersionsAPI, UsersAPI, + WebhooksAPI, FileAttachmentsAPI, DataExportAPI, DataImportAPI, @@ -288,6 +290,8 @@ async def main(): "UserDefinedFunctionsAPIAsync", "UsersAPI", "UsersAPIAsync", + "WebhooksAPI", + "WebhooksAPIAsync", "DataExportAPI", "DataExportAPIAsync", "DataImportAPI", diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.py b/python/lib/sift_client/resources/sync_stubs/__init__.py index 9f1b9240b..00c204e9f 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.py +++ b/python/lib/sift_client/resources/sync_stubs/__init__.py @@ -32,6 +32,7 @@ UserDefinedFunctionsAPIAsync, UserDefinedFunctionVersionsAPIAsync, UsersAPIAsync, + WebhooksAPIAsync, ) PingAPI = generate_sync_api(PingAPIAsync, "PingAPI") @@ -103,6 +104,7 @@ nested_resources={"versions": UserDefinedFunctionVersionsAPI}, ) UsersAPI = generate_sync_api(UsersAPIAsync, "UsersAPI") +WebhooksAPI = generate_sync_api(WebhooksAPIAsync, "WebhooksAPI") DataExportAPI = generate_sync_api(DataExportAPIAsync, "DataExportAPI") DataImportAPI = generate_sync_api(DataImportAPIAsync, "DataImportAPI") @@ -135,4 +137,5 @@ "UserDefinedFunctionVersionsAPI", "UserDefinedFunctionsAPI", "UsersAPI", + "WebhooksAPI", ] diff --git a/python/lib/sift_client/resources/sync_stubs/__init__.pyi b/python/lib/sift_client/resources/sync_stubs/__init__.pyi index 2ce2e65ee..792947a47 100644 --- a/python/lib/sift_client/resources/sync_stubs/__init__.pyi +++ b/python/lib/sift_client/resources/sync_stubs/__init__.pyi @@ -111,6 +111,13 @@ if TYPE_CHECKING: UserDefinedFunctionValidation, UserDefinedFunctionVersion, ) + from sift_client.sift_types.webhook import ( + Webhook, + WebhookCreate, + WebhookEventType, + WebhookTest, + WebhookUpdate, + ) class AnnotationLogsAPI: """Sync counterpart to `AnnotationLogsAPIAsync`. @@ -4325,3 +4332,148 @@ class UsersAPI: ValueError: If an email matches multiple users case-insensitively. """ ... + +class WebhooksAPI: + """Sync counterpart to `WebhooksAPIAsync`. + + High-level API for interacting with webhooks. + + A webhook registers an HTTP endpoint that Sift calls when a rule is violated. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the WebhooksAPI. + + Args: + sift_client: The Sift client to use. + """ + ... + + def _run(self, coro): ... + def archive(self, webhook: str | Webhook) -> Webhook: + """Archive a webhook. Archived webhooks stop receiving events. + + Args: + webhook: The Webhook or webhook ID to archive. + + Returns: + The archived Webhook. + """ + ... + + def create(self, create: WebhookCreate | dict) -> Webhook: + """Create a new webhook. + + Args: + create: The webhook definition. `http_headers` accepts either a list of + WebhookHttpHeader or a `{name: value}` mapping. + + Returns: + The created Webhook. + """ + ... + + def find(self, **kwargs) -> Webhook | None: + """Find a single webhook matching the given query. Takes the same arguments as `list_`. + If more than one webhook is found, raises an error. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The Webhook found or None. + """ + ... + + def get(self, webhook_id: str) -> Webhook: + """Get a Webhook. + + Args: + webhook_id: The ID of the webhook. + + Returns: + The Webhook. + """ + ... + + def list_( + self, + *, + name: str | None = None, + names: list[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + webhook_ids: list[str] | None = None, + event_type: WebhookEventType | None = None, + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[Webhook]: + """List webhooks with optional filtering. + + Args: + name: Exact name of the webhook. + names: List of webhook names to filter by. + name_contains: Partial name of the webhook. + name_regex: Regular expression to filter webhooks by name. + webhook_ids: Filter to webhooks with any of these IDs. + event_type: Filter to webhooks triggered by this event type. + include_archived: If True, include archived webhooks in results. + filter_query: Explicit CEL query to filter webhooks. + order_by: Field and direction to order results by. Only `created_date` is + supported, e.g. "created_date desc". + limit: Maximum number of webhooks to return. If None, returns all matches. + page_size: Number of results to fetch per request. Lower this if you hit gRPC + message size limits on responses. If None, uses the server default. + + Returns: + A list of Webhook objects that match the filter criteria. + """ + ... + + def send_test_request( + self, *, webhook: str | Webhook | None = None, create: WebhookCreate | dict | None = None + ) -> WebhookTest: + """Send a real request to a webhook's target URL and return its response. + + Pass exactly one of `webhook` or `create`. Use `create` to check an endpoint + before saving it. + + Args: + webhook: The Webhook or webhook ID to test. + create: An unsaved webhook definition to test. + + Returns: + The response the target URL returned. + + Raises: + ValueError: If neither or both arguments are provided. + """ + ... + + def unarchive(self, webhook: str | Webhook) -> Webhook: + """Unarchive a webhook. + + Args: + webhook: The Webhook or webhook ID to unarchive. + + Returns: + The unarchived Webhook. + """ + ... + + def update(self, webhook: str | Webhook, update: WebhookUpdate | dict) -> Webhook: + """Update a Webhook. + + Note that `http_headers` is replaced wholesale, not merged. + + Args: + webhook: The Webhook or webhook ID to update. + update: Updates to apply to the Webhook. + + Returns: + The updated Webhook. + """ + ... diff --git a/python/lib/sift_client/resources/webhooks.py b/python/lib/sift_client/resources/webhooks.py new file mode 100644 index 000000000..342ed0512 --- /dev/null +++ b/python/lib/sift_client/resources/webhooks.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sift_client._internal.low_level_wrappers.webhooks import WebhooksLowLevelClient +from sift_client.resources._base import ResourceBase +from sift_client.sift_types.webhook import ( + Webhook, + WebhookCreate, + WebhookEventType, + WebhookTest, + WebhookUpdate, +) +from sift_client.util import cel_utils as cel + +if TYPE_CHECKING: + import re + + from sift_client.client import SiftClient + + +class WebhooksAPIAsync(ResourceBase): + """High-level API for interacting with webhooks. + + A webhook registers an HTTP endpoint that Sift calls when a rule is violated. + """ + + def __init__(self, sift_client: SiftClient): + """Initialize the WebhooksAPI. + + Args: + sift_client: The Sift client to use. + """ + super().__init__(sift_client) + self._low_level_client = WebhooksLowLevelClient(grpc_client=self.client.grpc_client) + + async def get(self, webhook_id: str) -> Webhook: + """Get a Webhook. + + Args: + webhook_id: The ID of the webhook. + + Returns: + The Webhook. + """ + webhook = await self._low_level_client.get_webhook(webhook_id=webhook_id) + return self._apply_client_to_instance(webhook) + + async def list_( + self, + *, + name: str | None = None, + names: list[str] | None = None, + name_contains: str | None = None, + name_regex: str | re.Pattern | None = None, + # self ids + webhook_ids: list[str] | None = None, + # webhook specific + event_type: WebhookEventType | None = None, + # common filters + include_archived: bool = False, + filter_query: str | None = None, + order_by: str | None = None, + limit: int | None = None, + page_size: int | None = None, + ) -> list[Webhook]: + """List webhooks with optional filtering. + + Args: + name: Exact name of the webhook. + names: List of webhook names to filter by. + name_contains: Partial name of the webhook. + name_regex: Regular expression to filter webhooks by name. + webhook_ids: Filter to webhooks with any of these IDs. + event_type: Filter to webhooks triggered by this event type. + include_archived: If True, include archived webhooks in results. + filter_query: Explicit CEL query to filter webhooks. + order_by: Field and direction to order results by. Only `created_date` is + supported, e.g. "created_date desc". + limit: Maximum number of webhooks to return. If None, returns all matches. + page_size: Number of results to fetch per request. Lower this if you hit gRPC + message size limits on responses. If None, uses the server default. + + Returns: + A list of Webhook objects that match the filter criteria. + """ + filter_parts = [ + *self._build_name_cel_filters( + name=name, names=names, name_contains=name_contains, name_regex=name_regex + ), + *self._build_common_cel_filters( + include_archived=include_archived, + filter_query=filter_query, + ), + ] + if webhook_ids: + filter_parts.append(cel.in_("webhook_id", webhook_ids)) + if event_type: + filter_parts.append(cel.equals("event_type", event_type.to_filter_str())) + query_filter = cel.and_(*filter_parts) + + webhooks = await self._low_level_client.list_all_webhooks( + query_filter=query_filter or None, + order_by=order_by, + max_results=limit, + **({"page_size": page_size} if page_size is not None else {}), + ) + return self._apply_client_to_instances(webhooks) + + async def find(self, **kwargs) -> Webhook | None: + """Find a single webhook matching the given query. Takes the same arguments as `list_`. + If more than one webhook is found, raises an error. + + Args: + **kwargs: Keyword arguments to pass to `list_`. + + Returns: + The Webhook found or None. + """ + webhooks = await self.list_(**kwargs) + if len(webhooks) > 1: + raise ValueError(f"Multiple ({len(webhooks)}) webhooks found for query") + elif len(webhooks) == 1: + return webhooks[0] + return None + + async def create(self, create: WebhookCreate | dict) -> Webhook: + """Create a new webhook. + + Args: + create: The webhook definition. `http_headers` accepts either a list of + WebhookHttpHeader or a `{name: value}` mapping. + + Returns: + The created Webhook. + """ + if isinstance(create, dict): + create = WebhookCreate.model_validate(create) + created_webhook = await self._low_level_client.create_webhook(create=create) + return self._apply_client_to_instance(created_webhook) + + async def update(self, webhook: str | Webhook, update: WebhookUpdate | dict) -> Webhook: + """Update a Webhook. + + Note that `http_headers` is replaced wholesale, not merged. + + Args: + webhook: The Webhook or webhook ID to update. + update: Updates to apply to the Webhook. + + Returns: + The updated Webhook. + """ + webhook_id = webhook._id_or_error if isinstance(webhook, Webhook) else webhook + if isinstance(update, dict): + update = WebhookUpdate.model_validate(update) + update.resource_id = webhook_id + updated_webhook = await self._low_level_client.update_webhook(update) + return self._apply_client_to_instance(updated_webhook) + + async def archive(self, webhook: str | Webhook) -> Webhook: + """Archive a webhook. Archived webhooks stop receiving events. + + Args: + webhook: The Webhook or webhook ID to archive. + + Returns: + The archived Webhook. + """ + return await self.update(webhook, WebhookUpdate(is_archived=True)) + + async def unarchive(self, webhook: str | Webhook) -> Webhook: + """Unarchive a webhook. + + Args: + webhook: The Webhook or webhook ID to unarchive. + + Returns: + The unarchived Webhook. + """ + return await self.update(webhook, WebhookUpdate(is_archived=False)) + + async def send_test_request( + self, + *, + webhook: str | Webhook | None = None, + create: WebhookCreate | dict | None = None, + ) -> WebhookTest: + """Send a real request to a webhook's target URL and return its response. + + Pass exactly one of `webhook` or `create`. Use `create` to check an endpoint + before saving it. + + Args: + webhook: The Webhook or webhook ID to test. + create: An unsaved webhook definition to test. + + Returns: + The response the target URL returned. + + Raises: + ValueError: If neither or both arguments are provided. + """ + if (webhook is None) == (create is None): + raise ValueError("Exactly one of webhook or create must be provided") + + if webhook is not None: + webhook_id = webhook._id_or_error if isinstance(webhook, Webhook) else webhook + return await self._low_level_client.test_webhook(webhook_id=webhook_id) + + if isinstance(create, dict): + create = WebhookCreate.model_validate(create) + return await self._low_level_client.test_webhook(create=create) diff --git a/python/lib/sift_client/sift_types/__init__.py b/python/lib/sift_client/sift_types/__init__.py index a6d4f647e..4cc53e68f 100644 --- a/python/lib/sift_client/sift_types/__init__.py +++ b/python/lib/sift_client/sift_types/__init__.py @@ -247,6 +247,14 @@ UserDefinedFunctionValidation, UserDefinedFunctionVersion, ) +from sift_client.sift_types.webhook import ( + Webhook, + WebhookCreate, + WebhookEventType, + WebhookHttpHeader, + WebhookTest, + WebhookUpdate, +) if "pytest" in sys.modules: # These are not test classes, so we need to set __test__ to False to avoid pytest warnings. @@ -365,4 +373,10 @@ "UserDefinedFunctionValidation", "UserDefinedFunctionVersion", "UserOrganization", + "Webhook", + "WebhookCreate", + "WebhookEventType", + "WebhookHttpHeader", + "WebhookTest", + "WebhookUpdate", ] diff --git a/python/lib/sift_client/sift_types/rule.py b/python/lib/sift_client/sift_types/rule.py index 5112b964c..13dfd8f31 100644 --- a/python/lib/sift_client/sift_types/rule.py +++ b/python/lib/sift_client/sift_types/rule.py @@ -5,12 +5,14 @@ from typing import TYPE_CHECKING from uuid import UUID +from pydantic import field_validator from sift.rules.v1.rules_pb2 import ( ActionKind, AnnotationActionConfiguration, CalculatedChannelConfig, RuleActionConfiguration, UpdateActionRequest, + WebhookActionConfiguration, ) # Extract nested class. @@ -31,12 +33,13 @@ ) from sift_client.sift_types._base import BaseType, ModelCreate, ModelCreateUpdateBase, ModelUpdate +from sift_client.sift_types.asset import Asset from sift_client.sift_types.channel import ChannelReference from sift_client.sift_types.tag import Tag +from sift_client.sift_types.webhook import Webhook if TYPE_CHECKING: from sift_client.client import SiftClient - from sift_client.sift_types.asset import Asset class Rule(BaseType[RuleProto, "Rule"]): @@ -164,12 +167,22 @@ def _from_proto(cls, proto: RuleProto, sift_client: SiftClient | None = None) -> ) +def _wrap_webhook_action(value): + """A webhook action carries nothing else, so a bare Webhook is enough.""" + return RuleAction.webhook(value) if isinstance(value, Webhook) else value + + class RuleCreateUpdateBase(ModelCreateUpdateBase): """Base class for Rule create and update models with shared fields and validation.""" + @field_validator("asset_ids", mode="after") + @classmethod + def _asset_ids_to_strings(cls, value): + return [a._id_or_error if isinstance(a, Asset) else a for a in value] if value else value + organization_id: str | None = None client_key: str | None = None - asset_ids: list[str] | None = None + asset_ids: list[str | Asset] | None = None asset_tag_ids: list[str] | None = None contextual_channels: list[str] | None = None is_external: bool = False @@ -188,7 +201,12 @@ class RuleCreate(RuleCreateUpdateBase, ModelCreate[CreateRuleRequest]): description: str expression: str channel_references: list[ChannelReference] - action: RuleAction + action: RuleAction | Webhook + + @field_validator("action", mode="after") + @classmethod + def _wrap_webhook(cls, value): + return _wrap_webhook_action(value) def _get_proto_class(self) -> type[CreateRuleRequest]: return CreateRuleRequest @@ -207,7 +225,13 @@ class RuleUpdate(RuleCreateUpdateBase, ModelUpdate[RuleProto]): description: str | None = None expression: str | None = None channel_references: list[ChannelReference] | None = None - action: RuleAction | None = None + action: RuleAction | Webhook | None = None + + @field_validator("action", mode="after") + @classmethod + def _wrap_webhook(cls, value): + return _wrap_webhook_action(value) + is_archived: bool | None = None def _get_proto_class(self) -> type[RuleProto]: @@ -269,6 +293,14 @@ def from_str(cls, val: str) -> RuleAnnotationType | None: return cls(int(val)) +def _webhook_id(webhook: str | Webhook | None) -> str: + """Resolve a Webhook or webhook ID to the UUID string the proto expects.""" + if webhook is None: + raise ValueError("webhook is required for a webhook action") + raw = webhook._id_or_error if isinstance(webhook, Webhook) else webhook + return str(UUID(raw)) + + class RuleAction(BaseType[RuleActionProto, "RuleAction"]): """Model of a Rule Action.""" @@ -282,6 +314,23 @@ class RuleAction(BaseType[RuleActionProto, "RuleAction"]): annotation_type: RuleAnnotationType | None = None tags_ids: list[str] | None = None default_assignee_user: str | None = None + webhook_id: str | Webhook | None = None + + @field_validator("webhook_id", mode="after") + @classmethod + def _validate_webhook_id(cls, value): + if isinstance(value, str): + UUID(value) + return value + + @classmethod + def webhook(cls, webhook: str | Webhook) -> RuleAction: + """Create a webhook action. + + Args: + webhook: The Webhook or webhook ID to call when the rule is violated. + """ + return cls(action_type=RuleActionType.WEBHOOK, webhook_id=webhook) @classmethod def annotation( @@ -341,6 +390,11 @@ def _from_proto( if action_type == RuleActionType.ANNOTATION else None ), + webhook_id=( + proto.configuration.webhook.webhook_id + if action_type == RuleActionType.WEBHOOK + else None + ), _client=sift_client, ) @@ -358,6 +412,11 @@ def _to_update_request(self) -> UpdateActionRequest: if self.action_type == RuleActionType.ANNOTATION else None ), + webhook=( + WebhookActionConfiguration(webhook_id=_webhook_id(self.webhook_id)) + if self.action_type == RuleActionType.WEBHOOK + else None + ), ), ) diff --git a/python/lib/sift_client/sift_types/webhook.py b/python/lib/sift_client/sift_types/webhook.py new file mode 100644 index 000000000..4e8ccb234 --- /dev/null +++ b/python/lib/sift_client/sift_types/webhook.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, ClassVar + +from pydantic import BaseModel, field_validator, model_validator +from sift.webhooks.v1.webhooks_pb2 import CreateWebhookRequest as CreateWebhookRequestProto +from sift.webhooks.v1.webhooks_pb2 import Webhook as WebhookProto +from sift.webhooks.v1.webhooks_pb2 import WebhookEventType as WebhookEventTypeProto +from sift.webhooks.v1.webhooks_pb2 import WebhookHttpHeader as WebhookHttpHeaderProto + +from sift_client.sift_types._base import ( + BaseType, + MappingHelper, + ModelCreate, + ModelCreateUpdateBase, + ModelUpdate, +) + +if TYPE_CHECKING: + from sift_client.client import SiftClient + from sift_client.sift_types.user import User + + +class WebhookEventType(Enum): + """Enum for the events that trigger a webhook.""" + + RULE_VIOLATION = WebhookEventTypeProto.WEBHOOK_EVENT_TYPE_RULE_VIOLATION # 1 + + def to_filter_str(self) -> str: + """Convert to the string used in CEL filters.""" + return f"WEBHOOK_EVENT_TYPE_{self.name}" + + +class WebhookHttpHeader(BaseModel): + """An HTTP header sent with every request to the webhook's target URL. + + Attributes: + name: The header name. + value: The header value. + """ + + name: str + value: str + + @classmethod + def _from_proto(cls, proto: WebhookHttpHeaderProto) -> WebhookHttpHeader: + return cls(name=proto.name, value=proto.value) + + +class WebhookTest(BaseModel): + """The response the target URL returned for a test request. + + Attributes: + http_response_code: The HTTP status code the target returned. + http_response_body: The raw response body the target returned. + """ + + http_response_code: int + http_response_body: bytes + + @property + def text(self) -> str: + """The response body decoded as text, with undecodable bytes replaced.""" + return self.http_response_body.decode(errors="replace") + + +class Webhook(BaseType[WebhookProto, "Webhook"]): + """Webhook model representing an HTTP callback registered with Sift.""" + + # Required fields + name: str + organization_id: str + target_url: str + event_type: WebhookEventType | None + http_headers: list[WebhookHttpHeader] + created_date: datetime + modified_date: datetime + created_by_user_id: str + modified_by_user_id: str + is_archived: bool + + # Optional fields + payload: str | None + archived_date: datetime | None + + @classmethod + def _from_proto(cls, proto: WebhookProto, sift_client: SiftClient | None = None) -> Webhook: + return cls( + proto=proto, + id_=proto.webhook_id, + name=proto.name, + organization_id=proto.organization_id, + target_url=proto.target_url, + event_type=(WebhookEventType(proto.event_type) if proto.event_type else None), + http_headers=[WebhookHttpHeader._from_proto(header) for header in proto.http_headers], + created_date=proto.created_date.ToDatetime(tzinfo=timezone.utc), + modified_date=proto.modified_date.ToDatetime(tzinfo=timezone.utc), + created_by_user_id=proto.created_by_user_id, + modified_by_user_id=proto.modified_by_user_id, + is_archived=proto.is_archived, + payload=proto.payload if proto.HasField("payload") else None, + archived_date=( + proto.archived_date.ToDatetime(tzinfo=timezone.utc) + if proto.HasField("archived_date") + else None + ), + _client=sift_client, + ) + + @property + def created_by(self) -> User: + """Fetch the User that created this webhook.""" + return self.client.users.get(user_id=self.created_by_user_id) + + @property + def modified_by(self) -> User: + """Fetch the User that last modified this webhook.""" + return self.client.users.get(user_id=self.modified_by_user_id) + + def update(self, update: WebhookUpdate | dict) -> Webhook: + """Update the Webhook. + + Args: + update: The update to apply to the webhook. See WebhookUpdate for updatable fields. + + Returns: + The updated webhook. + """ + updated_webhook = self.client.webhooks.update(webhook=self, update=update) + self._update(updated_webhook) + return self + + def archive(self) -> Webhook: + """Archive the webhook. Archived webhooks stop receiving events.""" + updated_webhook = self.client.webhooks.archive(webhook=self) + self._update(updated_webhook) + return self + + def unarchive(self) -> Webhook: + """Unarchive the webhook.""" + updated_webhook = self.client.webhooks.unarchive(webhook=self) + self._update(updated_webhook) + return self + + def send_test_request(self) -> WebhookTest: + """Send a real request to this webhook's target URL and return its response.""" + return self.client.webhooks.send_test_request(webhook=self) + + +def _check_target_url(value: str | None) -> None: + """Reject target URLs that are not absolute HTTP(S) URLs.""" + if value is not None and not value.startswith(("http://", "https://")): + raise ValueError("target_url must start with http:// or https://") + + +class WebhookBase(ModelCreateUpdateBase): + """Base class for Webhook create and update models with shared fields and validation.""" + + event_type: WebhookEventType | None = None + payload: str | None = None + http_headers: list[WebhookHttpHeader] | dict[str, str] | None = None + + _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { + "http_headers": MappingHelper( + proto_attr_path="http_headers", + update_field="http_headers", + converter=WebhookHttpHeaderProto, + ), + } + + @field_validator("http_headers", mode="before") + @classmethod + def _normalize_http_headers(cls, value): + """Accept a ``{name: value}`` mapping in place of a list of headers.""" + if isinstance(value, dict): + return [WebhookHttpHeader(name=name, value=header) for name, header in value.items()] + return value + + +class WebhookCreate(WebhookBase, ModelCreate[CreateWebhookRequestProto]): + """Create model for Webhook.""" + + name: str + target_url: str + + @field_validator("target_url") + @classmethod + def _validate_target_url(cls, value: str) -> str: + _check_target_url(value) + return value + + def _get_proto_class(self) -> type[CreateWebhookRequestProto]: + return CreateWebhookRequestProto + + @model_validator(mode="after") + def _default_event_type(self): + """Assign rather than default, since `to_proto` drops unset fields.""" + if self.event_type is None: + self.event_type = WebhookEventType.RULE_VIOLATION + return self + + +class WebhookUpdate(WebhookBase, ModelUpdate[WebhookProto]): + """Update model for Webhook.""" + + name: str | None = None + target_url: str | None = None + is_archived: bool | None = None + + @field_validator("target_url") + @classmethod + def _validate_target_url(cls, value: str | None) -> str | None: + _check_target_url(value) + return value + + def _get_proto_class(self) -> type[WebhookProto]: + return WebhookProto + + def _add_resource_id_to_proto(self, proto_msg: WebhookProto): + if self._resource_id is None: + raise ValueError("Resource ID must be set before adding to proto") + proto_msg.webhook_id = self._resource_id diff --git a/python/lib/sift_client/util/util.py b/python/lib/sift_client/util/util.py index 5b46fc169..a9da1b920 100644 --- a/python/lib/sift_client/util/util.py +++ b/python/lib/sift_client/util/util.py @@ -22,6 +22,7 @@ TestResultsAPIAsync, UserDefinedFunctionsAPIAsync, UsersAPIAsync, + WebhooksAPIAsync, ) from sift_client.resources.access_control import AccessControlAPIAsync @@ -80,6 +81,9 @@ class AsyncAPIs(NamedTuple): users: UsersAPIAsync """Instance of the Users API for making asynchronous requests.""" + webhooks: WebhooksAPIAsync + """Instance of the Webhooks API for making asynchronous requests.""" + data_export: DataExportAPIAsync """Instance of the Data Export API for making asynchronous requests."""