Skip to content

Commit 7718f44

Browse files
claudebokelley
authored andcommitted
fix(server): strip None-valued asset fields from dict-based response builder output
Optional ImageAsset / VideoAsset / UrlAsset fields (format, alt_text, provenance, container_format, etc.) default to None in the Pydantic models but the bundled JSON schemas declare them as non-nullable (\"type\": \"string\", not [\"string\", \"null\"]). When adopters build dict-based creative responses with those fields explicitly set to None, the null values reach the wire and cause the storyboard schema validator's oneOf branch to reject the asset. Add _strip_none_values() — a recursive helper that removes None-valued keys from dicts before wire serialisation — and apply it in _serialize() (for dict items after the write-only-field strip), sync_creatives_response(), preview_creative_response(), and build_creative_response(). The Pydantic model path was already safe via model_dump(exclude_none=True). Closes #622 https://claude.ai/code/session_01AkZRebtF7xsZJZ2FPnQmNL
1 parent 9eb962c commit 7718f44

2 files changed

Lines changed: 198 additions & 9 deletions

File tree

src/adcp/server/responses.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,27 @@ async def get_products():
3131
_logger = logging.getLogger("adcp.server")
3232

3333

34+
def _strip_none_values(value: Any) -> Any:
35+
"""Recursively strip None-valued keys from dicts and lists.
36+
37+
Applied to loose-dict items in asset-bearing response builders so that
38+
optional Pydantic fields (e.g. ``ImageAsset.format``) which default to
39+
``None`` in Python do not appear as ``null`` on the wire. The bundled
40+
JSON schemas declare those fields as non-nullable (``"type": "string"``,
41+
not ``["string", "null"]``), so a null value causes ``oneOf``/discriminator
42+
validation to fail at the buyer's schema validator.
43+
44+
Pydantic model items are not passed through this function — their
45+
``model_dump(exclude_none=True)`` call in :func:`_serialize` already
46+
handles null exclusion.
47+
"""
48+
if isinstance(value, dict):
49+
return {k: _strip_none_values(v) for k, v in value.items() if v is not None}
50+
if isinstance(value, list):
51+
return [_strip_none_values(v) for v in value]
52+
return value
53+
54+
3455
def _strip_write_only_fields(value: Any) -> Any:
3556
"""Recursively strip write-only credential fields from a wire dict.
3657
@@ -86,17 +107,19 @@ def _serialize(items: list[Any]) -> list[Any]:
86107
a hand-built response builder) get a recursive write-only-field
87108
strip via :func:`_strip_write_only_fields` so
88109
``governance_agents[i].authentication`` and ``billing_entity.bank``
89-
can't smuggle through. Pydantic models are passed through their
90-
own ``model_dump`` — the typed projections at
91-
:mod:`adcp.decisioning.account_projection` are responsible for
92-
those.
110+
can't smuggle through, followed by :func:`_strip_none_values` to
111+
remove ``null``-valued keys that the bundled JSON schemas declare as
112+
non-nullable (e.g. ``ImageAsset.format``). Pydantic models are
113+
passed through their own ``model_dump(exclude_none=True)`` — the
114+
typed projections at :mod:`adcp.decisioning.account_projection` are
115+
responsible for the write-only strip on that path.
93116
"""
94117
out: list[Any] = []
95118
for p in items:
96119
if hasattr(p, "model_dump"):
97120
out.append(p.model_dump(mode="json", exclude_none=True))
98121
elif isinstance(p, dict):
99-
out.append(_strip_write_only_fields(p))
122+
out.append(_strip_none_values(_strip_write_only_fields(p)))
100123
else:
101124
out.append(p)
102125
return out
@@ -425,7 +448,7 @@ def sync_creatives_response(
425448
Optionally: status ("processing"|"pending_review"|"approved"|"rejected"|"archived").
426449
Matches SyncCreativesResponse1 schema (field: "creatives").
427450
"""
428-
return {"creatives": creatives, "sandbox": sandbox}
451+
return {"creatives": _serialize(creatives), "sandbox": sandbox}
429452

430453

431454
def list_creatives_response(
@@ -492,7 +515,7 @@ def preview_creative_response(
492515
"""
493516
return {
494517
"response_type": "single",
495-
"previews": previews,
518+
"previews": _serialize(previews),
496519
"expires_at": expires_at or "2099-12-31T23:59:59Z",
497520
"sandbox": sandbox,
498521
}
@@ -513,11 +536,11 @@ def build_creative_response(
513536
"""
514537
if isinstance(creative_manifest, list):
515538
return {
516-
"creative_manifests": creative_manifest,
539+
"creative_manifests": [_strip_none_values(m) for m in creative_manifest],
517540
"sandbox": sandbox,
518541
}
519542
return {
520-
"creative_manifest": creative_manifest,
543+
"creative_manifest": _strip_none_values(creative_manifest),
521544
"sandbox": sandbox,
522545
}
523546

tests/test_server_dx.py

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,42 @@ def test_uses_creatives_key(self):
179179
assert "results" not in result
180180
assert result["creatives"] == creatives
181181

182+
def test_strips_none_from_sync_result_fields(self):
183+
"""None-valued fields in sync creative dicts are stripped from wire output."""
184+
creatives = [{"creative_id": "c1", "action": "created", "status": None}]
185+
result = sync_creatives_response(creatives)
186+
assert result["creatives"][0] == {"creative_id": "c1", "action": "created"}
187+
188+
189+
class TestStripNoneValues:
190+
"""_strip_none_values removes None-valued keys from dicts recursively."""
191+
192+
def test_flat_dict_strips_none(self):
193+
from adcp.server.responses import _strip_none_values
194+
195+
result = _strip_none_values({"a": "hello", "b": None, "c": 42})
196+
assert result == {"a": "hello", "c": 42}
197+
198+
def test_nested_dict_strips_none(self):
199+
from adcp.server.responses import _strip_none_values
200+
201+
result = _strip_none_values(
202+
{"outer": {"inner": None, "keep": "yes"}, "top_none": None}
203+
)
204+
assert result == {"outer": {"keep": "yes"}}
205+
206+
def test_list_items_stripped(self):
207+
from adcp.server.responses import _strip_none_values
208+
209+
result = _strip_none_values([{"x": None, "y": 1}, {"x": 2, "y": None}])
210+
assert result == [{"y": 1}, {"x": 2}]
211+
212+
def test_non_none_values_preserved(self):
213+
from adcp.server.responses import _strip_none_values
214+
215+
result = _strip_none_values({"a": 0, "b": False, "c": "", "d": []})
216+
assert result == {"a": 0, "b": False, "c": "", "d": []}
217+
182218

183219
class TestListCreativesResponse:
184220
def test_basic(self):
@@ -225,6 +261,68 @@ def test_preserves_caller_provided_timestamps(self):
225261
assert item["created_date"] == created
226262
assert item["updated_date"] == updated
227263

264+
def test_strips_none_from_asset_fields_in_dict_creatives(self):
265+
"""None-valued asset fields must not appear as null on the wire.
266+
267+
ImageAsset.format / alt_text / provenance are optional (non-required)
268+
in the JSON schema but non-nullable (type: string, not [string, null]).
269+
When an adopter builds a dict-based creative with None-valued asset
270+
fields, the response builder must strip them before wire serialisation
271+
so the storyboard schema validator does not reject the payload.
272+
"""
273+
creative = {
274+
"creative_id": "c1",
275+
"created_date": "2024-01-01T00:00:00+00:00",
276+
"updated_date": "2024-01-01T00:00:00+00:00",
277+
"assets": {
278+
"banner": {
279+
"asset_type": "image",
280+
"url": "https://cdn.example.com/banner.png",
281+
"width": 300,
282+
"height": 250,
283+
"format": None,
284+
"alt_text": None,
285+
"provenance": None,
286+
}
287+
},
288+
}
289+
result = list_creatives_response([creative])
290+
asset = result["creatives"][0]["assets"]["banner"]
291+
assert "format" not in asset, "format: null must be stripped from wire output"
292+
assert "alt_text" not in asset, "alt_text: null must be stripped from wire output"
293+
assert "provenance" not in asset, "provenance: null must be stripped from wire output"
294+
assert asset["asset_type"] == "image"
295+
assert asset["url"] == "https://cdn.example.com/banner.png"
296+
assert asset["width"] == 300
297+
assert asset["height"] == 250
298+
299+
def test_strips_none_from_video_asset_fields(self):
300+
"""VideoAsset optional fields (container_format, video_codec, etc.) are non-nullable."""
301+
creative = {
302+
"creative_id": "v1",
303+
"created_date": "2024-01-01T00:00:00+00:00",
304+
"updated_date": "2024-01-01T00:00:00+00:00",
305+
"assets": {
306+
"main_video": {
307+
"asset_type": "video",
308+
"url": "https://cdn.example.com/video.mp4",
309+
"width": 1920,
310+
"height": 1080,
311+
"container_format": None,
312+
"video_codec": None,
313+
"duration_ms": None,
314+
"provenance": None,
315+
}
316+
},
317+
}
318+
result = list_creatives_response([creative])
319+
asset = result["creatives"][0]["assets"]["main_video"]
320+
assert "container_format" not in asset
321+
assert "video_codec" not in asset
322+
assert "duration_ms" not in asset
323+
assert "provenance" not in asset
324+
assert asset["asset_type"] == "video"
325+
228326

229327
class TestPreviewCreativeResponse:
230328
def test_basic(self):
@@ -234,6 +332,32 @@ def test_basic(self):
234332
assert result["previews"] == previews
235333
assert "expires_at" in result
236334

335+
def test_strips_none_from_asset_fields_in_preview(self):
336+
"""None asset fields in preview input are stripped from wire output."""
337+
previews = [
338+
{
339+
"preview_id": "p1",
340+
"input": {
341+
"assets": {
342+
"hero": {
343+
"asset_type": "image",
344+
"url": "https://cdn.example.com/hero.png",
345+
"width": 1200,
346+
"height": 628,
347+
"alt_text": None,
348+
"format": None,
349+
}
350+
}
351+
},
352+
"renders": [],
353+
}
354+
]
355+
result = preview_creative_response(previews)
356+
asset = result["previews"][0]["input"]["assets"]["hero"]
357+
assert "alt_text" not in asset
358+
assert "format" not in asset
359+
assert asset["asset_type"] == "image"
360+
237361

238362
class TestBuildCreativeResponse:
239363
def test_basic(self):
@@ -242,6 +366,48 @@ def test_basic(self):
242366
assert result["creative_manifest"] == manifest
243367
assert result["sandbox"] is True
244368

369+
def test_strips_none_from_asset_fields_in_manifest(self):
370+
"""None asset fields in build_creative manifest are stripped from wire output."""
371+
manifest = {
372+
"format_id": {"agent_url": "http://localhost", "id": "d300"},
373+
"name": "Test",
374+
"assets": {
375+
"banner": {
376+
"asset_type": "image",
377+
"url": "https://cdn.example.com/banner.png",
378+
"width": 300,
379+
"height": 250,
380+
"format": None,
381+
"alt_text": None,
382+
}
383+
},
384+
}
385+
result = build_creative_response(manifest)
386+
asset = result["creative_manifest"]["assets"]["banner"]
387+
assert "format" not in asset
388+
assert "alt_text" not in asset
389+
assert asset["url"] == "https://cdn.example.com/banner.png"
390+
391+
def test_strips_none_from_multi_manifest(self):
392+
"""None stripping works for multi-manifest (list) variant."""
393+
manifests = [
394+
{
395+
"name": "A",
396+
"assets": {
397+
"img": {
398+
"asset_type": "image",
399+
"url": "u",
400+
"width": 1,
401+
"height": 1,
402+
"format": None,
403+
}
404+
},
405+
}
406+
]
407+
result = build_creative_response(manifests)
408+
asset = result["creative_manifests"][0]["assets"]["img"]
409+
assert "format" not in asset
410+
245411

246412
class TestSignalsResponse:
247413
def test_basic(self):

0 commit comments

Comments
 (0)