Skip to content

Commit defa022

Browse files
authored
feat(decisioning): createOAuthPassthroughResolver — Shape B account resolver factory (#472)
* feat(decisioning): createOAuthPassthroughResolver — Shape B account resolver factory Port of @adcp/sdk@6.7's `createOAuthPassthroughResolver` (issue #458). Standardises the canonical "Shape B" account-resolution pattern: an adapter wraps a vendor OAuth + ad-account API (Snap, Meta, TikTok-shaped) and resolves the buyer's `AccountReference` by hitting the upstream's `/me/adaccounts`-shaped listing endpoint with the buyer's bearer. Behaviour: - Only the `{account_id}` discriminated-union arm is handled; natural-key refs and `None` return `None` without calling upstream. - Bearer pass-through via `DynamicBearer` on the upstream client; the factory forwards `ctx.auth_info` to `get_token` by default. - Upstream errors propagate verbatim — the upstream client already projects non-2xx to spec-conformant `AdcpError` codes. - `extract_rows` is a callable (Python diverges from JS's `rowsPath` string) defaulting to flat-list-or-`{"data": []}`. Adopters with deeper-nested shapes pass their own callback. Refs #458, parent #452. * fix(oauth-passthrough): conform to AccountStore Protocol, return AccountStore-shaped object Address code-review BLOCKERS: returned resolver had a (ref, ctx) signature that wouldn't match the framework dispatcher (which calls resolve with auth_info kwarg), and the docstring example referenced a phantom ExplicitAccounts(resolve=) pattern that doesn't exist. - Factory returns _OAuthPassthroughAccountStore: a class whose resolve(ref, auth_info=None) matches the Protocol. Adopters wire the returned object directly into DecisioningPlatform.accounts. - ResolveContext is synthesized inside resolve so adopter callbacks (to_account, get_auth_context) keep the ctx-based API uniform with upsert/list/sync_governance. - Tests now import AccountReferenceById from adcp.types instead of reaching into generated_poc/ (CLAUDE.md layering rule). - Test brand placeholders changed to Globex. - Pagination limitation documented in module + factory docstrings: single GET, paginated upstreams must aggregate inside extract_rows or compose their own resolver. Async-iterator support is a follow-up. - Six unit tests added for _default_extract_rows edge cases.
1 parent 7f919cc commit defa022

3 files changed

Lines changed: 779 additions & 0 deletions

File tree

src/adcp/decisioning/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ def create_media_buy(
9393
InMemoryMockAdServer,
9494
MockAdServer,
9595
)
96+
from adcp.decisioning.oauth_passthrough import (
97+
create_oauth_passthrough_resolver,
98+
)
9699
from adcp.decisioning.platform import (
97100
GOVERNANCE_SPECIALISMS,
98101
DecisioningCapabilities,
@@ -313,6 +316,7 @@ def __init__(self, *args: object, **kwargs: object) -> None:
313316
"bearer_only_registry",
314317
"compose_method",
315318
"create_adcp_server_from_platform",
319+
"create_oauth_passthrough_resolver",
316320
"create_roster_account_store",
317321
"create_translation_map",
318322
"create_upstream_http_client",
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""OAuth pass-through ``AccountStore`` factory ("Shape B").
2+
3+
Standardises the canonical Shape B account-resolution pattern: an
4+
adapter wraps a vendor OAuth + ad-account API (e.g. social-platform ad
5+
management APIs that expose ``/me/adaccounts``-shaped endpoints) and
6+
resolves the buyer's :class:`AccountReference` by hitting the
7+
upstream's "list-my-accounts" endpoint with the buyer's bearer.
8+
9+
Without this factory, every Shape B adapter rolls the same ~30 LOC:
10+
extract bearer from ``auth_info``, GET ``/me/adaccounts``, match by
11+
id, return the mapped :class:`Account`. This factory handles the
12+
boilerplate; the adapter supplies the upstream specifics
13+
(``list_endpoint``, ``to_account`` mapper) and the auth shape via
14+
:func:`create_upstream_http_client`'s :class:`DynamicBearer.get_token`.
15+
16+
Mirrors the JS ``createOAuthPassthroughResolver`` from
17+
``@adcp/sdk@6.7`` (``src/lib/adapters/oauth-passthrough-resolver.ts``).
18+
19+
Picking an :class:`AccountStore`? Three reference shapes by *who creates
20+
the account*:
21+
22+
* **Buyer self-onboards via ``sync_accounts``** — implement
23+
:class:`AccountStoreUpsert` (Shape A).
24+
* **Upstream OAuth API owns the roster** —
25+
:func:`create_oauth_passthrough_resolver` (this module, Shape B,
26+
returns an :class:`AccountStore`).
27+
* **Publisher ops curates the roster** — your own
28+
:class:`AccountStore` impl backed by a database (Shape C).
29+
30+
**Pagination limitation.** The factory issues a single GET against
31+
``list_endpoint`` and treats the parsed body as the full account
32+
list. Upstreams that paginate (cursor / next-url envelopes) drop
33+
accounts beyond page one silently. Adopters with paginated upstreams
34+
must either aggregate pages inside ``extract_rows`` (synchronous
35+
collection of all pages before returning the list) or compose their
36+
own resolver. See the :class:`AccountStore` Protocol if you need
37+
streaming pagination.
38+
"""
39+
40+
from __future__ import annotations
41+
42+
import inspect
43+
from collections.abc import Awaitable, Callable
44+
from typing import Any, Literal
45+
46+
from adcp.decisioning.accounts import ResolveContext
47+
from adcp.decisioning.context import AuthInfo
48+
from adcp.decisioning.helpers import ref_account_id
49+
from adcp.decisioning.types import Account
50+
from adcp.decisioning.upstream import AuthContext, UpstreamHttpClient
51+
from adcp.types import AccountReference
52+
53+
__all__ = ["create_oauth_passthrough_resolver"]
54+
55+
56+
def _default_extract_rows(body: Any) -> list[Any] | None:
57+
"""Default unwrap for the common ``/me/adaccounts``-shaped APIs.
58+
59+
Accepts either a flat list (some plain-list APIs) or a
60+
``{"data": [...]}`` envelope. Returns ``None`` when the body
61+
doesn't match either shape, signalling "no rows".
62+
"""
63+
if body is None:
64+
return None
65+
if isinstance(body, list):
66+
return body
67+
if isinstance(body, dict):
68+
rows = body.get("data")
69+
if isinstance(rows, list):
70+
return rows
71+
return None
72+
73+
74+
def _default_auth_context(ctx: ResolveContext | None) -> AuthContext | None:
75+
"""Default ``get_auth_context``: forward ``ctx.auth_info`` verbatim.
76+
77+
Works when the http client's :class:`DynamicBearer.get_token`
78+
resolver reads the bearer off the :class:`AuthInfo` directly. The
79+
upstream client treats this as an opaque mapping; the factory
80+
doesn't interpret it.
81+
"""
82+
if ctx is None:
83+
return None
84+
return ctx.auth_info # type: ignore[return-value]
85+
86+
87+
class _OAuthPassthroughAccountStore:
88+
""":class:`AccountStore` impl backing :func:`create_oauth_passthrough_resolver`.
89+
90+
Public attributes match the :class:`AccountStore` Protocol so an
91+
instance plugs directly into :class:`DecisioningPlatform.accounts`.
92+
The resolve method takes ``(ref, auth_info=None)`` per the
93+
Protocol; the factory's ``to_account`` and ``get_auth_context``
94+
callbacks see a synthesised :class:`ResolveContext` so adopter
95+
callbacks have a uniform shape with the rest of the
96+
:class:`AccountStore` surface.
97+
"""
98+
99+
resolution: Literal["explicit"] = "explicit"
100+
101+
def __init__(
102+
self,
103+
*,
104+
http_client: UpstreamHttpClient,
105+
list_endpoint: str,
106+
to_account: Callable[
107+
[Any, ResolveContext | None],
108+
Account[Any] | Awaitable[Account[Any]],
109+
],
110+
id_field: str,
111+
extract_rows: Callable[[Any], list[Any] | None],
112+
get_auth_context: Callable[[ResolveContext | None], AuthContext | None],
113+
) -> None:
114+
self._http_client = http_client
115+
self._list_endpoint = list_endpoint
116+
self._to_account = to_account
117+
self._id_field = id_field
118+
self._extract_rows = extract_rows
119+
self._get_auth_context = get_auth_context
120+
121+
async def resolve(
122+
self,
123+
ref: AccountReference | dict[str, Any] | None,
124+
auth_info: AuthInfo | None = None,
125+
) -> Account[Any] | None:
126+
account_id = ref_account_id(ref)
127+
if account_id is None:
128+
return None
129+
130+
ctx = ResolveContext(auth_info=auth_info, tool_name="resolve")
131+
auth_ctx = self._get_auth_context(ctx)
132+
body = await self._http_client.get(
133+
self._list_endpoint,
134+
auth_context=auth_ctx,
135+
)
136+
rows = self._extract_rows(body)
137+
if rows is None:
138+
return None
139+
140+
for row in rows:
141+
row_id = (
142+
row.get(self._id_field)
143+
if isinstance(row, dict)
144+
else getattr(row, self._id_field, None)
145+
)
146+
if row_id == account_id:
147+
result = self._to_account(row, ctx)
148+
if inspect.isawaitable(result):
149+
return await result
150+
return result
151+
return None
152+
153+
154+
def create_oauth_passthrough_resolver(
155+
*,
156+
http_client: UpstreamHttpClient,
157+
list_endpoint: str,
158+
to_account: Callable[
159+
[Any, ResolveContext | None],
160+
Account[Any] | Awaitable[Account[Any]],
161+
],
162+
id_field: str = "id",
163+
extract_rows: Callable[[Any], list[Any] | None] | None = None,
164+
get_auth_context: Callable[[ResolveContext | None], AuthContext | None] | None = None,
165+
) -> _OAuthPassthroughAccountStore:
166+
"""Create an :class:`AccountStore` backed by an upstream
167+
OAuth-protected listing endpoint.
168+
169+
The returned object satisfies the :class:`AccountStore` Protocol
170+
(``resolution = 'explicit'``, ``resolve(ref, auth_info=None)``).
171+
Adopters wire it directly into :class:`DecisioningPlatform`::
172+
173+
class SnapSeller(DecisioningPlatform):
174+
accounts = create_oauth_passthrough_resolver(...)
175+
176+
Shape B adapters typically don't manage account lifecycle on the
177+
seller side, so the returned store implements only ``resolve`` —
178+
not the optional :meth:`AccountStoreUpsert.upsert` /
179+
:meth:`AccountStoreList.list` surfaces. Add those by wrapping the
180+
returned store in a class that delegates ``resolve`` and adds the
181+
upsert/list methods.
182+
183+
:param http_client: Pre-configured upstream HTTP client (typically
184+
from :func:`create_upstream_http_client`). Should be configured
185+
with :class:`DynamicBearer` so the per-request auth context
186+
flows through to bearer selection.
187+
:param list_endpoint: Path on the upstream API that returns the
188+
buyer's accounts. Common shapes: ``/v1/adaccounts``,
189+
``/me/adaccounts``, ``/customers``.
190+
:param to_account: Map an upstream row to a framework
191+
:class:`Account`. Receives the row and a synthesised
192+
:class:`ResolveContext` (carrying the caller's
193+
``auth_info``). Sync or async — the framework awaits the
194+
result either way.
195+
196+
**Treat any embedded credential in ``Account.metadata`` as a
197+
secret.** The framework strips ``metadata`` from the wire
198+
response, but adopter code that throws an error containing
199+
``json.dumps(account)`` or logs ``ctx.account`` at info level
200+
WILL leak it. Either don't embed the bearer (re-derive from
201+
``ctx.auth_info`` on each downstream method), or audit your
202+
error projections.
203+
:param id_field: Field on each upstream row that matches
204+
``AccountReference.account_id``. Defaults to ``"id"``. A typo
205+
here silently always returns ``None`` — verify against the
206+
upstream's documented response shape.
207+
:param extract_rows: Optional callback receiving the raw parsed
208+
upstream body and returning the row list. Defaults to: try the
209+
body if it's a list, else ``body["data"]`` if it's a dict with
210+
that key. Provide a custom callback for deeper-nested shapes
211+
(e.g. ``{"data": {"list": [...]}}``).
212+
:param get_auth_context: Extract the auth context to forward to the
213+
upstream's :meth:`DynamicBearer.get_token` resolver. The return
214+
value flows through as the per-call ``auth_context`` on
215+
:meth:`UpstreamHttpClient.get`. Defaults to forwarding
216+
``ctx.auth_info`` verbatim — works when the http client's token
217+
resolver reads from :class:`AuthInfo` directly.
218+
219+
Behavior:
220+
221+
* The returned store only handles the ``{account_id}``
222+
discriminated-union arm of :class:`AccountReference`. Other arms
223+
(``{brand, operator}``) and ``None`` ref return ``None`` without
224+
calling upstream. Adopters needing natural-key fallback compose
225+
their own resolver around this one.
226+
* Upstream errors propagate verbatim — ``http_client`` already
227+
projects non-2xx to spec-conformant :class:`AdcpError` codes
228+
(``AUTH_REQUIRED``, ``SERVICE_UNAVAILABLE``, etc.). Adopters
229+
compose error mapping over the result if they want a different
230+
shape.
231+
* 404 from the upstream listing endpoint surfaces as ``None`` (the
232+
http client's ``treat_404_as_none`` default), which the store
233+
treats as "no rows found".
234+
* **Pagination is not handled.** A single GET fetches the full
235+
list; paginated upstreams drop accounts beyond page one. See the
236+
module docstring for adopter workarounds.
237+
238+
Example::
239+
240+
from adcp.decisioning import (
241+
DynamicBearer,
242+
create_oauth_passthrough_resolver,
243+
create_upstream_http_client,
244+
)
245+
246+
async def get_token(ctx):
247+
# ctx is the AuthInfo forwarded by default get_auth_context.
248+
return ctx.credential.token
249+
250+
upstream = create_upstream_http_client(
251+
"https://upstream.example.com",
252+
auth=DynamicBearer(get_token=get_token),
253+
)
254+
255+
class UpstreamSeller(DecisioningPlatform):
256+
accounts = create_oauth_passthrough_resolver(
257+
http_client=upstream,
258+
list_endpoint="/v1/me/adaccounts",
259+
to_account=lambda row, ctx: Account(
260+
id=row["id"],
261+
name=row["name"],
262+
status="active",
263+
metadata={"upstream_id": row["id"]},
264+
),
265+
)
266+
"""
267+
return _OAuthPassthroughAccountStore(
268+
http_client=http_client,
269+
list_endpoint=list_endpoint,
270+
to_account=to_account,
271+
id_field=id_field,
272+
extract_rows=(extract_rows if extract_rows is not None else _default_extract_rows),
273+
get_auth_context=(
274+
get_auth_context if get_auth_context is not None else _default_auth_context
275+
),
276+
)

0 commit comments

Comments
 (0)