From 7b6762a12f4cd4ddfb200fb95f7a7c7c61e2ce16 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Sun, 26 Jul 2026 09:15:27 +0100 Subject: [PATCH 1/2] feat(models): add WebhookEvent model and event type constants Adds src/shade/models/webhook.py with a WebhookEvent ShadeObject carrying id, type, data, created_at (aliased from createdAt) and livemode, plus a WebhookEventType str enum for the platform's event type strings. data is deliberately kept as the raw decoded JSON object; coercion into a typed resource model belongs to the webhooks resource layer (#66/#69), which does not exist yet. Exports both from shade.models and the package root. --- src/shade/__init__.py | 11 ++- src/shade/models/__init__.py | 10 ++- src/shade/models/webhook.py | 88 +++++++++++++++++++++ tests/test_webhook_event.py | 149 +++++++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/shade/models/webhook.py create mode 100644 tests/test_webhook_event.py diff --git a/src/shade/__init__.py b/src/shade/__init__.py index 43cb473..62900c7 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -16,7 +16,14 @@ ShadeError, SignatureVerificationError, ) -from .models import Merchant, ShadeObject, Transfer, TransferStatus +from .models import ( + Merchant, + ShadeObject, + Transfer, + TransferStatus, + WebhookEvent, + WebhookEventType, +) __version__ = "0.1.0" @@ -41,6 +48,8 @@ "SyncHTTPClient", "Transfer", "TransferStatus", + "WebhookEvent", + "WebhookEventType", "config", "api_base", "environment", diff --git a/src/shade/models/__init__.py b/src/shade/models/__init__.py index 330af80..bc2ae44 100644 --- a/src/shade/models/__init__.py +++ b/src/shade/models/__init__.py @@ -4,5 +4,13 @@ from .base import ShadeObject from .merchant import Merchant from .transfer import Transfer, TransferStatus +from .webhook import WebhookEvent, WebhookEventType -__all__ = ["Merchant", "ShadeObject", "Transfer", "TransferStatus"] +__all__ = [ + "Merchant", + "ShadeObject", + "Transfer", + "TransferStatus", + "WebhookEvent", + "WebhookEventType", +] diff --git a/src/shade/models/webhook.py b/src/shade/models/webhook.py new file mode 100644 index 0000000..b8e1c15 --- /dev/null +++ b/src/shade/models/webhook.py @@ -0,0 +1,88 @@ +""" +Webhook event model. + +Represents a parsed, verified webhook event delivered by the Shade platform. +Field names are converted from ``camelCase`` (backend/JSON) to ``snake_case`` +(Python) via pydantic field aliases, matching the convention established by +:class:`~shade.models.transfer.Transfer`. + +At this layer ``data`` is deliberately left as the raw decoded JSON object. The +resource layer (``Webhook.construct_event()``) is responsible for coercing it +into the corresponding typed model based on ``type``. +""" +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any + +from pydantic import Field + +from .base import ShadeObject + + +class WebhookEventType(str, Enum): + """Event types delivered by the Shade platform. + + Members subclass :class:`str`, so they compare equal to the wire value:: + + if event.type == WebhookEventType.PAYMENT_COMPLETED: + ... + + The list is not exhaustive by design: :class:`WebhookEvent` stores ``type`` + as a plain ``str``, so an event type added server-side still parses and can + be compared against a literal until a constant is added here. + """ + + PAYMENT_COMPLETED = "payment.completed" + PAYMENT_CANCELLED = "payment.cancelled" + PAYMENT_EXPIRED = "payment.expired" + PAYMENT_PARTIALLY_PAID = "payment.partially_paid" + INVOICE_PAID = "invoice.paid" + INVOICE_SENT = "invoice.sent" + INVOICE_CANCELLED = "invoice.cancelled" + TRANSFER_COMPLETED = "transfer.completed" + TRANSFER_FAILED = "transfer.failed" + SWAP_COMPLETED = "swap.completed" + SWAP_SLIPPAGE_EXCEEDED = "swap.slippage_exceeded" + + +class WebhookEvent(ShadeObject): + """A parsed, verified webhook event. + + The expected payload is a JSON object of the shape:: + + { + "id": "evt_123", + "type": "payment.completed", + "data": {"id": "pay_123", ...}, + "createdAt": "2026-07-20T12:00:00Z", + "livemode": false + } + + Build one with :meth:`ShadeObject.from_dict`, which maps ``createdAt`` to + :attr:`created_at` and parses it into a :class:`~datetime.datetime`. + ``livemode`` distinguishes a production event from a test-mode one. + + ``id``, ``type``, ``data``, ``created_at`` and ``livemode`` are all + required; a payload missing any of them — or carrying an unparseable + timestamp — raises + :class:`~shade.errors.InvalidRequestError` rather than producing a + half-populated event. Unknown extra keys are preserved, per + :class:`~shade.models.base.ShadeObject`. + + Attributes: + id: Unique identifier of the event. + type: Event type string, e.g. ``"payment.completed"``. Compare against + :class:`WebhookEventType` members. + data: The event payload, left as the raw decoded JSON object. Typed + model coercion happens in the resource layer, not here. + created_at: When the platform emitted the event. + livemode: ``True`` for a live event, ``False`` for a test-mode one. + """ + + id: str + type: str + data: Any + created_at: datetime = Field(alias="createdAt") + livemode: bool diff --git a/tests/test_webhook_event.py b/tests/test_webhook_event.py new file mode 100644 index 0000000..a1bce1e --- /dev/null +++ b/tests/test_webhook_event.py @@ -0,0 +1,149 @@ +from datetime import datetime, timezone + +import pytest + +import shade +from shade import InvalidRequestError, ShadeObject, WebhookEvent, WebhookEventType + +PAYMENT_DATA = { + "id": "pay_123", + "amount": "150.25", + "asset": "USDC", + "status": "completed", +} + + +def _api_response(**overrides): + """A representative camelCase backend payload.""" + data = { + "id": "evt_123", + "type": "payment.completed", + "data": dict(PAYMENT_DATA), + "createdAt": "2026-07-20T12:00:00Z", + "livemode": True, + } + data.update(overrides) + return data + + +def test_from_dict_populates_all_fields(): + event = WebhookEvent.from_dict(_api_response()) + + assert event.id == "evt_123" + assert event.type == "payment.completed" + assert event.data == PAYMENT_DATA + assert event.created_at == datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc) + assert event.livemode is True + + +def test_data_stays_a_raw_dict(): + event = WebhookEvent.from_dict(_api_response()) + + assert isinstance(event.data, dict) + assert not isinstance(event.data, ShadeObject) + assert event.data["status"] == "completed" + + +def test_data_accepts_non_dict_payloads_unchanged(): + event = WebhookEvent.from_dict(_api_response(data=["a", "b"])) + assert event.data == ["a", "b"] + + +def test_payload_is_not_mutated(): + payload = _api_response() + snapshot = { + **payload, + "data": dict(payload["data"]), + } + + WebhookEvent.from_dict(payload) + + assert payload == snapshot + + +def test_created_at_parsed_from_iso_string(): + event = WebhookEvent.from_dict(_api_response(createdAt="2026-01-02T03:04:05Z")) + assert event.created_at == datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc) + + +def test_created_at_accepts_snake_case_key(): + payload = _api_response() + del payload["createdAt"] + payload["created_at"] = "2026-07-20T12:00:00Z" + + event = WebhookEvent.from_dict(payload) + assert event.created_at == datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc) + + +def test_invalid_created_at_raises(): + with pytest.raises(InvalidRequestError) as exc_info: + WebhookEvent.from_dict(_api_response(createdAt="not-a-timestamp")) + + assert "createdAt" in exc_info.value.field_errors + + +@pytest.mark.parametrize("livemode,expected", [(True, True), (False, False)]) +def test_livemode_reflects_payload(livemode, expected): + event = WebhookEvent.from_dict(_api_response(livemode=livemode)) + assert event.livemode is expected + + +def test_livemode_coerced_from_json_falsy_value(): + event = WebhookEvent.from_dict(_api_response(livemode="false")) + assert event.livemode is False + + +@pytest.mark.parametrize("field", ["id", "type", "data", "createdAt", "livemode"]) +def test_missing_required_field_raises(field): + payload = _api_response() + del payload[field] + + with pytest.raises(InvalidRequestError): + WebhookEvent.from_dict(payload) + + +def test_non_dict_payload_raises(): + with pytest.raises(InvalidRequestError): + WebhookEvent.from_dict("not-a-payload") + + +def test_unknown_fields_are_preserved(): + event = WebhookEvent.from_dict(_api_response(apiVersion="2026-07-01")) + assert event.apiVersion == "2026-07-01" + + +def test_to_dict_round_trips_by_alias(): + event = WebhookEvent.from_dict(_api_response()) + dumped = event.to_dict() + + assert dumped["id"] == "evt_123" + assert dumped["createdAt"] == datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc) + assert dumped["data"] == PAYMENT_DATA + assert dumped["livemode"] is True + + +def test_repr_shows_event_id(): + event = WebhookEvent.from_dict(_api_response()) + assert repr(event) == "" + + +def test_event_type_constants_compare_to_wire_strings(): + assert WebhookEventType.PAYMENT_COMPLETED == "payment.completed" + assert WebhookEventType.INVOICE_PAID == "invoice.paid" + assert WebhookEventType.SWAP_SLIPPAGE_EXCEEDED == "swap.slippage_exceeded" + + +def test_event_type_usable_in_conditionals(): + event = WebhookEvent.from_dict(_api_response()) + assert event.type == WebhookEventType.PAYMENT_COMPLETED + assert event.type != WebhookEventType.PAYMENT_EXPIRED + + +def test_unknown_event_type_still_parses(): + event = WebhookEvent.from_dict(_api_response(type="payment.refunded")) + assert event.type == "payment.refunded" + + +def test_exported_from_package_root(): + assert shade.WebhookEvent is WebhookEvent + assert shade.WebhookEventType is WebhookEventType From 0a328cfccca469cfc0123008311c48e29911e0b5 Mon Sep 17 00:00:00 2001 From: Depo-dev Date: Wed, 29 Jul 2026 01:57:17 +0100 Subject: [PATCH 2/2] fix(webhook): enforce dict payload and strict bool per CodeRabbit review data now requires dict[str, Any] and livemode requires StrictBool so malformed payloads raise InvalidRequestError instead of silently coercing (e.g. a JSON-encoded "false" string, or an array/scalar data payload). --- src/shade/models/webhook.py | 6 +++--- tests/test_webhook_event.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/shade/models/webhook.py b/src/shade/models/webhook.py index b8e1c15..66a9d6a 100644 --- a/src/shade/models/webhook.py +++ b/src/shade/models/webhook.py @@ -16,7 +16,7 @@ from enum import Enum from typing import Any -from pydantic import Field +from pydantic import Field, StrictBool from .base import ShadeObject @@ -83,6 +83,6 @@ class WebhookEvent(ShadeObject): id: str type: str - data: Any + data: dict[str, Any] created_at: datetime = Field(alias="createdAt") - livemode: bool + livemode: StrictBool diff --git a/tests/test_webhook_event.py b/tests/test_webhook_event.py index a1bce1e..017ffbb 100644 --- a/tests/test_webhook_event.py +++ b/tests/test_webhook_event.py @@ -44,9 +44,9 @@ def test_data_stays_a_raw_dict(): assert event.data["status"] == "completed" -def test_data_accepts_non_dict_payloads_unchanged(): - event = WebhookEvent.from_dict(_api_response(data=["a", "b"])) - assert event.data == ["a", "b"] +def test_non_dict_data_raises(): + with pytest.raises(InvalidRequestError): + WebhookEvent.from_dict(_api_response(data=["a", "b"])) def test_payload_is_not_mutated(): @@ -88,9 +88,9 @@ def test_livemode_reflects_payload(livemode, expected): assert event.livemode is expected -def test_livemode_coerced_from_json_falsy_value(): - event = WebhookEvent.from_dict(_api_response(livemode="false")) - assert event.livemode is False +def test_livemode_string_value_raises(): + with pytest.raises(InvalidRequestError): + WebhookEvent.from_dict(_api_response(livemode="false")) @pytest.mark.parametrize("field", ["id", "type", "data", "createdAt", "livemode"])