From 1c533fcb6c8de704db5db03d62c2255bdb210084 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 01:10:04 +0000 Subject: [PATCH 1/2] fix(compat): handle inline BrandManifest object in v2.5 adapters (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both get_products and create_media_buy adapters only handled the URL string branch of v2.5 brand_manifest. The spec defines brand_manifest as a oneOf (URL string OR inline BrandManifest object), so inline objects were silently dropped — brand was never set, causing confusing downstream validation failures. Add elif isinstance(brand_manifest, dict) branch at both sites: extract domain from the object's url field when present; when url is absent (spec-valid per brand-manifest.json which only requires name), brand is omitted and v3 validation decides. Adds 8 new tests covering the dict happy path, no-url pass-through, url-with-path, and brand-wins cases. https://claude.ai/code/session_01Nz3E58qfSW4X6fdJngTxKt --- .../compat/legacy/v2_5/_media_buy_helpers.py | 15 ++++- src/adcp/compat/legacy/v2_5/get_products.py | 21 ++++--- .../test_legacy_adapter_v2_5_get_products.py | 56 +++++++++++++++++++ tests/test_legacy_adapter_v2_5_media_buy.py | 45 +++++++++++++++ 4 files changed, 126 insertions(+), 11 deletions(-) diff --git a/src/adcp/compat/legacy/v2_5/_media_buy_helpers.py b/src/adcp/compat/legacy/v2_5/_media_buy_helpers.py index 3d6b361c..06d857ea 100644 --- a/src/adcp/compat/legacy/v2_5/_media_buy_helpers.py +++ b/src/adcp/compat/legacy/v2_5/_media_buy_helpers.py @@ -18,9 +18,14 @@ def adapt_brand_manifest_to_brand(payload: dict[str, Any]) -> dict[str, Any]: - """Rewrite v2.5 ``brand_manifest`` (URL string) to v3 ``brand`` - ``{domain: ...}``. Caller-supplied ``brand`` wins when both fields - are present (half-migrated buyer). + """Rewrite v2.5 ``brand_manifest`` (URL string or inline BrandManifest + object) to v3 ``brand`` ``{domain: ...}``. Caller-supplied ``brand`` + wins when both fields are present (half-migrated buyer). + + v2.5 ``brand-manifest-ref.json`` is a oneOf: either a URL string or an + inline BrandManifest object. For the inline case, the ``url`` field is + optional; when absent there is no derivable hostname, so ``brand`` is + omitted and v3 validation decides whether to reject. Uses ``extract_brand_domain`` to isolate the hostname from full URLs (e.g. ``"https://acme.com/.well-known/brand.json"`` → ``"acme.com"``) @@ -30,6 +35,10 @@ def adapt_brand_manifest_to_brand(payload: dict[str, Any]) -> dict[str, Any]: manifest = out.pop("brand_manifest", None) if isinstance(manifest, str) and manifest and "brand" not in out: out["brand"] = {"domain": extract_brand_domain(manifest)} + elif isinstance(manifest, dict) and "brand" not in out: + url = manifest.get("url") + if isinstance(url, str) and url: + out["brand"] = {"domain": extract_brand_domain(url)} return out diff --git a/src/adcp/compat/legacy/v2_5/get_products.py b/src/adcp/compat/legacy/v2_5/get_products.py index caf37fed..ac0605bb 100644 --- a/src/adcp/compat/legacy/v2_5/get_products.py +++ b/src/adcp/compat/legacy/v2_5/get_products.py @@ -127,14 +127,19 @@ def adapt_request(payload: dict[str, Any]) -> dict[str, Any]: """Translate a v2.5 ``get_products`` request to v3 shape.""" out = dict(payload) - # brand_manifest (v2.5 URL string) → brand.domain (v3 BrandReference). - # v2.5 spec documents brand_manifest as a URL to a JSON file, so paths - # are the expected input shape. extract_brand_domain uses urlparse to - # isolate the hostname so the result satisfies BrandReference.domain's - # hostname-only regex. + # brand_manifest (v2.5 URL string or inline BrandManifest object) → + # brand.domain (v3 BrandReference). + # v2.5 brand-manifest-ref.json is a oneOf: either a URL string or an + # inline BrandManifest object. The inline schema has url as optional; + # when absent there is no derivable hostname, so we skip brand and let + # v3 validation handle the missing field. brand_manifest = out.pop("brand_manifest", None) if isinstance(brand_manifest, str) and brand_manifest and "brand" not in out: out["brand"] = {"domain": extract_brand_domain(brand_manifest)} + elif isinstance(brand_manifest, dict) and "brand" not in out: + url = brand_manifest.get("url") + if isinstance(url, str) and url: + out["brand"] = {"domain": extract_brand_domain(url)} # promoted_offerings → catalog promoted = out.pop("promoted_offerings", None) @@ -235,9 +240,9 @@ def normalize_response(response: dict[str, Any]) -> dict[str, Any]: def is_legacy_shape(payload: dict[str, Any]) -> bool: """v2.5 ``get_products`` carries either ``brand_manifest`` (URL - string field that v3 doesn't have) or ``promoted_offerings`` - (nested object replaced by ``catalog`` in v3). Either is a - strong signal.""" + string or inline object — v3 has no ``brand_manifest`` key) or + ``promoted_offerings`` (nested object replaced by ``catalog`` in v3). + Either is a strong signal.""" return "brand_manifest" in payload or "promoted_offerings" in payload diff --git a/tests/test_legacy_adapter_v2_5_get_products.py b/tests/test_legacy_adapter_v2_5_get_products.py index 71ace69a..2c42f4f9 100644 --- a/tests/test_legacy_adapter_v2_5_get_products.py +++ b/tests/test_legacy_adapter_v2_5_get_products.py @@ -83,6 +83,62 @@ def test_brand_field_takes_precedence_over_manifest() -> None: assert "brand_manifest" not in out +# --------------------------------------------------------------------------- +# Request: brand_manifest as inline BrandManifest object (#684) +# --------------------------------------------------------------------------- + + +def test_brand_manifest_inline_object_with_url_becomes_brand_domain() -> None: + """Inline BrandManifest object with url extracts hostname for brand.domain.""" + out = _adapt( + { + "brand_manifest": { + "url": "https://acme.example.com", + "name": "ACME Corp", + } + } + ) + assert out["brand"] == {"domain": "acme.example.com"} + assert "brand_manifest" not in out + + +def test_brand_manifest_inline_object_url_with_path_extracts_hostname() -> None: + """Inline object url may be a full URL with path — only hostname survives.""" + out = _adapt( + { + "brand_manifest": { + "url": "https://acme.com/.well-known/brand.json", + "name": "ACME Corp", + } + } + ) + assert out["brand"] == {"domain": "acme.com"} + assert "brand_manifest" not in out + + +def test_brand_manifest_inline_object_no_url_skips_brand() -> None: + """Inline object without url has no derivable hostname; brand is omitted. + + The BrandManifest schema marks url as optional (only name is required). + The adapter skips brand and lets v3 validation decide — it does not raise. + """ + out = _adapt({"brand_manifest": {"name": "Great Value"}}) + assert "brand" not in out + assert "brand_manifest" not in out + + +def test_brand_manifest_inline_object_brand_wins_when_both_present() -> None: + """Half-migrated buyer sends explicit brand alongside inline object — keep brand.""" + out = _adapt( + { + "brand_manifest": {"url": "https://acme.example.com", "name": "ACME Corp"}, + "brand": {"domain": "explicit.example.com"}, + } + ) + assert out["brand"] == {"domain": "explicit.example.com"} + assert "brand_manifest" not in out + + # --------------------------------------------------------------------------- # Request: promoted_offerings → catalog # --------------------------------------------------------------------------- diff --git a/tests/test_legacy_adapter_v2_5_media_buy.py b/tests/test_legacy_adapter_v2_5_media_buy.py index d96062c3..cf7f1987 100644 --- a/tests/test_legacy_adapter_v2_5_media_buy.py +++ b/tests/test_legacy_adapter_v2_5_media_buy.py @@ -74,6 +74,51 @@ def test_create_media_buy_no_brand_manifest_passes_through() -> None: assert "brand_manifest" not in out +# --------------------------------------------------------------------------- +# create_media_buy request: brand_manifest as inline object (#684) +# --------------------------------------------------------------------------- + + +def test_create_media_buy_brand_manifest_inline_object_with_url() -> None: + """Inline BrandManifest object with url extracts hostname for brand.domain.""" + out = v2_5_cmb.adapt_request( + {"brand_manifest": {"url": "https://acme.example.com", "name": "ACME Corp"}} + ) + assert out["brand"] == {"domain": "acme.example.com"} + assert "brand_manifest" not in out + + +def test_create_media_buy_brand_manifest_inline_object_url_with_path() -> None: + out = v2_5_cmb.adapt_request( + { + "brand_manifest": { + "url": "https://acme.com/.well-known/brand.json", + "name": "ACME Corp", + } + } + ) + assert out["brand"] == {"domain": "acme.com"} + assert "brand_manifest" not in out + + +def test_create_media_buy_brand_manifest_inline_object_no_url_skips_brand() -> None: + """Inline object without url (spec-valid) omits brand; no exception raised.""" + out = v2_5_cmb.adapt_request({"brand_manifest": {"name": "Great Value"}}) + assert "brand" not in out + assert "brand_manifest" not in out + + +def test_create_media_buy_brand_manifest_inline_object_brand_wins_when_both_present() -> None: + out = v2_5_cmb.adapt_request( + { + "brand_manifest": {"url": "https://acme.example.com", "name": "ACME Corp"}, + "brand": {"domain": "explicit.example.com"}, + } + ) + assert out["brand"] == {"domain": "explicit.example.com"} + assert "brand_manifest" not in out + + # --------------------------------------------------------------------------- # Package request: creative_ids → creative_assignments (both tools) # --------------------------------------------------------------------------- From ce1ffda1931f1d98113d08cf352a834cb00ef0cd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 01:16:38 +0000 Subject: [PATCH 2/2] fix(compat): guard whitespace-only url in inline BrandManifest dict branch url.strip() prevents a whitespace-only url (e.g. " ") from passing the truthiness check and producing {"domain": ""} via extract_brand_domain, which would hit a noisy v3 validation error instead of the clean silent-skip the no-url branch provides. https://claude.ai/code/session_01Nz3E58qfSW4X6fdJngTxKt --- src/adcp/compat/legacy/v2_5/_media_buy_helpers.py | 2 +- src/adcp/compat/legacy/v2_5/get_products.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/adcp/compat/legacy/v2_5/_media_buy_helpers.py b/src/adcp/compat/legacy/v2_5/_media_buy_helpers.py index 06d857ea..f6a7ea96 100644 --- a/src/adcp/compat/legacy/v2_5/_media_buy_helpers.py +++ b/src/adcp/compat/legacy/v2_5/_media_buy_helpers.py @@ -37,7 +37,7 @@ def adapt_brand_manifest_to_brand(payload: dict[str, Any]) -> dict[str, Any]: out["brand"] = {"domain": extract_brand_domain(manifest)} elif isinstance(manifest, dict) and "brand" not in out: url = manifest.get("url") - if isinstance(url, str) and url: + if isinstance(url, str) and url.strip(): out["brand"] = {"domain": extract_brand_domain(url)} return out diff --git a/src/adcp/compat/legacy/v2_5/get_products.py b/src/adcp/compat/legacy/v2_5/get_products.py index ac0605bb..b0d323c1 100644 --- a/src/adcp/compat/legacy/v2_5/get_products.py +++ b/src/adcp/compat/legacy/v2_5/get_products.py @@ -138,7 +138,7 @@ def adapt_request(payload: dict[str, Any]) -> dict[str, Any]: out["brand"] = {"domain": extract_brand_domain(brand_manifest)} elif isinstance(brand_manifest, dict) and "brand" not in out: url = brand_manifest.get("url") - if isinstance(url, str) and url: + if isinstance(url, str) and url.strip(): out["brand"] = {"domain": extract_brand_domain(url)} # promoted_offerings → catalog