@x12i/helpers 1.0.1 → 1.0.2

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,459 @@
1
+ /**
2
+ * ╔══════════════════════════════════════════════════════════════╗
3
+ * ║ API MAPPER ║
4
+ * ╠══════════════════════════════════════════════════════════════╣
5
+ * ║ Translates API calls between two contracts/protocols. ║
6
+ * ║ REST ↔ GraphQL ↔ JSON-RPC ↔ SOAP ↔ Custom ║
7
+ * ║ ║
8
+ * ║ Uses json-mapper.js for deep property mapping of both ║
9
+ * ║ request params and response bodies. ║
10
+ * ╚══════════════════════════════════════════════════════════════╝
11
+ *
12
+ * BRIDGE CONFIG:
13
+ * ┌──────────────────────────────────────────────────────────────┐
14
+ * │ { │
15
+ * │ source: Contract, // caller's view │
16
+ * │ target: Contract, // actual API │
17
+ * │ requestMapping: [...], // json-mapper: src → tgt │
18
+ * │ responseMapping: [...], // json-mapper: tgt → src │
19
+ * │ beforeRequest: fn, // middleware │
20
+ * │ afterResponse: fn, // middleware │
21
+ * │ errorMapping: {...}, // status→handler │
22
+ * │ retry: {...}, // attempts/backoff │
23
+ * │ credentials: {...}, // per-side auth │
24
+ * │ } │
25
+ * └──────────────────────────────────────────────────────────────┘
26
+ */
27
+
28
+ const { mapObject } = require("./json-mapper.js");
29
+ const { defineContract } = require("./api-contracts.js");
30
+
31
+ // ═══════════════════════════════════════════════════════════════
32
+ // HELPERS
33
+ // ═══════════════════════════════════════════════════════════════
34
+
35
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
36
+
37
+ function buildUrl(base, query = {}) {
38
+ const qs = Object.entries(query)
39
+ .filter(([, v]) => v != null)
40
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
41
+ .join("&");
42
+ return qs ? `${base}?${qs}` : base;
43
+ }
44
+
45
+ function resolveContract(ref, registry) {
46
+ if (typeof ref === "string") {
47
+ if (!registry) throw new Error(`String contract "${ref}" needs a registry`);
48
+ return registry.get(ref);
49
+ }
50
+ return ref._protocol ? ref : defineContract(ref);
51
+ }
52
+
53
+ // ═══════════════════════════════════════════════════════════════
54
+ // DEFAULT HTTP CLIENT (Node 18+ / browser fetch)
55
+ // ═══════════════════════════════════════════════════════════════
56
+
57
+ async function defaultHttpClient(req) {
58
+ const url = buildUrl(req.url, req.query);
59
+ const opts = { method: req.method, headers: req.headers || {} };
60
+ if (req.body !== undefined) {
61
+ opts.body = typeof req.body === "string" ? req.body : JSON.stringify(req.body);
62
+ }
63
+ const res = await fetch(url, opts);
64
+ let body;
65
+ const ct = res.headers.get("content-type") || "";
66
+ if (ct.includes("json")) body = await res.json();
67
+ else if (ct.includes("xml") || ct.includes("text")) body = await res.text();
68
+ else { try { body = await res.json(); } catch { body = await res.text(); } }
69
+
70
+ return {
71
+ status: res.status,
72
+ statusText: res.statusText,
73
+ headers: Object.fromEntries(res.headers.entries()),
74
+ body,
75
+ };
76
+ }
77
+
78
+ // ═══════════════════════════════════════════════════════════════
79
+ // RETRY
80
+ // ═══════════════════════════════════════════════════════════════
81
+
82
+ async function executeWithRetry(executor, request, cfg) {
83
+ const {
84
+ attempts = 3,
85
+ delay = 1000,
86
+ backoff = "exponential",
87
+ retryOn = (r) => r.status >= 500,
88
+ } = cfg;
89
+
90
+ let last;
91
+ for (let i = 0; i < attempts; i++) {
92
+ last = await executor(request);
93
+ if (!retryOn(last)) return last;
94
+ if (i < attempts - 1) {
95
+ const wait =
96
+ backoff === "exponential" ? delay * 2 ** i :
97
+ backoff === "linear" ? delay * (i + 1) : delay;
98
+ await sleep(wait);
99
+ }
100
+ }
101
+ return last;
102
+ }
103
+
104
+ // ═══════════════════════════════════════════════════════════════
105
+ // CORE: createBridge
106
+ // ═══════════════════════════════════════════════════════════════
107
+
108
+ function createBridge(config) {
109
+ const {
110
+ requestMapping = [],
111
+ responseMapping = [],
112
+ beforeRequest,
113
+ afterResponse,
114
+ errorMapping = {},
115
+ retry: retryConfig,
116
+ credentials = {},
117
+ registry,
118
+ httpClient,
119
+ } = config;
120
+
121
+ const source = resolveContract(config.source, registry);
122
+ const target = resolveContract(config.target, registry);
123
+
124
+ async function call(sourceParams, opts = {}) {
125
+ const creds = { ...credentials, ...opts.credentials };
126
+
127
+ // 1 — validate source params
128
+ const sv = source.validateRequest(sourceParams);
129
+ if (!sv.valid) {
130
+ return { ok: false, error: "Source validation failed", validationErrors: sv.errors, data: null };
131
+ }
132
+
133
+ // 2 — map source params → target params
134
+ let targetParams = requestMapping.length
135
+ ? mapObject(sourceParams, requestMapping)
136
+ : { ...sourceParams };
137
+
138
+ // 3 — build normalized HTTP request via target protocol
139
+ let targetReq = target.buildRequest(targetParams, creds.target);
140
+
141
+ // 4 — middleware: beforeRequest
142
+ if (beforeRequest) targetReq = beforeRequest(targetReq, sourceParams) || targetReq;
143
+ if (opts.beforeRequest) targetReq = opts.beforeRequest(targetReq, sourceParams) || targetReq;
144
+
145
+ // 5 — dry-run: return translated request without calling
146
+ if (opts.dryRun) {
147
+ return {
148
+ ok: true, dryRun: true,
149
+ sourceContract: source.name, targetContract: target.name,
150
+ sourceParams, targetParams, targetRequest: targetReq, data: null,
151
+ };
152
+ }
153
+
154
+ // 6 — execute
155
+ const exec = httpClient || opts.httpClient || defaultHttpClient;
156
+ const rawRes = retryConfig
157
+ ? await executeWithRetry(exec, targetReq, retryConfig)
158
+ : await exec(targetReq);
159
+
160
+ // 7 — parse via target protocol
161
+ const parsed = target.parseResponse(rawRes);
162
+
163
+ // 8 — error mapping
164
+ if (parsed.error) {
165
+ const h = errorMapping[String(rawRes.status)] || errorMapping["*"];
166
+ const err = h ? h(parsed.error, rawRes) : parsed.error;
167
+ return { ok: false, error: err, status: parsed.status, data: null, raw: parsed };
168
+ }
169
+
170
+ // 9 — map response data → source shape
171
+ let data = parsed.data;
172
+ if (responseMapping.length && data && typeof data === "object") {
173
+ data = mapObject(data, responseMapping);
174
+ }
175
+
176
+ // 10 — validate response
177
+ const rv = source.validateResponse(data);
178
+
179
+ // 11 — middleware: afterResponse
180
+ if (afterResponse) data = afterResponse(data, parsed) || data;
181
+ if (opts.afterResponse) data = opts.afterResponse(data, parsed) || data;
182
+
183
+ return {
184
+ ok: true, status: parsed.status, data,
185
+ headers: parsed.headers, raw: parsed,
186
+ ...(rv.valid ? {} : { responseWarnings: rv.errors }),
187
+ };
188
+ }
189
+
190
+ function explain() {
191
+ return {
192
+ from: { name: source.name, protocol: source.protocol },
193
+ to: { name: target.name, protocol: target.protocol },
194
+ requestMappingRules: requestMapping.length,
195
+ responseMappingRules: responseMapping.length,
196
+ hasMiddleware: !!(beforeRequest || afterResponse),
197
+ hasRetry: !!retryConfig,
198
+ hasErrorMapping: Object.keys(errorMapping).length > 0,
199
+ };
200
+ }
201
+
202
+ return { call, explain, source, target };
203
+ }
204
+
205
+ // ═══════════════════════════════════════════════════════════════
206
+ // BATCH
207
+ // ═══════════════════════════════════════════════════════════════
208
+
209
+ async function batchCall(bridge, paramsList, opts = {}) {
210
+ const { concurrency = 5, onProgress } = opts;
211
+
212
+ if (concurrency >= paramsList.length) {
213
+ return Promise.all(
214
+ paramsList.map((p, i) =>
215
+ bridge.call(p, opts).then((r) => { if (onProgress) onProgress(i, r); return r; })
216
+ )
217
+ );
218
+ }
219
+ const results = new Array(paramsList.length);
220
+ let cursor = 0;
221
+ async function worker() {
222
+ while (cursor < paramsList.length) {
223
+ const idx = cursor++;
224
+ results[idx] = await bridge.call(paramsList[idx], opts);
225
+ if (onProgress) onProgress(idx, results[idx]);
226
+ }
227
+ }
228
+ await Promise.all(Array.from({ length: concurrency }, worker));
229
+ return results;
230
+ }
231
+
232
+ // ═══════════════════════════════════════════════════════════════
233
+ // CHAIN (pipe: A→B→C)
234
+ // ═══════════════════════════════════════════════════════════════
235
+
236
+ function chainBridges(...bridges) {
237
+ return {
238
+ async call(sourceParams, opts = {}) {
239
+ let current = sourceParams;
240
+ const trace = [];
241
+ for (const b of bridges) {
242
+ const r = await b.call(current, opts);
243
+ trace.push({ from: b.source.name, to: b.target.name, ok: r.ok, status: r.status });
244
+ if (!r.ok) return { ...r, trace };
245
+ current = r.data;
246
+ }
247
+ return { ok: true, data: current, trace };
248
+ },
249
+ explain: () => bridges.map((b) => b.explain()),
250
+ };
251
+ }
252
+
253
+ // ═══════════════════════════════════════════════════════════════
254
+
255
+ module.exports = { createBridge, batchCall, chainBridges, defaultHttpClient };
256
+
257
+ // ══════════════════════════════════════════════════════════════════
258
+ // DEMO — node api-mapper.js
259
+ // ══════════════════════════════════════════════════════════════════
260
+
261
+ if (require.main === module) {
262
+ const { defineContract } = require("./api-contracts.js");
263
+
264
+ // ── Mock HTTP (no real network) ──
265
+ function mockHttp(req) {
266
+ console.log(`\n ┌─ TARGET HTTP REQUEST ─────────────────────`);
267
+ console.log(` │ ${req.method} ${buildUrl(req.url, req.query)}`);
268
+ console.log(` │ Content-Type: ${req.headers["Content-Type"]}`);
269
+ if (req.headers.Authorization) console.log(` │ Auth: ${req.headers.Authorization}`);
270
+ if (req.headers.SOAPAction) console.log(` │ SOAPAction: ${req.headers.SOAPAction}`);
271
+ if (req.body) {
272
+ const preview = typeof req.body === "string"
273
+ ? req.body.substring(0, 200)
274
+ : JSON.stringify(req.body, null, 2);
275
+ preview.split("\n").forEach((l) => console.log(` │ ${l}`));
276
+ }
277
+ console.log(` └─────────────────────────────────────────────`);
278
+
279
+ const ct = req.headers?.["Content-Type"] || "";
280
+ if (ct.includes("xml")) {
281
+ return Promise.resolve({ status: 200, headers: { "content-type": "text/xml" },
282
+ body: `<GetStockPriceResponse><Price>182.63</Price><Currency>USD</Currency></GetStockPriceResponse>` });
283
+ }
284
+ if (req.body?.jsonrpc) {
285
+ return Promise.resolve({ status: 200, headers: { "content-type": "application/json" },
286
+ body: { jsonrpc: "2.0", id: req.body.id, result: { balance: "3.14159", unit: "ETH" } } });
287
+ }
288
+ if (req.body?.query) {
289
+ return Promise.resolve({ status: 200, headers: { "content-type": "application/json" },
290
+ body: { data: { user: { id: "42", fullName: "Jane Doe", emailAddress: "jane@example.com", role: "ADMIN" } } } });
291
+ }
292
+ return Promise.resolve({ status: 200, headers: { "content-type": "application/json" },
293
+ body: { id: 42, name: "Jane Doe", email: "jane@example.com", role: "admin" } });
294
+ }
295
+
296
+ async function demo() {
297
+
298
+ // ═════════════════════════════════════════
299
+ // 1: REST → GraphQL
300
+ // ═════════════════════════════════════════
301
+ console.log("\n╔═══════════════════════════════════════════════════╗");
302
+ console.log("║ 1 — REST caller → GraphQL backend ║");
303
+ console.log("╚═══════════════════════════════════════════════════╝");
304
+
305
+ const restGetUser = defineContract({
306
+ name: "restGetUser", protocol: "rest",
307
+ endpoint: "/api/users/{id}", method: "GET",
308
+ auth: { type: "bearer" },
309
+ requestSchema: {
310
+ pathParams: { type: "object", required: true,
311
+ properties: { id: { type: "string", required: true } } },
312
+ },
313
+ });
314
+
315
+ const gqlGetUser = defineContract({
316
+ name: "gqlGetUser", protocol: "graphql",
317
+ endpoint: "https://api.example.com/graphql",
318
+ query: `query GetUser($userId: ID!) { user(id: $userId) { id fullName emailAddress role } }`,
319
+ auth: { type: "bearer" },
320
+ });
321
+
322
+ const bridge1 = createBridge({
323
+ source: restGetUser,
324
+ target: gqlGetUser,
325
+ requestMapping: [
326
+ { from: "pathParams.id", to: "variables.userId" },
327
+ ],
328
+ responseMapping: [
329
+ "user.id",
330
+ "user.fullName -> user.name",
331
+ "user.emailAddress -> user.email",
332
+ { from: "user.role", to: "user.role", transform: (v) => v?.toLowerCase() },
333
+ ],
334
+ credentials: { target: { token: "gql-token-abc" } },
335
+ });
336
+
337
+ const r1 = await bridge1.call({ pathParams: { id: "42" } }, { httpClient: mockHttp });
338
+ console.log("\n ✅ MAPPED RESPONSE (REST shape):", JSON.stringify(r1.data, null, 4));
339
+
340
+ // ═════════════════════════════════════════
341
+ // 2: REST → JSON-RPC
342
+ // ═════════════════════════════════════════
343
+ console.log("\n╔═══════════════════════════════════════════════════╗");
344
+ console.log("║ 2 — REST caller → JSON-RPC backend ║");
345
+ console.log("╚═══════════════════════════════════════════════════╝");
346
+
347
+ const restBalance = defineContract({
348
+ name: "restGetBalance", protocol: "rest",
349
+ endpoint: "/api/wallets/{address}/balance", method: "GET",
350
+ });
351
+ const rpcBalance = defineContract({
352
+ name: "rpcGetBalance", protocol: "jsonrpc",
353
+ endpoint: "https://rpc.example.com",
354
+ rpcMethod: "eth_getBalance", paramsAsArray: true,
355
+ });
356
+
357
+ const bridge2 = createBridge({
358
+ source: restBalance,
359
+ target: rpcBalance,
360
+ requestMapping: [
361
+ { from: "pathParams.address", to: "rpcParams.address" },
362
+ { compute: () => "latest", to: "rpcParams.block" },
363
+ ],
364
+ responseMapping: [
365
+ { from: "balance", to: "balance" },
366
+ { from: "unit", to: "currency" },
367
+ ],
368
+ });
369
+
370
+ const r2 = await bridge2.call({ pathParams: { address: "0xABC123" } }, { httpClient: mockHttp });
371
+ console.log("\n ✅ MAPPED RESPONSE:", JSON.stringify(r2.data, null, 4));
372
+
373
+ // ═════════════════════════════════════════
374
+ // 3: REST → SOAP
375
+ // ═════════════════════════════════════════
376
+ console.log("\n╔═══════════════════════════════════════════════════╗");
377
+ console.log("║ 3 — REST caller → SOAP backend ║");
378
+ console.log("╚═══════════════════════════════════════════════════╝");
379
+
380
+ const restStock = defineContract({
381
+ name: "restGetStock", protocol: "rest",
382
+ endpoint: "/api/stocks/{symbol}", method: "GET",
383
+ });
384
+ const soapStock = defineContract({
385
+ name: "soapGetStock", protocol: "soap",
386
+ endpoint: "https://legacy.example.com/StockService",
387
+ operation: "GetStockPrice",
388
+ namespace: "http://example.com/stocks",
389
+ soapAction: "http://example.com/stocks/GetStockPrice",
390
+ responseExtractor: (xml) => {
391
+ const price = xml.match(/<Price>(.*?)<\/Price>/)?.[1];
392
+ const currency = xml.match(/<Currency>(.*?)<\/Currency>/)?.[1];
393
+ return { price: parseFloat(price), currency };
394
+ },
395
+ });
396
+
397
+ const bridge3 = createBridge({
398
+ source: restStock,
399
+ target: soapStock,
400
+ requestMapping: [
401
+ { from: "pathParams.symbol", to: "soapBody.StockSymbol" },
402
+ ],
403
+ responseMapping: [
404
+ { from: "price", to: "price" },
405
+ { from: "currency", to: "currency" },
406
+ ],
407
+ });
408
+
409
+ const r3 = await bridge3.call({ pathParams: { symbol: "AAPL" } }, { httpClient: mockHttp });
410
+ console.log("\n ✅ MAPPED RESPONSE:", JSON.stringify(r3.data, null, 4));
411
+
412
+ // ═════════════════════════════════════════
413
+ // 4: DRY RUN
414
+ // ═════════════════════════════════════════
415
+ console.log("\n╔═══════════════════════════════════════════════════╗");
416
+ console.log("║ 4 — Dry-run (inspect without calling) ║");
417
+ console.log("╚═══════════════════════════════════════════════════╝");
418
+
419
+ const dry = await bridge1.call({ pathParams: { id: "99" } }, { dryRun: true });
420
+ console.log(JSON.stringify(dry, null, 2));
421
+
422
+ // ═════════════════════════════════════════
423
+ // 5: CHAIN (REST → GQL → JSON-RPC)
424
+ // ═════════════════════════════════════════
425
+ console.log("\n╔═══════════════════════════════════════════════════╗");
426
+ console.log("║ 5 — Chain: REST → GraphQL → JSON-RPC ║");
427
+ console.log("╚═══════════════════════════════════════════════════╝");
428
+
429
+ const enrichBridge = createBridge({
430
+ source: gqlGetUser,
431
+ target: rpcBalance,
432
+ requestMapping: [
433
+ { from: "user.id", to: "rpcParams.address" },
434
+ { compute: () => "latest", to: "rpcParams.block" },
435
+ ],
436
+ responseMapping: [
437
+ { from: "balance", to: "walletBalance" },
438
+ { from: "unit", to: "walletCurrency" },
439
+ ],
440
+ });
441
+
442
+ const chain = chainBridges(bridge1, enrichBridge);
443
+ const cr = await chain.call({ pathParams: { id: "42" } }, { httpClient: mockHttp });
444
+ console.log("\n ✅ CHAIN RESULT:", JSON.stringify(cr, null, 2));
445
+
446
+ // ═════════════════════════════════════════
447
+ // EXPLAIN
448
+ // ═════════════════════════════════════════
449
+ console.log("\n╔═══════════════════════════════════════════════════╗");
450
+ console.log("║ Bridge Introspection ║");
451
+ console.log("╚═══════════════════════════════════════════════════╝");
452
+ [bridge1, bridge2, bridge3].forEach((b) => {
453
+ const e = b.explain();
454
+ console.log(` ${e.from.name} (${e.from.protocol}) → ${e.to.name} (${e.to.protocol}) [${e.requestMappingRules} req, ${e.responseMappingRules} res rules]`);
455
+ });
456
+ }
457
+
458
+ demo().catch(console.error);
459
+ }