Skip to content

Commit 6a06e88

Browse files
bokelleyclaude
andauthored
feat(webhooks): public to_wire_dict() serialization seam (#602)
* feat(webhooks): public to_wire_dict() serialization seam Adopters wrapping create_a2a_webhook_payload (a2a-sdk 1.0+ protobuf Task / TaskStatusUpdateEvent) and create_mcp_webhook_payload (dict / McpWebhookPayload) had to write per-shape isinstance dispatch in every webhook send path. A future a2a-sdk that swaps protobuf for a Pydantic facade would silently change which branch runs. to_wire_dict centralizes that dispatch: - protobuf -> MessageToDict(preserving_proto_field_name=False), with the existing 1.0 -> 0.3 enum normalization (TASK_STATE_COMPLETED -> completed, ROLE_AGENT -> agent) so 0.3 buyer receivers keep parsing - Pydantic -> model_dump(mode="json", exclude_none=True) - Mapping -> dict() passthrough (legacy callers that hand-build the wire body) - anything else -> TypeError with a directional message Re-exports the seam from adcp.webhooks and the adcp package root. Internal _payload_to_dict (used by deliver()) is replaced with to_wire_dict; behaviour is unchanged for the supported types. Refs: #601 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(public-api): regenerate snapshot for to_wire_dict export Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0822abb commit 6a06e88

4 files changed

Lines changed: 170 additions & 13 deletions

File tree

src/adcp/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,7 @@
444444
get_adcp_signed_headers_for_webhook,
445445
sign_legacy_webhook,
446446
sign_webhook,
447+
to_wire_dict,
447448
)
448449

449450
try:
@@ -620,6 +621,7 @@ def get_adcp_version() -> str:
620621
"generate_webhook_idempotency_key",
621622
"sign_legacy_webhook",
622623
"sign_webhook",
624+
"to_wire_dict",
623625
"WebhookReceiver",
624626
"WebhookReceiverConfig",
625627
"WebhookVerifyOptions",

src/adcp/webhooks.py

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -846,7 +846,7 @@ async def deliver(
846846
allowed_ports=allowed_ports,
847847
)
848848

849-
body_dict = _payload_to_dict(payload)
849+
body_dict = to_wire_dict(payload)
850850
if token is not None and token_field is not None:
851851
_validate_header_value("config.token", token)
852852
_inject_push_token(body_dict, token, payload, token_field)
@@ -1036,19 +1036,37 @@ def _reserved_header_message(normalized: str, original_key: Any) -> str:
10361036
)
10371037

10381038

1039-
def _payload_to_dict(
1039+
def to_wire_dict(
10401040
payload: AdCPBaseModel | Task | TaskStatusUpdateEvent | Mapping[str, Any],
10411041
) -> dict[str, Any]:
1042-
"""Normalize a webhook payload to a JSON-ready dict.
1043-
1044-
a2a-sdk ``Task`` / ``TaskStatusUpdateEvent`` are protobuf messages and
1045-
serialize through ``MessageToDict`` with camelCase field names
1046-
(``artifact_id`` → ``artifactId``) so external A2A receivers see the
1047-
on-wire shape they expect. The protobuf default emits enum states as
1048-
``TASK_STATE_COMPLETED``; we post-process to the 0.3-compatible
1049-
lowercase form (``completed``) so existing A2A buyer webhook
1050-
receivers keep parsing. MCP-shape dicts / AdCP models are dumped
1051-
with camelCase-off defaults.
1042+
"""Serialize any AdCP webhook payload to a JSON-ready dict.
1043+
1044+
Single seam for adopters that accept "any AdCP webhook payload" — a
1045+
sender wrapping :func:`create_a2a_webhook_payload` and
1046+
:func:`create_mcp_webhook_payload` would otherwise have to write
1047+
per-shape dispatch (``isinstance`` checks, ``MessageToDict`` for
1048+
protobuf, ``model_dump`` for Pydantic, passthrough for dict). Brittle:
1049+
a future a2a-sdk that swaps protobuf for a Pydantic façade silently
1050+
changes which branch runs, and adopters duplicate the dispatch in
1051+
every send path. Use this helper instead — the dispatch lives here.
1052+
1053+
Behaviour by input shape:
1054+
1055+
* a2a ``Task`` / ``TaskStatusUpdateEvent`` (protobuf, a2a-sdk 1.0+) →
1056+
``MessageToDict(..., preserving_proto_field_name=False)`` so JSON
1057+
keys match the A2A wire spec (camelCase: ``id``, ``contextId``,
1058+
``artifactId``). Enum values are normalized from the 1.0 protobuf
1059+
form (``TASK_STATE_COMPLETED``, ``ROLE_AGENT``) to the 0.3-spec
1060+
lowercase form (``completed``, ``agent``) so 0.3 buyer receivers
1061+
keep parsing.
1062+
* Any Pydantic model (``McpWebhookPayload``, future Pydantic façades,
1063+
:class:`AdCPBaseModel` subclasses) → ``model_dump(mode="json",
1064+
exclude_none=True)``.
1065+
* ``Mapping`` → coerced to ``dict``. Legacy adopter passthrough for
1066+
callers that build the wire dict by hand.
1067+
1068+
Raises:
1069+
TypeError: payload is none of the above.
10521070
"""
10531071
if isinstance(payload, (Task, TaskStatusUpdateEvent)):
10541072
data = MessageToDict(payload, preserving_proto_field_name=False)
@@ -1057,7 +1075,13 @@ def _payload_to_dict(
10571075
if hasattr(payload, "model_dump"):
10581076
model = cast(AdCPBaseModel, payload)
10591077
return model.model_dump(mode="json", exclude_none=True)
1060-
return dict(payload)
1078+
if isinstance(payload, Mapping):
1079+
return dict(payload)
1080+
raise TypeError(
1081+
f"Unsupported webhook payload type {type(payload).__name__}: expected "
1082+
"a2a Task / TaskStatusUpdateEvent (protobuf), an AdCP Pydantic model "
1083+
"(e.g. McpWebhookPayload), or a Mapping[str, Any]."
1084+
)
10611085

10621086

10631087
def _normalize_a2a_task_state_to_v03(payload: dict[str, Any]) -> None:
@@ -1172,6 +1196,7 @@ def _validate_header_value(name: str, value: Any) -> None:
11721196
"generate_webhook_idempotency_key",
11731197
"get_adcp_signed_headers_for_webhook",
11741198
"sign_legacy_webhook",
1199+
"to_wire_dict",
11751200
# Sender — 9421 signing (low-level)
11761201
"sign_webhook",
11771202
# Sender — one-call outbound helpers

tests/fixtures/public_api_snapshot.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@
367367
"test_agent_a2a_no_auth",
368368
"test_agent_client",
369369
"test_agent_no_auth",
370+
"to_wire_dict",
370371
"uses_deprecated_assets_field",
371372
"validate_adagents",
372373
"validate_agent_authorization",
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Tests for ``adcp.webhooks.to_wire_dict``.
2+
3+
The seam exists so adopters wrapping ``create_a2a_webhook_payload`` and
4+
``create_mcp_webhook_payload`` can serialize either return shape with one
5+
call. The load-bearing properties:
6+
7+
* a2a protobuf round-trips to camelCase keys (``id``, ``contextId``,
8+
``artifactId``) so external A2A receivers see the on-wire shape.
9+
* MCP dicts pass through with the snake_case keys the MCP webhook
10+
schema specifies (``task_id``, ``task_type``).
11+
* Pydantic models dump to JSON-mode dicts so sub-models serialize too.
12+
* Unsupported types raise ``TypeError`` at the seam — silent fallthrough
13+
to ``str(payload)`` or similar would mask integration bugs.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from datetime import datetime, timezone
19+
20+
import pytest
21+
22+
from adcp.types import GeneratedTaskStatus
23+
from adcp.types.generated_poc.core.mcp_webhook_payload import McpWebhookPayload
24+
from adcp.webhooks import (
25+
create_a2a_webhook_payload,
26+
create_mcp_webhook_payload,
27+
to_wire_dict,
28+
)
29+
30+
31+
def test_a2a_task_round_trips_to_camelcase_wire_keys() -> None:
32+
"""Terminated A2A status returns a Task → camelCase wire keys."""
33+
payload = create_a2a_webhook_payload(
34+
task_id="task_123",
35+
status=GeneratedTaskStatus.completed,
36+
context_id="ctx_456",
37+
result={"media_buy_id": "mb_1"},
38+
timestamp=datetime(2026, 5, 8, 12, 0, 0, tzinfo=timezone.utc),
39+
)
40+
41+
wire = to_wire_dict(payload)
42+
43+
assert wire["id"] == "task_123"
44+
assert wire["contextId"] == "ctx_456"
45+
assert wire["status"]["state"] == "completed"
46+
assert wire["artifacts"][0]["artifactId"] == "task_123_result"
47+
# Inner DataPart preserves the AdCP response payload verbatim.
48+
assert wire["artifacts"][0]["parts"][0]["data"] == {"media_buy_id": "mb_1"}
49+
50+
51+
def test_a2a_status_update_event_round_trips_to_camelcase_wire_keys() -> None:
52+
"""Intermediate A2A status returns a TaskStatusUpdateEvent."""
53+
payload = create_a2a_webhook_payload(
54+
task_id="task_789",
55+
status=GeneratedTaskStatus.working,
56+
context_id="ctx_789",
57+
result={"current_step": "processing", "percentage": 50},
58+
)
59+
60+
wire = to_wire_dict(payload)
61+
62+
assert wire["taskId"] == "task_789"
63+
assert wire["contextId"] == "ctx_789"
64+
assert wire["status"]["state"] == "working"
65+
assert wire["status"]["message"]["role"] == "agent"
66+
assert wire["status"]["message"]["parts"][0]["data"] == {
67+
"current_step": "processing",
68+
"percentage": 50,
69+
}
70+
71+
72+
def test_mcp_dict_passes_through_with_snake_case_keys() -> None:
73+
"""MCP wire shape is snake_case per mcp-webhook-payload.json."""
74+
payload = create_mcp_webhook_payload(
75+
task_id="task_123",
76+
task_type="create_media_buy",
77+
status="completed",
78+
result={"media_buy_id": "mb_1"},
79+
idempotency_key="whk_01HW9D2T3VXQ5M7K9N1P3R5S7U",
80+
)
81+
82+
wire = to_wire_dict(payload)
83+
84+
assert wire["task_id"] == "task_123"
85+
assert wire["task_type"] == "create_media_buy"
86+
assert wire["status"] == "completed"
87+
assert wire["result"] == {"media_buy_id": "mb_1"}
88+
assert wire["idempotency_key"] == "whk_01HW9D2T3VXQ5M7K9N1P3R5S7U"
89+
90+
91+
def test_mcp_pydantic_model_dumps_to_snake_case_wire_keys() -> None:
92+
"""Adopters that construct ``McpWebhookPayload`` directly get the
93+
same wire shape as the dict path — single seam, no per-shape branch.
94+
"""
95+
model = McpWebhookPayload(
96+
idempotency_key="whk_01HW9D2T3VXQ5M7K9N1P3R5S7U",
97+
task_id="task_456",
98+
task_type="create_media_buy",
99+
status="completed",
100+
timestamp=datetime(2026, 5, 8, 12, 0, 0, tzinfo=timezone.utc),
101+
)
102+
103+
wire = to_wire_dict(model)
104+
105+
assert wire["task_id"] == "task_456"
106+
assert wire["task_type"] == "create_media_buy"
107+
assert wire["status"] == "completed"
108+
# ``mode="json", exclude_none=True`` is load-bearing — None fields
109+
# would otherwise pollute the wire body.
110+
assert "operation_id" not in wire
111+
assert "context_id" not in wire
112+
113+
114+
def test_plain_dict_passes_through_unchanged() -> None:
115+
"""Hand-built dicts (legacy adopter passthrough) round-trip verbatim."""
116+
raw = {"task_id": "t1", "status": "working", "extra": {"nested": True}}
117+
118+
wire = to_wire_dict(raw)
119+
120+
assert wire == raw
121+
# Defensive copy — caller mutating the returned dict must not
122+
# mutate the input.
123+
assert wire is not raw
124+
125+
126+
def test_unsupported_type_raises_type_error() -> None:
127+
"""Silent fallthrough would mask integration bugs — fail loud."""
128+
with pytest.raises(TypeError, match="Unsupported webhook payload type"):
129+
to_wire_dict("not a payload") # type: ignore[arg-type]

0 commit comments

Comments
 (0)