|
| 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