@larc-iu/plaid-client 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Cursor-pagination helpers for collection endpoints.
3
+ *
4
+ * The server's collection endpoints return a paginated envelope shaped like
5
+ * `{ entries: [...], nextCursor: "<opaque-string>" | null }` (the wire key
6
+ * `next-cursor` is camelCased to `nextCursor` by transformResponse). These
7
+ * helpers paper over the cursoring so callers can either get the full flat
8
+ * array transparently, request a single page, or iterate page-by-page.
9
+ *
10
+ * Each helper threads the client through to `client._request`, so auth
11
+ * headers, base URL, and response transforms all apply exactly as for any
12
+ * other request.
13
+ */
14
+
15
+ // Merge caller query params with paging params, dropping undefined/null so we
16
+ // don't emit empty query string values.
17
+ function buildQueryParams(query, limit, cursor) {
18
+ const params = { ...query };
19
+ if (limit !== undefined && limit !== null) params.limit = limit;
20
+ if (cursor !== undefined && cursor !== null) params.cursor = cursor;
21
+ return params;
22
+ }
23
+
24
+ /**
25
+ * Fetch every page and return the full flat array of entries, transparently
26
+ * following `nextCursor` until it is null. This is what `.list()` calls so it
27
+ * stays backward compatible with the pre-pagination (bare-array) contract.
28
+ *
29
+ * NOTE: This auto-paginates and therefore CANNOT be used inside a batch — each
30
+ * page's request needs the previous page's `nextCursor`, which doesn't exist
31
+ * until the batch executes. It throws immediately when `client.isBatching` is
32
+ * true. Use `listPage` for a single page inside a batch.
33
+ *
34
+ * @param {object} client - PlaidClient instance
35
+ * @param {string} path - API path, e.g. '/api/v1/projects'
36
+ * @param {object} [opts]
37
+ * @param {number} [opts.pageSize=1000] - Per-request page size (limit)
38
+ * @param {object} [opts.query={}] - Extra query params (e.g. { 'as-of': asOf })
39
+ * @returns {Promise<Array>} The concatenated entries across all pages
40
+ */
41
+ export async function listAll(client, path, { pageSize = 1000, query = {} } = {}) {
42
+ if (client.isBatching) {
43
+ throw new Error(
44
+ `Cannot auto-paginate ${path} inside a batch: list methods follow cursors across multiple requests, which a batch cannot do. Use listPage() for a single page inside a batch, or call list() outside the batch.`,
45
+ );
46
+ }
47
+ const all = [];
48
+ let cursor = null;
49
+ let prevCursor;
50
+ do {
51
+ const response = await client._request('GET', path, {
52
+ queryParams: buildQueryParams(query, pageSize, cursor),
53
+ });
54
+ // Compatibility shim: a non-paginated server (or proxy) may return a bare
55
+ // array. Treat it as a terminal full result with no further paging.
56
+ if (Array.isArray(response)) {
57
+ all.push(...response);
58
+ break;
59
+ }
60
+ if (response && Array.isArray(response.entries)) {
61
+ all.push(...response.entries);
62
+ } else {
63
+ throw new Error(
64
+ "Unexpected list response shape (no 'entries'); server may be incompatible.",
65
+ );
66
+ }
67
+ prevCursor = cursor;
68
+ cursor = response.nextCursor;
69
+ // Guard against a buggy server/proxy that returns a constant non-null
70
+ // cursor, which would otherwise loop forever.
71
+ if (cursor !== null && cursor !== undefined && cursor === prevCursor) {
72
+ throw new Error(
73
+ 'Pagination cursor did not advance; aborting to avoid an infinite loop.',
74
+ );
75
+ }
76
+ } while (cursor !== null && cursor !== undefined);
77
+ return all;
78
+ }
79
+
80
+ /**
81
+ * Fetch a single page and return the raw envelope.
82
+ *
83
+ * @param {object} client - PlaidClient instance
84
+ * @param {string} path - API path
85
+ * @param {object} [opts]
86
+ * @param {number} [opts.limit] - Page size (1..1000; server default 100)
87
+ * @param {string} [opts.cursor] - Opaque cursor from a previous page
88
+ * @param {object} [opts.query={}] - Extra query params
89
+ * @returns {Promise<{entries: Array, nextCursor: (string|null)}>}
90
+ */
91
+ export async function listPage(client, path, { limit, cursor, query = {} } = {}) {
92
+ return client._request('GET', path, {
93
+ queryParams: buildQueryParams(query, limit, cursor),
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Async generator yielding each page's entries array in turn, following
99
+ * `nextCursor` until it is null.
100
+ *
101
+ * NOTE: This auto-paginates and therefore CANNOT be used inside a batch — each
102
+ * page's request needs the previous page's `nextCursor`, which doesn't exist
103
+ * until the batch executes. It throws on first iteration when
104
+ * `client.isBatching` is true. Use `listPage` for a single page inside a batch.
105
+ *
106
+ * @param {object} client - PlaidClient instance
107
+ * @param {string} path - API path
108
+ * @param {object} [opts]
109
+ * @param {number} [opts.pageSize=1000] - Per-request page size (limit)
110
+ * @param {object} [opts.query={}] - Extra query params
111
+ * @yields {Array} The entries array for each page
112
+ */
113
+ export async function* iterPages(client, path, { pageSize = 1000, query = {} } = {}) {
114
+ if (client.isBatching) {
115
+ throw new Error(
116
+ `Cannot auto-paginate ${path} inside a batch: list methods follow cursors across multiple requests, which a batch cannot do. Use listPage() for a single page inside a batch, or call list() outside the batch.`,
117
+ );
118
+ }
119
+ let cursor = null;
120
+ let prevCursor;
121
+ do {
122
+ const response = await client._request('GET', path, {
123
+ queryParams: buildQueryParams(query, pageSize, cursor),
124
+ });
125
+ const entries = (response && Array.isArray(response.entries)) ? response.entries : [];
126
+ // Suppress the trailing empty page that the server emits when a collection's
127
+ // size is an exact multiple of the page size (a final full page with a
128
+ // non-null cursor, then an empty page). Still follow the cursor below.
129
+ if (entries.length > 0) {
130
+ yield entries;
131
+ }
132
+ prevCursor = cursor;
133
+ cursor = response ? response.nextCursor : null;
134
+ // Guard against a buggy server/proxy that returns a constant non-null
135
+ // cursor, which would otherwise loop forever.
136
+ if (cursor !== null && cursor !== undefined && cursor === prevCursor) {
137
+ throw new Error(
138
+ 'Pagination cursor did not advance; aborting to avoid an infinite loop.',
139
+ );
140
+ }
141
+ } while (cursor !== null && cursor !== undefined);
142
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Service coordination: discovery + server-mediated request/response RPC.
3
+ *
4
+ * All of this runs OFF the broadcast bus (`/listen` + `/message`). A service is
5
+ * present exactly while its inbound request channel (SSE) is open — that
6
+ * channel is the registration; there is no separate registry or heartbeat.
7
+ * Discovery is a synchronous GET. Work requests are addressed: a service
8
+ * receives them on its channel and reports back via plain POSTs that the
9
+ * server relays to the one waiting requester.
10
+ */
11
+ import { transformRequest, transformResponse } from './transforms.js';
12
+
13
+ /**
14
+ * Discover the services currently connected to a project — a synchronous GET.
15
+ * `timeout` is accepted for back-compat and ignored.
16
+ *
17
+ * @param {Object} client - PlaidClient instance
18
+ * @param {string} projectId - Project UUID
19
+ * @param {number} [timeout] - Ignored
20
+ * @returns {Promise<Array>} [{serviceId, serviceName, description, extras}]
21
+ */
22
+ export function discoverServices(client, projectId, timeout) {
23
+ return client._request('GET', `/api/v1/projects/${projectId}/services`);
24
+ }
25
+
26
+ /**
27
+ * Register a service and handle incoming work requests.
28
+ *
29
+ * Opens the service's dedicated request channel — which registers it for
30
+ * discovery (presence = open channel) — and handles work on it. For each
31
+ * request, runs `onServiceRequest(data, responseHelper)` where `responseHelper`
32
+ * has `progress(percent, msg)` / `complete(data)` / `error(err)`.
33
+ *
34
+ * @param {Object} client - PlaidClient instance
35
+ * @param {string} projectId - Project UUID
36
+ * @param {Object} serviceInfo - {serviceId, serviceName, description}
37
+ * @param {function} onServiceRequest - Handler callback (data, responseHelper)
38
+ * @param {Object} extras - Optional additional metadata
39
+ * @returns {Object} ServiceRegistration with .stop(), .isRunning(), .serviceInfo
40
+ */
41
+ export function serve(client, projectId, serviceInfo, onServiceRequest, extras = {}) {
42
+ const { serviceId, serviceName, description = '' } = serviceInfo;
43
+ let connection = null;
44
+ let isRunning = true;
45
+ let reconnectTimer = null;
46
+
47
+ const reportEvent = (requestId, body) =>
48
+ client
49
+ ._request('POST', `/api/v1/projects/${projectId}/service-requests/${encodeURIComponent(requestId)}/events`, { body })
50
+ .catch((error) => {
51
+ // 404 just means the requester already went away; nothing to do.
52
+ console.warn('Failed to report request event:', error.message || error);
53
+ });
54
+
55
+ const serviceRegistration = {
56
+ stop: () => {
57
+ isRunning = false;
58
+ if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
59
+ // Closing the channel deregisters the service server-side.
60
+ if (connection) connection.close();
61
+ },
62
+ isRunning: () => isRunning,
63
+ serviceInfo: { serviceId, serviceName, description, extras },
64
+ };
65
+
66
+ // Discovery metadata rides the channel's query string — opening the channel
67
+ // is the registration. Keep wire keys kebab-case (transform extras too) so
68
+ // they round-trip like the rest of the API.
69
+ const params = new URLSearchParams();
70
+ if (serviceName) params.set('service-name', serviceName);
71
+ if (description) params.set('description', description);
72
+ if (extras && Object.keys(extras).length) params.set('extras', JSON.stringify(transformRequest(extras)));
73
+ const qs = params.toString();
74
+ const channelPath = `/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests${qs ? `?${qs}` : ''}`;
75
+
76
+ // The channel only carries `connected` (ignored) and `service_request` events.
77
+ const onChannelEvent = (eventType, payload) => {
78
+ if (!isRunning) return true;
79
+ if (eventType !== 'service_request' || !payload) return;
80
+ const requestId = payload.requestId;
81
+ if (!requestId) return;
82
+
83
+ const responseHelper = {
84
+ progress: (percent, msg) =>
85
+ reportEvent(requestId, { status: 'progress', progress: { percent, message: msg } }),
86
+ complete: (data) =>
87
+ reportEvent(requestId, { status: 'completed', data }),
88
+ error: (error) =>
89
+ reportEvent(requestId, { status: 'error', data: { error: error?.message || error } }),
90
+ };
91
+
92
+ try {
93
+ onServiceRequest(payload.data, responseHelper);
94
+ } catch (error) {
95
+ responseHelper.error(error?.message || error);
96
+ }
97
+ };
98
+ const openChannel = () => client.messages.listen(projectId, onChannelEvent, channelPath);
99
+ try {
100
+ connection = openChannel();
101
+ } catch (error) {
102
+ throw new Error(`Failed to start service: ${error.message}`);
103
+ }
104
+
105
+ // Reopen the channel if it drops (e.g. the server restarted). Reopening
106
+ // re-registers the service server-side, so presence and reachability come
107
+ // back together.
108
+ reconnectTimer = setInterval(() => {
109
+ if (!isRunning) return;
110
+ if (connection && connection.readyState === 2) { // CLOSED (dropped)
111
+ try { connection = openChannel(); } catch (_) { /* retry next tick */ }
112
+ }
113
+ }, 3000);
114
+
115
+ return serviceRegistration;
116
+ }
117
+
118
+ /**
119
+ * Submit work to a service and await its result.
120
+ *
121
+ * Streams the service's progress + result back over a single server-mediated
122
+ * response (no broadcast). Rejects if no service is connected (503), if the
123
+ * service reports an error, or on timeout.
124
+ *
125
+ * @param {Object} client - PlaidClient instance
126
+ * @param {string} projectId - Project UUID
127
+ * @param {string} serviceId - Service ID to request
128
+ * @param {any} data - Request payload
129
+ * @param {number} [timeout=10000] - Timeout in ms
130
+ * @param {function} [onProgress] - Called with each progress payload {percent, message}
131
+ * @returns {Promise<any>} The service's result
132
+ */
133
+ export function requestService(client, projectId, serviceId, data, timeout = 10000, onProgress) {
134
+ return new Promise((resolve, reject) => {
135
+ const abortController = new AbortController();
136
+ let settled = false;
137
+ const finish = (fn, arg) => {
138
+ if (settled) return;
139
+ settled = true;
140
+ clearTimeout(timer);
141
+ abortController.abort();
142
+ fn(arg);
143
+ };
144
+ const timer = setTimeout(
145
+ () => finish(reject, new Error(`Service request timed out after ${timeout}ms`)),
146
+ timeout,
147
+ );
148
+
149
+ (async () => {
150
+ let response;
151
+ try {
152
+ response = await fetch(
153
+ `${client.baseUrl}/api/v1/projects/${projectId}/services/${encodeURIComponent(serviceId)}/requests`,
154
+ {
155
+ method: 'POST',
156
+ headers: {
157
+ 'Authorization': `Bearer ${client.token}`,
158
+ 'Content-Type': 'application/json',
159
+ 'Accept': 'text/event-stream',
160
+ },
161
+ body: JSON.stringify(data === undefined ? null : transformRequest(data)),
162
+ signal: abortController.signal,
163
+ },
164
+ );
165
+ } catch (error) {
166
+ if (error.name !== 'AbortError') finish(reject, new Error(`Failed to submit service request: ${error.message}`));
167
+ return;
168
+ }
169
+
170
+ if (response.status === 503) {
171
+ finish(reject, new Error(`No live service '${serviceId}' on this project`));
172
+ return;
173
+ }
174
+ if (!response.ok) {
175
+ finish(reject, new Error(`Service request failed: HTTP ${response.status} ${response.statusText}`));
176
+ return;
177
+ }
178
+
179
+ const reader = response.body.getReader();
180
+ const decoder = new TextDecoder();
181
+ let buffer = '';
182
+ let eventType = '';
183
+ let dataLine = '';
184
+
185
+ try {
186
+ while (true) {
187
+ const { done, value } = await reader.read();
188
+ if (done || settled) break;
189
+ buffer += decoder.decode(value, { stream: true });
190
+ const lines = buffer.split('\n');
191
+ buffer = lines.pop() || '';
192
+ for (const rawLine of lines) {
193
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
194
+ if (line.startsWith('event: ')) {
195
+ eventType = line.slice(7).trim();
196
+ } else if (line.startsWith('data: ')) {
197
+ dataLine = line.slice(6);
198
+ } else if (line === '' && eventType && dataLine) {
199
+ const payload = transformResponse(JSON.parse(dataLine));
200
+ if (eventType === 'progress') {
201
+ if (onProgress) { try { onProgress(payload.progress); } catch (_) { /* ignore */ } }
202
+ } else if (eventType === 'result') {
203
+ finish(resolve, payload.data);
204
+ return;
205
+ } else if (eventType === 'error') {
206
+ finish(reject, new Error(payload?.error || 'Service request failed'));
207
+ return;
208
+ }
209
+ eventType = '';
210
+ dataLine = '';
211
+ }
212
+ }
213
+ }
214
+ finish(reject, new Error('Service closed the connection without a result'));
215
+ } catch (error) {
216
+ if (error.name !== 'AbortError') finish(reject, new Error(`Service request stream error: ${error.message}`));
217
+ }
218
+ })();
219
+ });
220
+ }
package/src/sse.js ADDED
@@ -0,0 +1,147 @@
1
+ import { transformResponse } from './transforms.js';
2
+
3
+ /**
4
+ * Create an SSE connection to the listen endpoint using fetch-based streaming.
5
+ * Automatically handles heartbeat confirmations and event parsing.
6
+ *
7
+ * @param {Object} client - PlaidClient instance
8
+ * @param {string} projectId - Project UUID
9
+ * @param {function} onEvent - Callback (eventType, data). Return true to stop.
10
+ * @param {string} [path] - Stream path under baseUrl. Defaults to the project
11
+ * /listen bus; service request channels pass their own. Only /listen emits
12
+ * `heartbeat` events needing a POST confirmation — other streams keep
13
+ * themselves alive with ignored SSE comments.
14
+ * @returns {Object} SSE connection with .close(), .getStats(), .readyState
15
+ */
16
+ export function createSSEConnection(client, projectId, onEvent, path) {
17
+ const streamPath = path || `/api/v1/projects/${projectId}/listen`;
18
+ const startTime = Date.now();
19
+ let isConnected = false;
20
+ let isClosed = false;
21
+ let clientId = null;
22
+ let eventStats = { 'audit-log': 0, message: 0, heartbeat: 0, connected: 0, other: 0 };
23
+ let abortController = new AbortController();
24
+
25
+ const sendHeartbeatConfirmation = async () => {
26
+ if (!clientId || isClosed) return;
27
+ try {
28
+ const response = await fetch(`${client.baseUrl}/api/v1/projects/${projectId}/heartbeat`, {
29
+ method: 'POST',
30
+ headers: {
31
+ 'Authorization': `Bearer ${client.token}`,
32
+ 'Content-Type': 'application/json',
33
+ },
34
+ body: JSON.stringify({ 'client-id': clientId }),
35
+ signal: abortController.signal,
36
+ });
37
+ if (!response.ok) { /* heartbeat failed */ }
38
+ } catch (error) {
39
+ if (error.name !== 'AbortError') { /* heartbeat error */ }
40
+ }
41
+ };
42
+
43
+ const sseConnection = {
44
+ readyState: 0, // CONNECTING
45
+ close: () => {
46
+ if (!isClosed) {
47
+ isClosed = true;
48
+ isConnected = false;
49
+ sseConnection.readyState = 2; // CLOSED
50
+ abortController.abort();
51
+ }
52
+ },
53
+ getStats: () => ({
54
+ durationSeconds: (Date.now() - startTime) / 1000,
55
+ isConnected,
56
+ isClosed,
57
+ clientId,
58
+ events: { ...eventStats },
59
+ readyState: sseConnection.readyState,
60
+ }),
61
+ };
62
+
63
+ // Start the streaming connection
64
+ (async () => {
65
+ try {
66
+ const url = `${client.baseUrl}${streamPath}`;
67
+ const response = await fetch(url, {
68
+ method: 'GET',
69
+ headers: {
70
+ 'Authorization': `Bearer ${client.token}`,
71
+ 'Accept': 'text/event-stream',
72
+ 'Cache-Control': 'no-cache',
73
+ },
74
+ signal: abortController.signal,
75
+ });
76
+
77
+ if (!response.ok) {
78
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
79
+ }
80
+
81
+ isConnected = true;
82
+ sseConnection.readyState = 1; // OPEN
83
+
84
+ const reader = response.body.getReader();
85
+ const decoder = new TextDecoder();
86
+ let buffer = '';
87
+
88
+ // Event state persists across read chunks: an event's `event:` and
89
+ // `data:` lines may land in separate reads, so these are reset only
90
+ // after an event is dispatched (mirrors the Python client).
91
+ let eventType = '';
92
+ let data = '';
93
+
94
+ while (true) {
95
+ const { done, value } = await reader.read();
96
+ if (done || isClosed) break;
97
+
98
+ buffer += decoder.decode(value, { stream: true });
99
+ const lines = buffer.split('\n');
100
+ buffer = lines.pop() || '';
101
+
102
+ for (const rawLine of lines) {
103
+ // Strip a trailing CR so CRLF-delimited streams parse cleanly.
104
+ const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
105
+ if (line.startsWith('event: ')) {
106
+ eventType = line.slice(7).trim();
107
+ } else if (line.startsWith('data: ')) {
108
+ data = line.slice(6);
109
+ } else if (line === '' && eventType && data) {
110
+ try {
111
+ eventStats[eventType] = (eventStats[eventType] || 0) + 1;
112
+
113
+ if (eventType === 'connected') {
114
+ const parsedData = JSON.parse(data);
115
+ clientId = parsedData['client-id'] || parsedData.clientId;
116
+ } else if (eventType === 'heartbeat') {
117
+ sendHeartbeatConfirmation();
118
+ } else {
119
+ const parsedData = JSON.parse(data);
120
+ const shouldStop = onEvent(eventType, transformResponse(parsedData));
121
+ if (shouldStop === true) {
122
+ sseConnection.close();
123
+ return;
124
+ }
125
+ }
126
+ } catch (e) {
127
+ console.warn('Failed to parse SSE event data:', e);
128
+ }
129
+
130
+ eventType = '';
131
+ data = '';
132
+ }
133
+ }
134
+ }
135
+ } catch (error) {
136
+ if (error.name !== 'AbortError') {
137
+ console.warn('SSE connection error:', error);
138
+ }
139
+ } finally {
140
+ isConnected = false;
141
+ isClosed = true;
142
+ sseConnection.readyState = 2; // CLOSED
143
+ }
144
+ })();
145
+
146
+ return sseConnection;
147
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Convert kebab-case/namespaced key to camelCase.
3
+ * 'layer-id' -> 'layerId'
4
+ * 'relation/layer' -> 'layer' (namespace stripped)
5
+ * Hyphens before a digit are also consumed ('layer-2' -> 'layer2') so no stray
6
+ * hyphen is ever left in the key. (The Python client uses snake_case, where the
7
+ * analogous key is 'layer_2' — the local spelling differs by convention, but
8
+ * neither leaves a separator that doesn't belong to the convention.)
9
+ */
10
+ export function transformKeyToCamel(key) {
11
+ return key.replace(/^[^/]+\//, '').replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase());
12
+ }
13
+
14
+ /**
15
+ * Convert camelCase key to kebab-case.
16
+ * 'layerId' -> 'layer-id'
17
+ */
18
+ export function transformKeyFromCamel(key) {
19
+ return key.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
20
+ }
21
+
22
+ // `metadata` and `config` are opaque, client-agnostic buckets: their contents
23
+ // are arbitrary user/application data whose keys must NOT vary by client. We
24
+ // pass their values through verbatim (no recursion) so object keys inside them
25
+ // are never re-cased or namespace-stripped — a label like `case-marker` used as
26
+ // a map key survives intact. Everything else is API envelope and gets the
27
+ // usual case conversion.
28
+ const OPAQUE_KEYS = new Set(['metadata', 'config']);
29
+
30
+ /**
31
+ * Recursively transform request object keys from camelCase to kebab-case.
32
+ * Preserves `metadata` and `config` contents without transformation.
33
+ */
34
+ export function transformRequest(obj) {
35
+ if (obj === null || obj === undefined) return obj;
36
+ if (Array.isArray(obj)) return obj.map(item => transformRequest(item));
37
+ if (typeof obj !== 'object') return obj;
38
+
39
+ const transformed = {};
40
+ for (const [key, value] of Object.entries(obj)) {
41
+ const newKey = transformKeyFromCamel(key);
42
+ if (OPAQUE_KEYS.has(key) && typeof value === 'object' && value !== null && !Array.isArray(value)) {
43
+ transformed[newKey] = value;
44
+ } else {
45
+ transformed[newKey] = transformRequest(value);
46
+ }
47
+ }
48
+ return transformed;
49
+ }
50
+
51
+ /**
52
+ * Recursively transform response object keys from kebab-case/namespaced to camelCase.
53
+ * Preserves `metadata` and `config` contents without transformation.
54
+ */
55
+ export function transformResponse(obj) {
56
+ if (obj === null || obj === undefined) return obj;
57
+ if (Array.isArray(obj)) return obj.map(item => transformResponse(item));
58
+ if (typeof obj !== 'object') return obj;
59
+
60
+ const transformed = {};
61
+ for (const [key, value] of Object.entries(obj)) {
62
+ const newKey = transformKeyToCamel(key);
63
+ if (OPAQUE_KEYS.has(newKey) && typeof value === 'object' && value !== null && !Array.isArray(value)) {
64
+ transformed[newKey] = value;
65
+ } else {
66
+ transformed[newKey] = transformResponse(value);
67
+ }
68
+ }
69
+ return transformed;
70
+ }