Skip to content

Commit 7e5f2c4

Browse files
bokelleyclaude
andauthored
feat(decisioning): createUpstreamHttpClient + createTranslationMap (#464)
* feat(decisioning): createUpstreamHttpClient + createTranslationMap (translator helpers) Port the JS @adcp/sdk@6.7 helpers to Python so translator-pattern adopters (Prebid, GAM-shaped publishers, the v3 reference seller) stop hand-rolling the same ~200 lines of httpx boilerplate per adapter. create_upstream_http_client wraps httpx.AsyncClient with auth injection (StaticBearer / DynamicBearer / ApiKey / NoAuth as a discriminated dataclass union), default-header merge, query-string serialization, 404→None for resource lookups, and projection of non-2xx responses to spec-conformant AdcpError codes (AUTH_REQUIRED, PERMISSION_DENIED, MEDIA_BUY_NOT_FOUND, RATE_LIMITED, SERVICE_UNAVAILABLE, INVALID_REQUEST). DynamicBearer.get_token receives a per-call auth_context so the same client supports single-key tenant fan-out, per-operator credential lookup, and pass-through of the caller's principal without per-operator client construction. The 404 code is configurable per call via not_found_code for endpoints whose missing-resource semantics aren't a media buy. create_translation_map provides a bidirectional, type-safe key mapping (AdCP wire ↔ upstream platform values) with to_upstream / to_adcp / has_adcp / has_upstream methods. Collisions in either direction are detected at construction time so silent overwrite can't produce wrong-tenant routing in production. Doesn't refactor the v3 reference seller's hand-rolled upstream.py to use the helpers — that's a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(upstream): treat 3xx as error gate + widen return type to Any (#464 fix-pack) Code reviewer flagged two should-fix issues: 1. response.status_code >= 400 skipped 3xx redirects, which then crashed on response.json() for non-JSON redirect bodies. Lowered to >= 300. httpx defaults to no auto-follow, so any 3xx from a misconfigured upstream now produces a structured AdcpError instead of a JSONDecodeError. 2. Return type was dict[str, Any] but JSON arrays/scalars at the top level (list endpoints) violated the annotation. Widened to Any — adopters narrow at the call site. 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 9301acf commit 7e5f2c4

4 files changed

Lines changed: 1044 additions & 0 deletions

File tree

src/adcp/decisioning/__init__.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,10 @@ def create_media_buy(
147147
TaskRegistry,
148148
TaskState,
149149
)
150+
from adcp.decisioning.translation import (
151+
TranslationMap,
152+
create_translation_map,
153+
)
150154
from adcp.decisioning.types import (
151155
Account,
152156
AdcpError,
@@ -155,6 +159,16 @@ def create_media_buy(
155159
TaskHandoff,
156160
WorkflowHandoff,
157161
)
162+
from adcp.decisioning.upstream import (
163+
ApiKey,
164+
AuthContext,
165+
DynamicBearer,
166+
NoAuth,
167+
StaticBearer,
168+
UpstreamAuth,
169+
UpstreamHttpClient,
170+
create_upstream_http_client,
171+
)
158172

159173
# Conditional import: PgTaskRegistry needs the [pg] extra. Always expose
160174
# the name — when psycopg isn't installed we fall through to a stub class whose
@@ -195,7 +209,9 @@ def __init__(self, *args: object, **kwargs: object) -> None:
195209
"AccountNotFoundError",
196210
"AccountStore",
197211
"AdcpError",
212+
"ApiKey",
198213
"ApiKeyCredential",
214+
"AuthContext",
199215
"AudiencePlatform",
200216
"AuditingBuyerAgentRegistry",
201217
"AuthInfo",
@@ -218,6 +234,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
218234
"CreativeBuilderPlatform",
219235
"DecisioningCapabilities",
220236
"DecisioningPlatform",
237+
"DynamicBearer",
221238
"ExplicitAccounts",
222239
"Format",
223240
"FormatReferenceStructuredObject",
@@ -231,6 +248,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
231248
"MaybeAsync",
232249
"MediaBuyNotFoundError",
233250
"MockAdServer",
251+
"NoAuth",
234252
"OAuthCredential",
235253
"PermissionDeniedError",
236254
"PgTaskRegistry",
@@ -249,11 +267,15 @@ def __init__(self, *args: object, **kwargs: object) -> None:
249267
"SignalsPlatform",
250268
"SingletonAccounts",
251269
"StateReader",
270+
"StaticBearer",
252271
"TaskHandoff",
253272
"TaskHandoffContext",
254273
"TaskRegistry",
255274
"TaskState",
275+
"TranslationMap",
256276
"UnsupportedFeatureError",
277+
"UpstreamAuth",
278+
"UpstreamHttpClient",
257279
"ValidationError",
258280
"WorkflowHandoff",
259281
"WorkflowObjectType",
@@ -262,6 +284,8 @@ def __init__(self, *args: object, **kwargs: object) -> None:
262284
"assert_media_buy_transition",
263285
"bearer_only_registry",
264286
"create_adcp_server_from_platform",
287+
"create_translation_map",
288+
"create_upstream_http_client",
265289
"mixed_registry",
266290
"project_account_for_response",
267291
"project_business_entity_for_response",
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"""Bidirectional, type-safe key translation between AdCP and upstream values.
2+
3+
Every translator-pattern adapter owes the framework two things: a thin
4+
HTTP client (see :mod:`adcp.decisioning.upstream`) and a set of small
5+
bidirectional key maps (AdCP ``"olv"`` ↔ upstream ``"video"``, AdCP
6+
``"guaranteed"`` ↔ upstream ``"GUARANTEED_AT_PRICE"``, …).
7+
:func:`create_translation_map` ships the map primitive so adapters
8+
declare the mapping once and the framework projects it both ways.
9+
10+
Mirrors the JS ``createTranslationMap`` helper from
11+
``@adcp/sdk@6.7`` (``src/lib/server/upstream-helpers.ts``).
12+
"""
13+
14+
from __future__ import annotations
15+
16+
from collections.abc import Mapping
17+
from typing import Generic, TypeVar
18+
19+
A = TypeVar("A")
20+
U = TypeVar("U")
21+
22+
23+
class TranslationMap(Generic[A, U]):
24+
"""Reversible mapping between AdCP wire values and upstream platform values.
25+
26+
Construct via :func:`create_translation_map`. Both lookup methods
27+
raise :class:`KeyError` for unknown keys unless a default is
28+
configured. Use the ``has_*`` predicates to guard a lookup, or
29+
pass ``default_adcp`` / ``default_upstream`` for graceful
30+
fallback.
31+
32+
Example::
33+
34+
channel_map = create_translation_map({
35+
"olv": "video",
36+
"ctv": "ctv",
37+
"display": "display",
38+
"streaming_audio": "audio",
39+
})
40+
channel_map.to_upstream("olv") # "video"
41+
channel_map.to_adcp("video") # "olv"
42+
channel_map.has_adcp("olv") # True
43+
channel_map.has_upstream("audio") # True
44+
"""
45+
46+
__slots__ = ("_forward", "_reverse", "_default_adcp", "_default_upstream")
47+
48+
def __init__(
49+
self,
50+
adcp_to_upstream: Mapping[A, U],
51+
*,
52+
default_adcp: A | None = None,
53+
default_upstream: U | None = None,
54+
) -> None:
55+
self._forward: dict[A, U] = dict(adcp_to_upstream)
56+
self._default_adcp = default_adcp
57+
self._default_upstream = default_upstream
58+
59+
# Build reverse map and detect collisions in the same pass. Two
60+
# AdCP keys mapping to the same upstream value would silently
61+
# overwrite without this check — fail loud at construction so
62+
# the bug is caught at boot, not on a request.
63+
reverse: dict[U, A] = {}
64+
for adcp_key, upstream_key in self._forward.items():
65+
if upstream_key in reverse:
66+
raise ValueError(
67+
f"translation collision: AdCP keys "
68+
f"{reverse[upstream_key]!r} and {adcp_key!r} both map to "
69+
f"upstream value {upstream_key!r}"
70+
)
71+
reverse[upstream_key] = adcp_key
72+
self._reverse: dict[U, A] = reverse
73+
74+
def to_upstream(self, adcp_key: A) -> U:
75+
"""Translate an AdCP wire value to the upstream platform value.
76+
77+
:raises KeyError: when ``adcp_key`` isn't in the map and no
78+
``default_upstream`` was provided.
79+
"""
80+
if adcp_key in self._forward:
81+
return self._forward[adcp_key]
82+
if self._default_upstream is not None:
83+
return self._default_upstream
84+
raise KeyError(f"unknown AdCP key: {adcp_key!r}")
85+
86+
def to_adcp(self, upstream_key: U) -> A:
87+
"""Translate an upstream platform value back to the AdCP wire value.
88+
89+
:raises KeyError: when ``upstream_key`` isn't in the map and
90+
no ``default_adcp`` was provided.
91+
"""
92+
if upstream_key in self._reverse:
93+
return self._reverse[upstream_key]
94+
if self._default_adcp is not None:
95+
return self._default_adcp
96+
raise KeyError(f"unknown upstream key: {upstream_key!r}")
97+
98+
def has_adcp(self, value: object) -> bool:
99+
"""True when ``value`` is a known AdCP-side key."""
100+
return value in self._forward
101+
102+
def has_upstream(self, value: object) -> bool:
103+
"""True when ``value`` is a known upstream-side key."""
104+
return value in self._reverse
105+
106+
107+
def create_translation_map(
108+
adcp_to_upstream: Mapping[A, U],
109+
*,
110+
default_adcp: A | None = None,
111+
default_upstream: U | None = None,
112+
) -> TranslationMap[A, U]:
113+
"""Build a bidirectional translation map from an AdCP→upstream record.
114+
115+
Keys on the left side are AdCP wire values; values on the right
116+
are upstream platform values. Collisions in either direction (two
117+
AdCP keys mapping to the same upstream value) are detected at
118+
construction time and raise :class:`ValueError` — silent
119+
overwrite would produce wrong-tenant routing in production.
120+
121+
:param adcp_to_upstream: ``{adcp_key: upstream_key}`` mapping.
122+
:param default_adcp: Returned by :meth:`TranslationMap.to_adcp`
123+
when the upstream key isn't in the map. ``None`` (default)
124+
raises ``KeyError``.
125+
:param default_upstream: Returned by
126+
:meth:`TranslationMap.to_upstream` when the AdCP key isn't in
127+
the map. ``None`` (default) raises ``KeyError``.
128+
129+
Example::
130+
131+
delivery_map = create_translation_map({
132+
"guaranteed": "GUARANTEED_AT_PRICE",
133+
"non_guaranteed": "STANDARD",
134+
})
135+
delivery_map.to_upstream("guaranteed") # "GUARANTEED_AT_PRICE"
136+
delivery_map.to_adcp("STANDARD") # "non_guaranteed"
137+
"""
138+
return TranslationMap(
139+
adcp_to_upstream,
140+
default_adcp=default_adcp,
141+
default_upstream=default_upstream,
142+
)
143+
144+
145+
__all__ = ["TranslationMap", "create_translation_map"]

0 commit comments

Comments
 (0)