Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/adcp/compat/legacy/v2_5/_media_buy_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"``)
Expand All @@ -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.strip():
out["brand"] = {"domain": extract_brand_domain(url)}
return out


Expand Down
21 changes: 13 additions & 8 deletions src/adcp/compat/legacy/v2_5/get_products.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.strip():
out["brand"] = {"domain": extract_brand_domain(url)}

# promoted_offerings → catalog
promoted = out.pop("promoted_offerings", None)
Expand Down Expand Up @@ -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


Expand Down
56 changes: 56 additions & 0 deletions tests/test_legacy_adapter_v2_5_get_products.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
45 changes: 45 additions & 0 deletions tests/test_legacy_adapter_v2_5_media_buy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# ---------------------------------------------------------------------------
Expand Down
Loading