|
| 1 | +"""Shared v2.5 ↔ v3 helpers for ``create_media_buy`` and ``update_media_buy``. |
| 2 | +
|
| 3 | +These two tools share most of the wire-shape deltas (packages, |
| 4 | +``brand_manifest``, ``buyer_ref``), so the per-tool adapter modules |
| 5 | +re-export the same primitives from here. |
| 6 | +
|
| 7 | +Direct port (with direction inverted) of |
| 8 | +``src/lib/utils/creative-adapter.ts``. The JS direction is *client* |
| 9 | +side (v3 → v2). Our server-side direction is v2 → v3 for requests and |
| 10 | +v3 → v2 for responses. |
| 11 | +""" |
| 12 | + |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +from typing import Any |
| 16 | + |
| 17 | +from adcp.compat.legacy.v2_5._url import strip_url_scheme |
| 18 | + |
| 19 | + |
| 20 | +def adapt_brand_manifest_to_brand(payload: dict[str, Any]) -> dict[str, Any]: |
| 21 | + """Rewrite v2.5 ``brand_manifest`` (URL string) to v3 ``brand`` |
| 22 | + ``{domain: ...}``. Caller-supplied ``brand`` wins when both fields |
| 23 | + are present (half-migrated buyer).""" |
| 24 | + out = dict(payload) |
| 25 | + manifest = out.pop("brand_manifest", None) |
| 26 | + if isinstance(manifest, str) and manifest and "brand" not in out: |
| 27 | + out["brand"] = {"domain": strip_url_scheme(manifest)} |
| 28 | + return out |
| 29 | + |
| 30 | + |
| 31 | +def adapt_package_request(pkg: dict[str, Any]) -> dict[str, Any]: |
| 32 | + """Rewrite a v2.5 package request to v3 shape. |
| 33 | +
|
| 34 | + Translations: |
| 35 | +
|
| 36 | + * ``creative_ids: list[str]`` → ``creative_assignments: list[{creative_id}]``. |
| 37 | + v2.5 packages reference creatives by ID alone; v3 wraps each in an |
| 38 | + assignment object so future ``weight`` / ``placement_ids`` fields |
| 39 | + can attach. The v2.5 → v3 path can't synthesize those (the buyer |
| 40 | + never sent them), so we just lift the ID. |
| 41 | + * ``buyer_ref`` survives unchanged. v3 doesn't model it on packages |
| 42 | + explicitly but tolerates ``additionalProperties`` per the spec, so |
| 43 | + passing it through preserves the buyer's idempotency-by-name |
| 44 | + semantics if their handler expects it. |
| 45 | + """ |
| 46 | + out = dict(pkg) |
| 47 | + creative_ids = out.get("creative_ids") |
| 48 | + if isinstance(creative_ids, list): |
| 49 | + out.pop("creative_ids", None) |
| 50 | + out["creative_assignments"] = [ |
| 51 | + {"creative_id": cid} for cid in creative_ids if isinstance(cid, str) |
| 52 | + ] |
| 53 | + return out |
| 54 | + |
| 55 | + |
| 56 | +def normalize_package_response(pkg: dict[str, Any]) -> dict[str, Any]: |
| 57 | + """Rewrite a v3 package response back to v2.5 shape for legacy buyers. |
| 58 | +
|
| 59 | + Translations: |
| 60 | +
|
| 61 | + * ``creative_assignments: [{creative_id, weight?, placement_ids?}]`` |
| 62 | + → ``creative_ids: [creative_id, ...]``. **Lossy** — ``weight`` and |
| 63 | + ``placement_ids`` have no v2.5 equivalent and are dropped. v2.5 |
| 64 | + buyers can't act on them, so silent collapse is acceptable. |
| 65 | + * Null arrays (``creative_assignments: null``, ``creative_ids: null``, |
| 66 | + ``products: null``) are coerced to absent fields. Some servers emit |
| 67 | + explicit ``null`` for optional arrays; downstream consumers expect |
| 68 | + either absent or a real list. |
| 69 | + """ |
| 70 | + cleaned = dict(pkg) |
| 71 | + for field in ("creative_assignments", "creative_ids", "products"): |
| 72 | + if cleaned.get(field) is None and field in cleaned: |
| 73 | + cleaned.pop(field, None) |
| 74 | + |
| 75 | + assignments = cleaned.get("creative_assignments") |
| 76 | + if isinstance(assignments, list): |
| 77 | + creative_ids: list[str] = [] |
| 78 | + for assignment in assignments: |
| 79 | + if not isinstance(assignment, dict): |
| 80 | + continue |
| 81 | + cid = assignment.get("creative_id") |
| 82 | + if isinstance(cid, str): |
| 83 | + creative_ids.append(cid) |
| 84 | + cleaned.pop("creative_assignments", None) |
| 85 | + cleaned["creative_ids"] = creative_ids |
| 86 | + |
| 87 | + return cleaned |
| 88 | + |
| 89 | + |
| 90 | +def normalize_media_buy_response(response: dict[str, Any]) -> dict[str, Any]: |
| 91 | + """Apply :func:`normalize_package_response` to every package in a |
| 92 | + media-buy response. Pass-through when ``packages`` is absent (error |
| 93 | + responses, terminal envelopes).""" |
| 94 | + packages = response.get("packages") |
| 95 | + if not isinstance(packages, list): |
| 96 | + return response |
| 97 | + return { |
| 98 | + **response, |
| 99 | + "packages": [normalize_package_response(p) if isinstance(p, dict) else p for p in packages], |
| 100 | + } |
0 commit comments