Skip to content

Commit 65526a5

Browse files
authored
Merge pull request #52 from Depo-dev/feat/webhook-event-model
feat(models): implement WebhookEvent model
2 parents c6a28ab + 0a328cf commit 65526a5

4 files changed

Lines changed: 252 additions & 1 deletion

File tree

src/shade/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,16 @@
1616
ShadeError,
1717
SignatureVerificationError,
1818
)
19-
from .models import AssetBalance, Balance, Merchant, ShadeObject, Transfer, TransferStatus
19+
from .models import (
20+
AssetBalance,
21+
Balance,
22+
Merchant,
23+
ShadeObject,
24+
Transfer,
25+
TransferStatus,
26+
WebhookEvent,
27+
WebhookEventType,
28+
)
2029

2130
__version__ = "0.1.0"
2231

@@ -43,6 +52,8 @@
4352
"SyncHTTPClient",
4453
"Transfer",
4554
"TransferStatus",
55+
"WebhookEvent",
56+
"WebhookEventType",
4657
"config",
4758
"api_base",
4859
"environment",

src/shade/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .base import ShadeObject
66
from .merchant import Merchant
77
from .transfer import Transfer, TransferStatus
8+
from .webhook import WebhookEvent, WebhookEventType
89

910
__all__ = [
1011
"AssetBalance",
@@ -13,4 +14,6 @@
1314
"ShadeObject",
1415
"Transfer",
1516
"TransferStatus",
17+
"WebhookEvent",
18+
"WebhookEventType",
1619
]

src/shade/models/webhook.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Webhook event model.
3+
4+
Represents a parsed, verified webhook event delivered by the Shade platform.
5+
Field names are converted from ``camelCase`` (backend/JSON) to ``snake_case``
6+
(Python) via pydantic field aliases, matching the convention established by
7+
:class:`~shade.models.transfer.Transfer`.
8+
9+
At this layer ``data`` is deliberately left as the raw decoded JSON object. The
10+
resource layer (``Webhook.construct_event()``) is responsible for coercing it
11+
into the corresponding typed model based on ``type``.
12+
"""
13+
from __future__ import annotations
14+
15+
from datetime import datetime
16+
from enum import Enum
17+
from typing import Any
18+
19+
from pydantic import Field, StrictBool
20+
21+
from .base import ShadeObject
22+
23+
24+
class WebhookEventType(str, Enum):
25+
"""Event types delivered by the Shade platform.
26+
27+
Members subclass :class:`str`, so they compare equal to the wire value::
28+
29+
if event.type == WebhookEventType.PAYMENT_COMPLETED:
30+
...
31+
32+
The list is not exhaustive by design: :class:`WebhookEvent` stores ``type``
33+
as a plain ``str``, so an event type added server-side still parses and can
34+
be compared against a literal until a constant is added here.
35+
"""
36+
37+
PAYMENT_COMPLETED = "payment.completed"
38+
PAYMENT_CANCELLED = "payment.cancelled"
39+
PAYMENT_EXPIRED = "payment.expired"
40+
PAYMENT_PARTIALLY_PAID = "payment.partially_paid"
41+
INVOICE_PAID = "invoice.paid"
42+
INVOICE_SENT = "invoice.sent"
43+
INVOICE_CANCELLED = "invoice.cancelled"
44+
TRANSFER_COMPLETED = "transfer.completed"
45+
TRANSFER_FAILED = "transfer.failed"
46+
SWAP_COMPLETED = "swap.completed"
47+
SWAP_SLIPPAGE_EXCEEDED = "swap.slippage_exceeded"
48+
49+
50+
class WebhookEvent(ShadeObject):
51+
"""A parsed, verified webhook event.
52+
53+
The expected payload is a JSON object of the shape::
54+
55+
{
56+
"id": "evt_123",
57+
"type": "payment.completed",
58+
"data": {"id": "pay_123", ...},
59+
"createdAt": "2026-07-20T12:00:00Z",
60+
"livemode": false
61+
}
62+
63+
Build one with :meth:`ShadeObject.from_dict`, which maps ``createdAt`` to
64+
:attr:`created_at` and parses it into a :class:`~datetime.datetime`.
65+
``livemode`` distinguishes a production event from a test-mode one.
66+
67+
``id``, ``type``, ``data``, ``created_at`` and ``livemode`` are all
68+
required; a payload missing any of them — or carrying an unparseable
69+
timestamp — raises
70+
:class:`~shade.errors.InvalidRequestError` rather than producing a
71+
half-populated event. Unknown extra keys are preserved, per
72+
:class:`~shade.models.base.ShadeObject`.
73+
74+
Attributes:
75+
id: Unique identifier of the event.
76+
type: Event type string, e.g. ``"payment.completed"``. Compare against
77+
:class:`WebhookEventType` members.
78+
data: The event payload, left as the raw decoded JSON object. Typed
79+
model coercion happens in the resource layer, not here.
80+
created_at: When the platform emitted the event.
81+
livemode: ``True`` for a live event, ``False`` for a test-mode one.
82+
"""
83+
84+
id: str
85+
type: str
86+
data: dict[str, Any]
87+
created_at: datetime = Field(alias="createdAt")
88+
livemode: StrictBool

tests/test_webhook_event.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
from datetime import datetime, timezone
2+
3+
import pytest
4+
5+
import shade
6+
from shade import InvalidRequestError, ShadeObject, WebhookEvent, WebhookEventType
7+
8+
PAYMENT_DATA = {
9+
"id": "pay_123",
10+
"amount": "150.25",
11+
"asset": "USDC",
12+
"status": "completed",
13+
}
14+
15+
16+
def _api_response(**overrides):
17+
"""A representative camelCase backend payload."""
18+
data = {
19+
"id": "evt_123",
20+
"type": "payment.completed",
21+
"data": dict(PAYMENT_DATA),
22+
"createdAt": "2026-07-20T12:00:00Z",
23+
"livemode": True,
24+
}
25+
data.update(overrides)
26+
return data
27+
28+
29+
def test_from_dict_populates_all_fields():
30+
event = WebhookEvent.from_dict(_api_response())
31+
32+
assert event.id == "evt_123"
33+
assert event.type == "payment.completed"
34+
assert event.data == PAYMENT_DATA
35+
assert event.created_at == datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc)
36+
assert event.livemode is True
37+
38+
39+
def test_data_stays_a_raw_dict():
40+
event = WebhookEvent.from_dict(_api_response())
41+
42+
assert isinstance(event.data, dict)
43+
assert not isinstance(event.data, ShadeObject)
44+
assert event.data["status"] == "completed"
45+
46+
47+
def test_non_dict_data_raises():
48+
with pytest.raises(InvalidRequestError):
49+
WebhookEvent.from_dict(_api_response(data=["a", "b"]))
50+
51+
52+
def test_payload_is_not_mutated():
53+
payload = _api_response()
54+
snapshot = {
55+
**payload,
56+
"data": dict(payload["data"]),
57+
}
58+
59+
WebhookEvent.from_dict(payload)
60+
61+
assert payload == snapshot
62+
63+
64+
def test_created_at_parsed_from_iso_string():
65+
event = WebhookEvent.from_dict(_api_response(createdAt="2026-01-02T03:04:05Z"))
66+
assert event.created_at == datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
67+
68+
69+
def test_created_at_accepts_snake_case_key():
70+
payload = _api_response()
71+
del payload["createdAt"]
72+
payload["created_at"] = "2026-07-20T12:00:00Z"
73+
74+
event = WebhookEvent.from_dict(payload)
75+
assert event.created_at == datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc)
76+
77+
78+
def test_invalid_created_at_raises():
79+
with pytest.raises(InvalidRequestError) as exc_info:
80+
WebhookEvent.from_dict(_api_response(createdAt="not-a-timestamp"))
81+
82+
assert "createdAt" in exc_info.value.field_errors
83+
84+
85+
@pytest.mark.parametrize("livemode,expected", [(True, True), (False, False)])
86+
def test_livemode_reflects_payload(livemode, expected):
87+
event = WebhookEvent.from_dict(_api_response(livemode=livemode))
88+
assert event.livemode is expected
89+
90+
91+
def test_livemode_string_value_raises():
92+
with pytest.raises(InvalidRequestError):
93+
WebhookEvent.from_dict(_api_response(livemode="false"))
94+
95+
96+
@pytest.mark.parametrize("field", ["id", "type", "data", "createdAt", "livemode"])
97+
def test_missing_required_field_raises(field):
98+
payload = _api_response()
99+
del payload[field]
100+
101+
with pytest.raises(InvalidRequestError):
102+
WebhookEvent.from_dict(payload)
103+
104+
105+
def test_non_dict_payload_raises():
106+
with pytest.raises(InvalidRequestError):
107+
WebhookEvent.from_dict("not-a-payload")
108+
109+
110+
def test_unknown_fields_are_preserved():
111+
event = WebhookEvent.from_dict(_api_response(apiVersion="2026-07-01"))
112+
assert event.apiVersion == "2026-07-01"
113+
114+
115+
def test_to_dict_round_trips_by_alias():
116+
event = WebhookEvent.from_dict(_api_response())
117+
dumped = event.to_dict()
118+
119+
assert dumped["id"] == "evt_123"
120+
assert dumped["createdAt"] == datetime(2026, 7, 20, 12, 0, tzinfo=timezone.utc)
121+
assert dumped["data"] == PAYMENT_DATA
122+
assert dumped["livemode"] is True
123+
124+
125+
def test_repr_shows_event_id():
126+
event = WebhookEvent.from_dict(_api_response())
127+
assert repr(event) == "<WebhookEvent id='evt_123'>"
128+
129+
130+
def test_event_type_constants_compare_to_wire_strings():
131+
assert WebhookEventType.PAYMENT_COMPLETED == "payment.completed"
132+
assert WebhookEventType.INVOICE_PAID == "invoice.paid"
133+
assert WebhookEventType.SWAP_SLIPPAGE_EXCEEDED == "swap.slippage_exceeded"
134+
135+
136+
def test_event_type_usable_in_conditionals():
137+
event = WebhookEvent.from_dict(_api_response())
138+
assert event.type == WebhookEventType.PAYMENT_COMPLETED
139+
assert event.type != WebhookEventType.PAYMENT_EXPIRED
140+
141+
142+
def test_unknown_event_type_still_parses():
143+
event = WebhookEvent.from_dict(_api_response(type="payment.refunded"))
144+
assert event.type == "payment.refunded"
145+
146+
147+
def test_exported_from_package_root():
148+
assert shade.WebhookEvent is WebhookEvent
149+
assert shade.WebhookEventType is WebhookEventType

0 commit comments

Comments
 (0)